Named worktrees
--worktree <name> re-roots the entire run — run log, lock, agent
diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts
index 601bd75..4a068a8 100644
--- a/src/cli/args.test.ts
+++ b/src/cli/args.test.ts
@@ -208,6 +208,33 @@ describe('parseArgs', () => {
});
});
+ describe('--parallel-phases (EXPERIMENTAL cooperative waves)', () => {
+ it('parses with --phased --autonomous', async () => {
+ const a = await parseArgs([
+ 'run', '--goal', 'g', '--verify-cmd', 'true', '--phased', '--autonomous', '--parallel-phases',
+ ]);
+ expect(a.config.parallelPhases).toBe(true);
+ expect(a.config.phased).toBe(true);
+ });
+
+ it('defaults OFF (grouped plans run sequentially without the flag)', async () => {
+ const a = await parseArgs(['run', '--goal', 'g', '--verify-cmd', 'true', '--phased', '--autonomous']);
+ expect(a.config.parallelPhases).toBe(false);
+ });
+
+ it('rejects --parallel-phases without --phased (fail-closed)', async () => {
+ await expect(
+ parseArgs(['run', '--goal', 'g', '--verify-cmd', 'true', '--autonomous', '--parallel-phases']),
+ ).rejects.toThrow(/--phased/);
+ });
+
+ it('rejects --parallel-phases without --autonomous (children seal concurrently)', async () => {
+ await expect(
+ parseArgs(['run', '--goal', 'g', '--verify-cmd', 'true', '--phased', '--parallel-phases']),
+ ).rejects.toThrow(/--autonomous/);
+ });
+ });
+
describe('--resume-best-of-incomplete (issue #85 follow-up)', () => {
it('defaults to rerun when absent (byte-for-byte the historical behavior)', async () => {
const a = await parseArgs(['run', '--goal', 'g', '--verify-cmd', 'true']);
diff --git a/src/cli/args.ts b/src/cli/args.ts
index 13a7bf3..5c75e8e 100644
--- a/src/cli/args.ts
+++ b/src/cli/args.ts
@@ -212,7 +212,7 @@ Usage:
[--install-missing-tools true|false]
[--rubric ""] [--autonomous] [--max-iterations N] [--candidates N]
[--phased [--max-phases N] [--max-plan-revisions N] [--plan-file ]
- [--planner-model ]]
+ [--planner-model ] [--parallel-phases]]
[--max-seal-revisions N] [--max-compile-retries N] [--verify-dir ]
[--budget-tokens N] [--budget-wall-ms N] [--diff-ignore ""]
[--stuck-no-diff true|false] [--stuck-repeat-threshold N]
@@ -351,6 +351,19 @@ Phased decomposition (issue #48 — split one big goal into a frozen plan of sma
--max-plan-revisions N cap the free-text plan-Seal revise rounds (default 10; 0 disables revision).
--planner-model model for the planner step only (cascades like the other LLM-step models).
--autonomous also auto-accepts the plan AND each phase contract — still frozen + logged loudly.
+ --parallel-phases EXPERIMENTAL, opt-in — cooperative parallel WAVES: consecutive plan phases
+ sharing a "group" value (plan-file: {"goal": …, "group": 1}) execute
+ CONCURRENTLY, each as its own frozen, two-key CHILD goaly run in an isolated
+ git worktree on the SHARED --budget-tokens meter. The children are then merged
+ in phase order (3-way git merge-tree — plumbing only, no commits) and each
+ merged phase's frozen DETERMINISTIC rungs are RE-VERIFIED on the combined tree
+ — a merge is never trusted. Fail-closed everywhere: a merge conflict, a red
+ re-verify, or a child that can't reach DONE simply DOWNGRADES that phase to
+ the classic sequential run on the merged tree (the bar never moves, only the
+ starting tree); the cumulative ACCEPTANCE contract still gates the whole run.
+ Requires --phased and --autonomous (children seal concurrently). Without this
+ flag, grouped plans run strictly sequentially. Resume note: a crash mid-wave
+ re-runs the WHOLE wave on --resume (children live in ephemeral worktrees).
Best-of-N parallel worker (issue #85 — tournament-select candidates against the frozen ladder):
--candidates N (alias --best-of N) run N independent worker attempts EACH loop iteration in
@@ -1014,6 +1027,7 @@ export async function parseArgs(
? { resumeBestOfIncomplete: parseResumeBestOfIncomplete(flags) }
: {}),
...(flags['phased'] !== undefined ? { phased: true } : {}),
+ ...(flags['parallel-phases'] !== undefined ? { parallelPhases: true } : {}),
...(str(flags, 'max-phases') !== undefined ? { maxPhases: str(flags, 'max-phases') } : {}),
...(str(flags, 'max-plan-revisions') !== undefined
? { maxPlanRevisions: str(flags, 'max-plan-revisions') }
@@ -1070,6 +1084,24 @@ export async function parseArgs(
const harness = parseHarness(str(flags, 'harness'));
const config = cliInputToRunConfig(cliInput);
+
+ // EXPERIMENTAL parallel waves: the fan-out only exists inside a phased plan (grouped sub-goals),
+ // and wave children compile + Seal their contracts CONCURRENTLY — an interactive gate cannot pause
+ // K children at once, so autonomy is required (the contracts are still frozen + logged loudly).
+ if (config.parallelPhases && !resuming) {
+ if (!config.phased) {
+ throw new UsageError(
+ "--parallel-phases parallelizes a phased plan's grouped sub-goals — pair it with --phased " +
+ '(and mark consecutive phases with a shared "group" in the plan)',
+ );
+ }
+ if (!config.autonomous) {
+ throw new UsageError(
+ '--parallel-phases requires --autonomous: wave children seal their frozen contracts ' +
+ 'concurrently and cannot pause at interactive gates (each contract is still frozen + logged)',
+ );
+ }
+ }
// Explicitness for the resume extension is judged on CLI flags ONLY (never the config-file
// overlay): a `.goalyrc` default like "budget-tokens" must not append a RUN_EXTENDED marker to
// the log on every resume — an extension is an explicit per-invocation operator act.
diff --git a/src/cli/compose.ts b/src/cli/compose.ts
index 4a1ecfd..940de8c 100644
--- a/src/cli/compose.ts
+++ b/src/cli/compose.ts
@@ -38,6 +38,7 @@ import { AgentCliHarness } from '../harness/agent-cli-harness';
import { SystemClock } from '../driver/clock';
import { SystemBudgetMeter } from '../driver/budget';
import { LlmTokenMeter, meterLlm } from '../driver/llm-meter';
+import { DefaultWaveRunner } from '../driver/wave-runner';
import { buildLogger, type FileLogOptions } from '../log/build';
import type { Logger, LogLevel } from '../log/logger';
import type { LogFs } from '../log/sinks';
@@ -191,6 +192,13 @@ export type ComposeOptions = {
sealGate?: SealGate;
/** Inject the plan-Seal gate (phased runs), same rules as {@link sealGate}. */
planGate?: PlanGate;
+ /**
+ * Inject the harness adapter per workspace root (tests/embedders) — bypasses {@link harness}
+ * selection. The FACTORY shape (not a single adapter) exists for EXPERIMENTAL parallel waves,
+ * where each wave child composes its own deps rooted at its worktree: the factory receives that
+ * root so a scripted test harness can write into the right tree.
+ */
+ harnessFactory?: (workspaceRoot: string) => HarnessAdapter;
};
/**
@@ -361,11 +369,12 @@ export function composeDeps(config: RunConfig, options: ComposeOptions): DriverD
options.egressProxy,
);
const workspace = new GitWorkspace(options.workspaceRoot, undefined, excludes, true, runLauncher);
- // Best-of-N worktree host (issue #85): only wired when `--candidates > 1` (a `--candidates 1` run
- // never touches it). It shares the canonical root / exec / excludes / verify-jail so each candidate's
- // isolated worktree hashes + scores identically to the canonical workspace.
+ // Worktree host: wired for best-of-N (issue #85, `--candidates > 1`) and for EXPERIMENTAL
+ // cooperative parallel waves (`--parallel-phases`) — a run using neither never touches it. It
+ // shares the canonical root / exec / excludes / verify-jail so each isolated worktree hashes +
+ // scores identically to the canonical workspace.
const worktrees =
- config.candidates > 1
+ config.candidates > 1 || (config.phased && config.parallelPhases)
? new GitWorktreeHost({
root: options.workspaceRoot,
exec: realExec,
@@ -470,6 +479,39 @@ export function composeDeps(config: RunConfig, options: ComposeOptions): DriverD
// detected, never assumed — a non-code workspace yields `undefined` and nothing is injected.
const workspaceFacts = detectWorkspaceFacts(options.workspaceRoot);
+ // ONE budget meter for the whole run — hoisted so EXPERIMENTAL parallel-wave children share it
+ // (the `--budget-tokens` cap governs the fan-out, not each child separately).
+ const budget = new SystemBudgetMeter(config.budget, clock);
+
+ // EXPERIMENTAL cooperative parallel waves (`--parallel-phases`): each wave CHILD is a FULL goaly
+ // run composed by this very function, rooted at its ephemeral worktree — its own frozen contract,
+ // two-key gate, and write-ahead log (under `/.goaly`), on the parent's budget meter and
+ // interrupt probe. Parent-anchored artifact paths (log/stream/state overrides, the diff baseline)
+ // are stripped so children never write into the parent's files.
+ const wave =
+ config.phased && config.parallelPhases && worktrees !== undefined
+ ? new DefaultWaveRunner({
+ host: worktrees,
+ workspace,
+ workspaceRoot: options.workspaceRoot,
+ ...(timeouts.verifyMs !== undefined ? { verifyTimeoutMs: timeouts.verifyMs } : {}),
+ logger,
+ composeChild: async (spec, worktree, childRunId, interrupted) => {
+ const { logFile: _lf, streamFile: _sf, stateDir: _sd, baseline: _b, ...rest } = options;
+ const childDeps = composeDeps(spec.config, {
+ ...rest,
+ workspaceRoot: worktree.root,
+ runId: childRunId,
+ });
+ return {
+ ...childDeps,
+ budget,
+ ...(interrupted !== undefined ? { interrupted } : {}),
+ };
+ },
+ })
+ : undefined;
+
return {
compiler: seedCompiler(
critiqueCompiler(
@@ -500,14 +542,16 @@ export function composeDeps(config: RunConfig, options: ComposeOptions): DriverD
: new HumanSealGate({ allowRevise: config.maxSealRevisions > 0 })),
...(phasedSeams !== undefined ? phasedSeams : {}),
harness:
- options.harness === 'goaly-code'
- ? makeGoalyCodeHarness(options, models, stateDir, logger, launcher)
- : makeHarness(options.harness, models.harness, timeouts.harnessMs, timeouts.harnessIdleMs, {
- launcher,
- workspace: options.workspaceRoot,
- policy: options.sandbox ?? defaultPolicy(),
- ...(options.egressProxy !== undefined ? { proxy: options.egressProxy } : {}),
- }),
+ options.harnessFactory !== undefined
+ ? options.harnessFactory(options.workspaceRoot)
+ : options.harness === 'goaly-code'
+ ? makeGoalyCodeHarness(options, models, stateDir, logger, launcher)
+ : makeHarness(options.harness, models.harness, timeouts.harnessMs, timeouts.harnessIdleMs, {
+ launcher,
+ workspace: options.workspaceRoot,
+ policy: options.sandbox ?? defaultPolicy(),
+ ...(options.egressProxy !== undefined ? { proxy: options.egressProxy } : {}),
+ }),
makeLadder: (contract) => {
// Surface the frozen authored bar (`generatedFiles`) in the diff the two LLM keys review, even
// though it's git-excluded (issue #52) from the user's `git status`. Without this the judge sees
@@ -540,8 +584,9 @@ export function composeDeps(config: RunConfig, options: ComposeOptions): DriverD
prepareLlm: llmFor(models.judge, 'preflight'),
workspace,
...(worktrees !== undefined ? { worktrees } : {}),
+ ...(wave !== undefined ? { wave } : {}),
clock,
- budget: new SystemBudgetMeter(config.budget, clock),
+ budget,
llmMeter,
runlog: new FileRunLog(path.join(stateDir, options.runId)),
logger,
diff --git a/src/cli/compose.wave.test.ts b/src/cli/compose.wave.test.ts
new file mode 100644
index 0000000..43a16f8
--- /dev/null
+++ b/src/cli/compose.wave.test.ts
@@ -0,0 +1,196 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { composeDeps } from './compose';
+import { drive } from '../driver/driver';
+import { makeConfig } from '../testing/fakes';
+import { asRunId, coerceSessionId, type SessionId } from '../domain/ids';
+import type { HarnessAdapter } from '../harness/adapter';
+import type { LlmProvider } from '../llm/provider';
+import { runProcess } from '../util/spawn';
+
+/**
+ * The parallel-wave pipeline END TO END on REAL git — real worktrees, real 3-way merges, real
+ * promotion — with zero LLM tokens and zero agent CLIs: the LLM is routed by prompt content (each
+ * step's schema marker) so the CONCURRENT children can't race a scripted queue, and the harness is
+ * a scripted writer that creates whatever file its sub-goal names, inside its own worktree.
+ */
+
+const routedLlm: LlmProvider = {
+ name: 'routed-fake-llm',
+ async complete(req) {
+ // The schema/marker may live in `system` (the compiler's session-style calls) or the prompt.
+ const p = `${req.system ?? ''}\n${req.prompt}`;
+ // The usage-gate shape classification (compile phase).
+ if (p.includes('"buildAndUse"')) {
+ return { text: '{"buildAndUse":false,"targetArtifact":null,"reason":"n/a"}' };
+ }
+ // The Sign-off approver (child sign-offs + the acceptance sign-off).
+ if (p.includes('{"veto"')) return { text: '{"veto": false}' };
+ // The per-child authoring compiler: a deterministic bar per sub-goal, no rubric (no judge rung).
+ if (p.includes('"command": string')) {
+ if (p.includes('a.txt')) return { text: '{"command":"test -f a.txt","rubric":""}' };
+ if (p.includes('b.txt')) return { text: '{"command":"test -f b.txt","rubric":""}' };
+ return { text: '{"command":"true","rubric":""}' };
+ }
+ throw new Error(`unrouted LLM prompt: ${p.slice(0, 160)}`);
+ },
+};
+
+/** A worker that "achieves" its sub-goal by writing the file the prompt names — in ITS OWN root. */
+function scriptedWriter(root: string): HarnessAdapter {
+ return {
+ name: 'scripted-wave-writer',
+ async run(prompt: string, sessionId?: SessionId) {
+ const id = sessionId ?? coerceSessionId('scripted', 'scripted');
+ if (prompt.includes('a.txt')) await writeFile(path.join(root, 'a.txt'), 'alpha\n');
+ if (prompt.includes('b.txt')) await writeFile(path.join(root, 'b.txt'), 'beta\n');
+ return { output: 'did the work', sessionId: id, status: 'completed' as const };
+ },
+ };
+}
+
+async function initRepo(): Promise {
+ const dir = await mkdtemp(path.join(tmpdir(), 'goaly-wave-e2e-'));
+ await runProcess('git', ['-C', dir, 'init', '-q']);
+ await runProcess('git', ['-C', dir, 'config', 'user.email', 't@example.com']);
+ await runProcess('git', ['-C', dir, 'config', 'user.name', 'tester']);
+ await writeFile(path.join(dir, 'README.md'), '# fixture\n');
+ await runProcess('git', ['-C', dir, 'add', '-A']);
+ await runProcess('git', ['-C', dir, 'commit', '-qm', 'init']);
+ return dir;
+}
+
+describe('parallel waves END TO END (compose + drive, real git, routed fake LLM)', () => {
+ let dir: string | null = null;
+ afterEach(async () => {
+ if (dir !== null) await rm(dir, { recursive: true, force: true });
+ dir = null;
+ });
+
+ it('runs a grouped plan as one wave: children fork, merge cleanly, re-verify, acceptance gates DONE', async () => {
+ dir = await initRepo();
+ // Two INDEPENDENT sub-goals sharing wave group 1 — the whole plan is one wave + acceptance.
+ await writeFile(
+ path.join(dir, 'plan.json'),
+ JSON.stringify({
+ phases: [
+ { goal: 'create a file a.txt containing alpha', group: 1 },
+ { goal: 'create a file b.txt containing beta', group: 1 },
+ ],
+ }),
+ );
+ const config = makeConfig({
+ goal: 'produce both fixture files',
+ // The ORIGINAL verifier becomes the cumulative acceptance bar on the whole merged tree.
+ verifier: { kind: 'existing', ref: 'test -f a.txt && test -f b.txt' },
+ autonomous: true,
+ phased: true,
+ parallelPhases: true,
+ });
+ const runId = asRunId('run-wave-e2e');
+ const deps = composeDeps(config, {
+ harness: 'fake',
+ harnessFactory: scriptedWriter,
+ workspaceRoot: dir,
+ runId,
+ noLogConsole: true,
+ llm: routedLlm,
+ planFile: path.join(dir, 'plan.json'),
+ });
+
+ const outcome = await drive(deps, config, runId);
+
+ // The whole run reaches DONE through the acceptance contract's two keys.
+ expect(outcome.status).toBe('DONE');
+ // BOTH children's work landed in the canonical tree via the 3-way merge (disjoint files).
+ expect(await readFile(path.join(dir, 'a.txt'), 'utf8')).toBe('alpha\n');
+ expect(await readFile(path.join(dir, 'b.txt'), 'utf8')).toBe('beta\n');
+
+ // The parent log carries ONE WAVE_RAN with both phases merged — and the reducer never saw the
+ // children's iterations (each child kept its own write-ahead log in its worktree).
+ const stored = await deps.runlog.read();
+ const wave = stored?.entries.find((e) => e.event.tag === 'WAVE_RAN');
+ expect(wave?.event.tag).toBe('WAVE_RAN');
+ if (wave?.event.tag === 'WAVE_RAN') {
+ expect(wave.event.outcomes.map((o) => o.kind)).toEqual(['merged', 'merged']);
+ }
+ // No stray worktrees left behind.
+ const wt = await runProcess('git', ['-C', dir, 'worktree', 'list']);
+ expect(wt.stdout.trim().split('\n')).toHaveLength(1);
+ });
+
+ it('a conflicting wave member downgrades to the classic sequential phase and the run still finishes', async () => {
+ dir = await initRepo();
+ // BOTH sub-goals write the SAME file with different content — the second merge must conflict,
+ // downgrade to a sequential re-run on the merged tree, and the run must still reach DONE.
+ await writeFile(
+ path.join(dir, 'plan.json'),
+ JSON.stringify({
+ phases: [
+ { goal: 'create clash.txt saying alpha', group: 1 },
+ { goal: 'make clash.txt say beta instead', group: 1 },
+ ],
+ }),
+ );
+ const conflictLlm: LlmProvider = {
+ name: 'routed-fake-llm',
+ async complete(req) {
+ const p = `${req.system ?? ''}\n${req.prompt}`;
+ if (p.includes('"buildAndUse"')) {
+ return { text: '{"buildAndUse":false,"targetArtifact":null,"reason":"n/a"}' };
+ }
+ if (p.includes('{"veto"')) return { text: '{"veto": false}' };
+ if (p.includes('"command": string')) {
+ if (p.includes('beta')) return { text: '{"command":"grep -q beta clash.txt","rubric":""}' };
+ return { text: '{"command":"grep -q alpha clash.txt","rubric":""}' };
+ }
+ throw new Error(`unrouted LLM prompt: ${p.slice(0, 160)}`);
+ },
+ };
+ const conflictWriter = (root: string): HarnessAdapter => ({
+ name: 'scripted-conflict-writer',
+ async run(prompt: string, sessionId?: SessionId) {
+ const id = sessionId ?? coerceSessionId('scripted', 'scripted');
+ // Each child rewrites the SAME file; the sequential fallback then runs on the merged tree.
+ if (prompt.includes('beta')) await writeFile(path.join(root, 'clash.txt'), 'beta\n');
+ else await writeFile(path.join(root, 'clash.txt'), 'alpha\n');
+ return { output: 'did the work', sessionId: id, status: 'completed' as const };
+ },
+ });
+ const config = makeConfig({
+ goal: 'end with clash.txt saying beta',
+ verifier: { kind: 'existing', ref: 'grep -q beta clash.txt' },
+ autonomous: true,
+ phased: true,
+ parallelPhases: true,
+ });
+ const runId = asRunId('run-wave-conflict');
+ const deps = composeDeps(config, {
+ harness: 'fake',
+ harnessFactory: conflictWriter,
+ workspaceRoot: dir,
+ runId,
+ noLogConsole: true,
+ llm: conflictLlm,
+ planFile: path.join(dir, 'plan.json'),
+ });
+
+ const outcome = await drive(deps, config, runId);
+
+ expect(outcome.status).toBe('DONE');
+ expect(await readFile(path.join(dir, 'clash.txt'), 'utf8')).toBe('beta\n');
+
+ const stored = await deps.runlog.read();
+ const wave = stored?.entries.find((e) => e.event.tag === 'WAVE_RAN');
+ expect(wave?.event.tag).toBe('WAVE_RAN');
+ if (wave?.event.tag === 'WAVE_RAN') {
+ const kinds = wave.event.outcomes.map((o) => o.kind).sort();
+ expect(kinds).toEqual(['merged', 'unmerged']); // one landed, one downgraded fail-closed
+ }
+ // The downgraded phase re-ran through the CLASSIC sequential path: its own frozen contract.
+ const contracts = stored?.entries.filter((e) => e.event.tag === 'CONTRACT_COMPILED') ?? [];
+ expect(contracts.length).toBeGreaterThanOrEqual(2); // the fallback phase + acceptance
+ });
+});
diff --git a/src/cli/watch.ts b/src/cli/watch.ts
index db75fce..5f0e907 100644
--- a/src/cli/watch.ts
+++ b/src/cli/watch.ts
@@ -138,6 +138,12 @@ export function renderWatchEvent(entry: RunLogEntry, iteration: number): string
];
return `${at} operator extension: ${parts.join(', ')}`;
}
+ case 'WAVE_RAN': {
+ const merged = e.outcomes.filter((o) => o.kind === 'merged').length;
+ const fallback = e.outcomes.length - merged;
+ const tail = fallback > 0 ? `, ${fallback} downgraded to sequential` : '';
+ return `${at} wave: ${merged}/${e.outcomes.length} phase(s) merged + re-verified${tail}`;
+ }
case 'CHECKPOINTED':
return null; // internal diff-baseline plumbing — noise for a human watcher
}
diff --git a/src/domain/config.ts b/src/domain/config.ts
index d58af16..bfbc31b 100644
--- a/src/domain/config.ts
+++ b/src/domain/config.ts
@@ -139,6 +139,17 @@ export const RunConfig = z.object({
* reads this in `initial()` to seed PLANNING instead of COMPILING.
*/
phased: z.boolean().default(false),
+ /**
+ * EXPERIMENTAL — cooperative parallel waves (`--parallel-phases`, opt-in). When true, CONSECUTIVE
+ * plan phases sharing a `group` value execute as one concurrent WAVE: each phase runs as its own
+ * frozen, two-key CHILD goaly run in an isolated worktree (sharing this run's budget), then the
+ * children are merged in phase order and each merged phase's frozen ladder is RE-VERIFIED on the
+ * combined tree — a merge is never trusted. Any conflict / red re-verify downgrades that phase to
+ * the classic sequential run (fail-closed; the bar never moves, only the starting tree). Requires
+ * `phased` + `autonomous` (child contracts cannot pause at interactive Seals mid-fan-out). Default
+ * false ⇒ grouped plans still run strictly sequentially — byte-for-byte the classic phased run.
+ */
+ parallelPhases: z.boolean().default(false),
/** Max sub-goals a phased plan may contain; a planner that exceeds it is a fail-closed PLAN_FAILED. */
maxPhases: z.number().int().positive().default(10),
/**
@@ -266,6 +277,7 @@ export type LoopPolicy = Pick<
| 'stuckPolicy'
| 'budget'
| 'phased'
+ | 'parallelPhases'
| 'maxPhases'
| 'installMissingTools'
>;
@@ -287,6 +299,7 @@ export const pickLoopPolicy = (c: LoopPolicy): LoopPolicy => ({
stuckPolicy: c.stuckPolicy,
budget: c.budget,
phased: c.phased,
+ parallelPhases: c.parallelPhases,
maxPhases: c.maxPhases,
installMissingTools: c.installMissingTools,
});
@@ -330,6 +343,8 @@ export const CliInput = z.object({
resumeBestOfIncomplete: z.enum(['rerun', 'collapse']).optional(),
/** Phased decomposition (issue #48). */
phased: z.coerce.boolean().optional(),
+ /** EXPERIMENTAL cooperative parallel waves (`--parallel-phases`; requires phased + autonomous). */
+ parallelPhases: z.coerce.boolean().optional(),
maxPhases: z.coerce.number().int().positive().optional(),
maxPlanRevisions: z.coerce.number().int().nonnegative().optional(),
budgetTokens: z.coerce.number().int().positive().optional(),
@@ -445,6 +460,7 @@ export function cliInputToRunConfig(input: CliInput): RunConfig {
? { resumeBestOfIncomplete: input.resumeBestOfIncomplete }
: {}),
...(input.phased !== undefined ? { phased: input.phased } : {}),
+ ...(input.parallelPhases !== undefined ? { parallelPhases: input.parallelPhases } : {}),
...(input.maxPhases !== undefined ? { maxPhases: input.maxPhases } : {}),
...(input.maxPlanRevisions !== undefined
? { maxPlanRevisions: input.maxPlanRevisions }
diff --git a/src/domain/events.ts b/src/domain/events.ts
index c3c9a31..966af05 100644
--- a/src/domain/events.ts
+++ b/src/domain/events.ts
@@ -107,6 +107,45 @@ export const OrchestratorEvent = z.discriminatedUnion('tag', [
* (like CHECKPOINTED) AND drives the reducer's advance to the next phase's contract compile.
*/
z.object({ tag: z.literal('PHASE_ADVANCED'), tree: DiffHash }),
+ /**
+ * EXPERIMENTAL — a cooperative parallel WAVE completed (`--parallel-phases`): consecutive grouped
+ * phases ran concurrently as isolated, frozen, two-key CHILD runs; the Driver merged the DONE
+ * children in phase order and RE-VERIFIED each merged phase's frozen ladder on the combined tree.
+ * One outcome per wave member:
+ * - `merged` — child DONE, merged clean, frozen ladder green on the combined tree ⇒ the phase
+ * is complete (the reducer SKIPS it when advancing).
+ * - `unmerged` — the child failed to land (merge conflict, red re-verify, or a non-DONE child
+ * outcome) ⇒ FAIL-CLOSED downgrade: the phase re-runs as a classic sequential
+ * phase on the merged-so-far tree (a fresh frozen contract on the same sub-goal —
+ * the bar never moves, only the starting tree).
+ * Carries the post-merge checkpoint tree (the baseline for whatever follows, like PHASE_ADVANCED).
+ * Fed to `step()` (it drives the advance) AND read by replay for baseline reconstruction.
+ */
+ z.object({
+ tag: z.literal('WAVE_RAN'),
+ outcomes: z
+ .array(
+ z.discriminatedUnion('kind', [
+ z.object({
+ kind: z.literal('merged'),
+ /** The plan phase index this outcome belongs to. */
+ index: z.number().int().nonnegative(),
+ /** The child run's total spend (all layers), for the parent's usage fold. */
+ usage: TokenUsage.optional(),
+ }),
+ z.object({
+ kind: z.literal('unmerged'),
+ index: z.number().int().nonnegative(),
+ /** Why the child did not land (conflict / red re-verify / child FAILED-ABORTED / error). */
+ reason: z.string(),
+ usage: TokenUsage.optional(),
+ }),
+ ]),
+ )
+ .min(1),
+ /** The post-merge checkpoint tree — the diff baseline for the phases that follow. */
+ tree: DiffHash,
+ }),
z.object({
tag: z.literal('CONTRACT_COMPILED'),
contract: CompiledContract,
@@ -304,7 +343,17 @@ export type Command =
*/
| { tag: 'RUN_AGENT_BEST_OF'; prompt: string; sessionId: SessionId | undefined; candidates: number }
| { tag: 'RUN_VERIFIER'; contract: CompiledContract }
- | { tag: 'REQUEST_SIGNOFF'; goal: string; rubric: string; verdicts: Verdict[] };
+ | { tag: 'REQUEST_SIGNOFF'; goal: string; rubric: string; verdicts: Verdict[] }
+ /**
+ * EXPERIMENTAL — run a cooperative parallel WAVE (`--parallel-phases`): the consecutive grouped
+ * phases at `phases[i].index`, each as its own frozen, two-key CHILD goaly run in an isolated
+ * worktree (per-phase config derived by the reducer exactly as for a sequential phase), then merge
+ * the DONE children in phase order and re-verify each merged ladder on the combined tree. The
+ * Driver performs the whole wave through the injected {@link WaveRunner} seam and feeds back ONE
+ * `WAVE_RAN` event. Emitted INSTEAD of the first phase's `COMPILE_VERIFIER` when the plan groups
+ * consecutive phases and `config.parallelPhases` is on — still exactly one command per state.
+ */
+ | { tag: 'RUN_WAVE'; phases: { index: number; config: RunConfig }[] };
/** Terminal result of a whole run. */
export const RunOutcome = z.object({
diff --git a/src/domain/plan.ts b/src/domain/plan.ts
index f8375d0..894c83f 100644
--- a/src/domain/plan.ts
+++ b/src/domain/plan.ts
@@ -13,6 +13,14 @@ export const SubGoal = z.object({
intent: z.string().optional(),
/** Optional rubric guidance for this phase's LLM-judge portion (frozen with the phase contract). */
rubric: z.string().optional(),
+ /**
+ * EXPERIMENTAL — cooperative parallel waves (opt-in via `--parallel-phases`): CONSECUTIVE phases
+ * sharing a `group` value form a WAVE that executes concurrently (each phase as its own frozen,
+ * two-key child run in an isolated worktree) and is then merged and RE-VERIFIED fail-closed.
+ * Absent (the default) ⇒ the phase is strictly sequential, byte-for-byte the classic plan. The
+ * grouping is part of the frozen plan (hashed), so no transition can re-shuffle it.
+ */
+ group: z.number().int().nonnegative().optional(),
});
export type SubGoal = z.infer;
@@ -52,6 +60,28 @@ export function canonicalPlanString(p: UnhashedPlan): string {
goal: s.goal,
intent: s.intent ?? null,
rubric: s.rubric ?? null,
+ // `group` (parallel waves) is included ONLY when set, so every pre-existing plan keeps the
+ // planHash it always had (back-compat), while a grouped plan's grouping is frozen into the hash.
+ ...(s.group !== undefined ? { group: s.group } : {}),
}));
return JSON.stringify({ phases });
}
+
+/**
+ * The CONSECUTIVE indices sharing `plan.phases[index]`'s wave group, starting at `index` (which must
+ * be the group's first member for a fan-out to trigger). Returns `[index]` alone when the phase has
+ * no group, the group has one member, or `index` is mid-group (a resumed sequential fallback walks
+ * the remaining members one at a time — never re-fans-out from the middle). Pure and total.
+ */
+export function waveIndicesAt(plan: PhasePlan, index: number): readonly number[] {
+ const phase = plan.phases[index];
+ if (phase === undefined || phase.group === undefined) return [index];
+ // Mid-group entry (a sequential fallback / resume) never re-fans-out.
+ if (index > 0 && plan.phases[index - 1]?.group === phase.group) return [index];
+ const wave: number[] = [index];
+ for (let i = index + 1; i < plan.phases.length; i += 1) {
+ if (plan.phases[i]?.group !== phase.group) break;
+ wave.push(i);
+ }
+ return wave;
+}
diff --git a/src/driver/driver.ts b/src/driver/driver.ts
index 249971e..488c11d 100644
--- a/src/driver/driver.ts
+++ b/src/driver/driver.ts
@@ -18,6 +18,7 @@ import type { PlanGate } from '../plan/plan-gate';
import type { HarnessAdapter } from '../harness/adapter';
import type { Verifier } from '../verify/verifier';
import type { Approver } from '../verify/approver';
+import type { WaveRunner } from './wave';
import type { LlmProvider } from '../llm/provider';
import type { Workspace, WorktreeHost } from '../workspace/workspace';
import type { Clock } from './clock';
@@ -86,6 +87,12 @@ export type DriverDeps = {
* but this is absent, the run refuses to start (fail-closed).
*/
worktrees?: WorktreeHost;
+ /**
+ * EXPERIMENTAL — the cooperative parallel-wave seam (`--parallel-phases`). Used ONLY when a
+ * grouped, phased run fans a wave out; absent ⇒ a `RUN_WAVE` fails closed by DOWNGRADING every
+ * wave member to the classic sequential phase (never a crash, never a skipped phase).
+ */
+ wave?: WaveRunner;
clock: Clock;
budget: BudgetMeter;
/**
@@ -632,6 +639,40 @@ async function perform(
return { event: { tag: 'PLAN_SEAL_DECIDED', decision } };
}
+ case 'RUN_WAVE': {
+ // EXPERIMENTAL parallel waves: the whole fan-out + merge + re-verify happens behind the
+ // injected seam; the reducer sees ONE WAVE_RAN. Fail-closed on every failure shape: a missing
+ // runner or a thrown runner DOWNGRADES every wave member to the classic sequential phase
+ // (`unmerged`) — never a crash, never a skipped phase, never an unverified merge.
+ try {
+ if (deps.wave === undefined) {
+ throw new Error('parallel waves require a wave runner, but none was configured');
+ }
+ const result = await deps.wave.run(command.phases, deps.interrupted);
+ log.info('wave completed', {
+ phases: command.phases.length,
+ merged: result.outcomes.filter((o) => o.kind === 'merged').length,
+ });
+ return { event: { tag: 'WAVE_RAN', outcomes: result.outcomes, tree: result.tree } };
+ } catch (e) {
+ log.warn('wave runner failed — downgrading every wave member to sequential', {
+ reason: errorMessage(e),
+ });
+ const tree = await deps.workspace.checkpoint();
+ return {
+ event: {
+ tag: 'WAVE_RAN',
+ outcomes: command.phases.map((p) => ({
+ kind: 'unmerged' as const,
+ index: p.index,
+ reason: `wave fan-out unavailable: ${errorMessage(e)}`,
+ })),
+ tree,
+ },
+ };
+ }
+ }
+
case 'CHECKPOINT_AND_ADVANCE': {
// Between-phase checkpoint (issue #47): snapshot the tree (advancing the diff baseline so the
// next phase diffs only its own delta) and return the tree on PHASE_ADVANCED — which both drives
diff --git a/src/driver/driver.wave.test.ts b/src/driver/driver.wave.test.ts
new file mode 100644
index 0000000..9ca3876
--- /dev/null
+++ b/src/driver/driver.wave.test.ts
@@ -0,0 +1,72 @@
+import { describe, it, expect } from 'vitest';
+import { drive, type DriverDeps } from './driver';
+import { asRunId } from '../domain/ids';
+import {
+ FakeApprover,
+ FakeCompiler,
+ FakeHarness,
+ FakePlanGate,
+ FakePlanner,
+ FakeSealGate,
+ FakeVerifier,
+ FakeWorkspace,
+ InMemoryRunLog,
+ ManualBudgetMeter,
+ ManualClock,
+ approve,
+ makeConfig,
+ makeFakeContract,
+ makeFakePlan,
+ passVerdict,
+} from '../testing/fakes';
+
+describe('driver — RUN_WAVE fail-closed (EXPERIMENTAL parallel waves)', () => {
+ it('a wave with NO runner configured downgrades EVERY member to sequential and the run still finishes', async () => {
+ // A grouped, parallel-enabled plan… but the deps carry no `wave` seam (an embedder that never
+ // wired one). The wave must degrade to the classic sequential phased run — never crash, never
+ // skip a phase, never green anything unverified.
+ const plan = makeFakePlan({
+ phases: [
+ { goal: 'member A', group: 1 },
+ { goal: 'member B', group: 1 },
+ ],
+ });
+ const config = makeConfig({ phased: true, parallelPhases: true, autonomous: true });
+ const workspace = new FakeWorkspace('0000000');
+ const runlog = new InMemoryRunLog();
+ const deps: DriverDeps = {
+ planner: new FakePlanner(plan),
+ planGate: new FakePlanGate(),
+ compiler: new FakeCompiler(makeFakeContract()),
+ seal: new FakeSealGate(),
+ // Three sequential worker turns: fallback phase A, fallback phase B, then acceptance.
+ harness: new FakeHarness(
+ [{ postHash: '0000aaa' }, { postHash: '0000bbb' }, { postHash: '0000ccc' }],
+ workspace,
+ ),
+ makeLadder: () => new FakeVerifier([passVerdict()]),
+ approver: new FakeApprover([approve(), approve(), approve()]),
+ workspace,
+ clock: new ManualClock(),
+ budget: new ManualBudgetMeter(false),
+ runlog,
+ // no `wave` — the fail-closed path under test
+ };
+
+ const outcome = await drive(deps, config, asRunId('run-wave-noseam'));
+
+ expect(outcome.status).toBe('DONE');
+ const stored = await runlog.read();
+ const wave = stored?.entries.find((e) => e.event.tag === 'WAVE_RAN');
+ expect(wave?.event.tag).toBe('WAVE_RAN');
+ if (wave?.event.tag === 'WAVE_RAN') {
+ expect(wave.event.outcomes.map((o) => o.kind)).toEqual(['unmerged', 'unmerged']);
+ if (wave.event.outcomes[0]!.kind === 'unmerged') {
+ expect(wave.event.outcomes[0]!.reason).toContain('wave fan-out unavailable');
+ }
+ }
+ // Both members + acceptance ran the classic sequential path: three agent turns in the log.
+ const turns = stored?.entries.filter((e) => e.event.tag === 'AGENT_RAN') ?? [];
+ expect(turns).toHaveLength(3);
+ });
+});
diff --git a/src/driver/wave-runner.test.ts b/src/driver/wave-runner.test.ts
new file mode 100644
index 0000000..7ad4bee
--- /dev/null
+++ b/src/driver/wave-runner.test.ts
@@ -0,0 +1,179 @@
+import { describe, it, expect } from 'vitest';
+import { DefaultWaveRunner, type ComposeChild } from './wave-runner';
+import type { DriverDeps } from './driver';
+import type { WavePhaseSpec } from './wave';
+import type { Worktree } from '../workspace/workspace';
+import type { CompiledContract } from '../domain/contract';
+import {
+ FakeApprover,
+ FakeCompiler,
+ FakeHarness,
+ FakeSealGate,
+ FakeVerifier,
+ FakeWorkspace,
+ FakeWorktreeHost,
+ InMemoryRunLog,
+ ManualBudgetMeter,
+ ManualClock,
+ approve,
+ failVerdict,
+ makeConfig,
+ makeFakeContract,
+ passVerdict,
+} from '../testing/fakes';
+
+/**
+ * The whole wave with ZERO LLM calls and ZERO subprocesses: children are full `drive()` runs on
+ * fakes, the host merges with the scripted fake `mergeTrees`, and the post-merge re-verify runs the
+ * child contracts' deterministic rungs against the scripted canonical FakeWorkspace.
+ */
+
+const specA: WavePhaseSpec = { index: 0, config: makeConfig({ goal: 'member A', autonomous: true }) };
+const specB: WavePhaseSpec = { index: 1, config: makeConfig({ goal: 'member B', autonomous: true }) };
+const contractA = makeFakeContract({ goal: 'member A', rungs: [{ kind: 'deterministic', command: 'check-a' }] });
+const contractB = makeFakeContract({ goal: 'member B', rungs: [{ kind: 'deterministic', command: 'check-b' }] });
+
+/** Compose one DONE-in-one-iteration child on fakes; `fail` scripts a red ladder (child FAILS). */
+function childDeps(opts: {
+ worktree: Worktree;
+ contract: CompiledContract;
+ tree: string;
+ budget: ManualBudgetMeter;
+ fail?: boolean;
+}): DriverDeps {
+ const scope = opts.worktree.scope as FakeWorkspace;
+ return {
+ compiler: new FakeCompiler(opts.contract),
+ seal: new FakeSealGate(),
+ harness: new FakeHarness([{ postHash: opts.tree, tokensUsed: 111 }], scope),
+ makeLadder: () =>
+ new FakeVerifier(opts.fail === true ? [failVerdict('child red')] : [passVerdict()]),
+ approver: new FakeApprover([approve()]),
+ workspace: scope,
+ clock: new ManualClock(),
+ budget: opts.budget,
+ runlog: new InMemoryRunLog(),
+ };
+}
+
+function runner(opts: {
+ host: FakeWorktreeHost;
+ canonical: FakeWorkspace;
+ composeChild: ComposeChild;
+}): DefaultWaveRunner {
+ return new DefaultWaveRunner({
+ host: opts.host,
+ workspace: opts.canonical,
+ workspaceRoot: '/fake/canonical',
+ composeChild: opts.composeChild,
+ });
+}
+
+/** Standard two-child fixture: canonical at eeeeeee; A edits to aaaa111, B to bbbb222. */
+function fixture(opts: { failB?: boolean } = {}): {
+ host: FakeWorktreeHost;
+ canonical: FakeWorkspace;
+ wave: DefaultWaveRunner;
+} {
+ const canonical = new FakeWorkspace('eeeeeee');
+ const host = new FakeWorktreeHost([], canonical);
+ const budget = new ManualBudgetMeter(false);
+ const composeChild: ComposeChild = async (spec, worktree) =>
+ spec.index === 0
+ ? childDeps({ worktree, contract: contractA, tree: 'aaaa111', budget })
+ : childDeps({ worktree, contract: contractB, tree: 'bbbb222', budget, ...(opts.failB === true ? { fail: true } : {}) });
+ return { host, canonical, wave: runner({ host, canonical, composeChild }) };
+}
+
+describe('DefaultWaveRunner — cooperative parallel waves (EXPERIMENTAL)', () => {
+ it('runs both children to DONE, merges in phase order, re-verifies, and checkpoints', async () => {
+ const { host, canonical, wave } = fixture();
+ const result = await wave.run([specA, specB]);
+
+ expect(result.outcomes.map((o) => o.kind)).toEqual(['merged', 'merged']);
+ // Merges are 3-way against the WAVE-START base, accumulating in phase order.
+ expect(host.mergedCalls).toEqual([
+ { base: 'eeeeeee', ours: 'eeeeeee', theirs: 'aaaa111' },
+ { base: 'eeeeeee', ours: 'eeeaaaa', theirs: 'bbbb222' },
+ ]);
+ // The combined tree was promoted into the canonical workspace and checkpointed as the baseline.
+ expect(host.promoted).toEqual(['eeebbbb']);
+ expect(result.tree).toBe('eeebbbb');
+ expect(await canonical.diffHash()).toBe('eeebbbb');
+ // Every worktree torn down on the happy path.
+ expect(host.live.size).toBe(0);
+ // Child spend is surfaced for the parent's usage fold (shared budget already metered it).
+ expect(result.outcomes[0]!.usage?.tokens).toBe(111);
+ });
+
+ it('a merge CONFLICT downgrades that child to unmerged; the rest still land', async () => {
+ const { host, wave } = fixture();
+ host.conflicts.add('eeeaaaa+bbbb222'); // B conflicts when merged onto A's result
+ const result = await wave.run([specA, specB]);
+
+ expect(result.outcomes[0]).toMatchObject({ kind: 'merged', index: 0 });
+ expect(result.outcomes[1]).toMatchObject({ kind: 'unmerged', index: 1 });
+ if (result.outcomes[1]!.kind === 'unmerged') {
+ expect(result.outcomes[1]!.reason).toContain('merge conflict');
+ }
+ // Only A's tree was promoted — nothing of B was applied (fail-closed).
+ expect(host.promoted).toEqual(['eeeaaaa']);
+ expect(host.live.size).toBe(0);
+ });
+
+ it('a child that cannot reach DONE is unmerged with its terminal status as the reason', async () => {
+ const { host, wave } = fixture({ failB: true });
+ const result = await wave.run([specA, specB]);
+
+ expect(result.outcomes[0]!.kind).toBe('merged');
+ expect(result.outcomes[1]).toMatchObject({ kind: 'unmerged', index: 1 });
+ if (result.outcomes[1]!.kind === 'unmerged') {
+ // The fake red ladder makes the child run terminate without both keys.
+ expect(result.outcomes[1]!.reason).toContain('child run');
+ }
+ expect(host.live.size).toBe(0);
+ });
+
+ it('a RED post-merge re-verify downgrades that child — a merge is never trusted', async () => {
+ // The canonical workspace scripts the two re-verify rungs: A's `check-a` green, B's `check-b` red
+ // (the semantic-conflict case: two CLEAN merges that break each other).
+ const canonical = new FakeWorkspace('eeeeeee', '', [
+ { exitCode: 0, stdout: '', stderr: '' },
+ { exitCode: 1, stdout: '', stderr: 'check-b broke after the merge' },
+ ]);
+ const host = new FakeWorktreeHost([], canonical);
+ const budget = new ManualBudgetMeter(false);
+ const composeChild: ComposeChild = async (spec, worktree) =>
+ spec.index === 0
+ ? childDeps({ worktree, contract: contractA, tree: 'aaaa111', budget })
+ : childDeps({ worktree, contract: contractB, tree: 'bbbb222', budget });
+ const wave = runner({ host, canonical, composeChild });
+
+ const result = await wave.run([specA, specB]);
+ expect(result.outcomes[0]!.kind).toBe('merged');
+ expect(result.outcomes[1]).toMatchObject({ kind: 'unmerged', index: 1 });
+ if (result.outcomes[1]!.kind === 'unmerged') {
+ expect(result.outcomes[1]!.reason).toContain('post-merge re-verify failed');
+ expect(result.outcomes[1]!.reason).toContain('check-b broke after the merge');
+ }
+ });
+
+ it('a composeChild failure is a fail-closed unmerged outcome, never a thrown wave', async () => {
+ const canonical = new FakeWorkspace('eeeeeee');
+ const host = new FakeWorktreeHost([], canonical);
+ const budget = new ManualBudgetMeter(false);
+ const composeChild: ComposeChild = async (spec, worktree) => {
+ if (spec.index === 1) throw new Error('no deps for you');
+ return childDeps({ worktree, contract: contractA, tree: 'aaaa111', budget });
+ };
+ const wave = runner({ host, canonical, composeChild });
+
+ const result = await wave.run([specA, specB]);
+ expect(result.outcomes[0]!.kind).toBe('merged');
+ expect(result.outcomes[1]).toMatchObject({ kind: 'unmerged', index: 1 });
+ if (result.outcomes[1]!.kind === 'unmerged') {
+ expect(result.outcomes[1]!.reason).toContain('no deps for you');
+ }
+ expect(host.live.size).toBe(0);
+ });
+});
diff --git a/src/driver/wave-runner.ts b/src/driver/wave-runner.ts
new file mode 100644
index 0000000..2eba5a7
--- /dev/null
+++ b/src/driver/wave-runner.ts
@@ -0,0 +1,311 @@
+import { randomUUID } from 'node:crypto';
+import { copyFile, mkdir } from 'node:fs/promises';
+import { dirname, resolve, sep } from 'node:path';
+import type { CompiledContract } from '../domain/contract';
+import type { TokenUsage } from '../domain/usage';
+import { asRunId, type DiffHash, type RunId } from '../domain/ids';
+import type { Workspace, Worktree, WorktreeHost } from '../workspace/workspace';
+import { DeterministicVerifier } from '../verify/deterministic';
+import type { Logger } from '../log/logger';
+import { noopLogger } from '../log/logger';
+import { drive, type DriverDeps } from './driver';
+import type { WavePhaseSpec, WaveOutcome, WaveResult, WaveRunner } from './wave';
+
+/**
+ * Compose the FULL driver dependencies for one wave CHILD, rooted at its worktree. The composition
+ * root provides the real thing (harness/ladder/approver/runlog scoped to the worktree — see
+ * `makeWaveRunner` in compose.ts); tests inject fakes so the whole wave runs with zero LLM and zero
+ * subprocesses. `runId` names the child's OWN write-ahead log dir; `interrupted` is the parent's
+ * cooperative stop probe. Contract for implementers: the child's `budget` MUST be the PARENT's
+ * meter (the wave shares the run's one budget) and `interrupted` should be threaded through so
+ * Ctrl-C stops children cleanly between steps.
+ */
+export type ComposeChild = (
+ spec: WavePhaseSpec,
+ worktree: Worktree,
+ runId: RunId,
+ interrupted?: () => boolean,
+) => Promise;
+
+/** A child after the sequential preparation stage (worktree + deps), before its concurrent run. */
+type Prepared = {
+ readonly spec: WavePhaseSpec;
+ readonly worktree: Worktree | null;
+ readonly deps: DriverDeps | null;
+ readonly runId: RunId | null;
+ readonly reason: string | null;
+};
+
+/** What one finished child contributes to the merge stage. */
+type ChildResult = {
+ readonly spec: WavePhaseSpec;
+ readonly worktree: Worktree | null;
+ /** Set only when the child reached DONE (both keys) — the merge candidates. */
+ readonly done: {
+ readonly tree: DiffHash;
+ readonly contract: CompiledContract | null;
+ } | null;
+ readonly reason: string | null;
+ readonly usage: TokenUsage | undefined;
+};
+
+/**
+ * EXPERIMENTAL — the real cooperative-wave executor (`--parallel-phases`). One `run()`:
+ *
+ * 1. **Fork.** Checkpoint the canonical tree (the merge BASE) and give each phase an isolated
+ * worktree + a full CHILD goaly run (`drive()` — its own frozen contract, iterations, two-key
+ * gate, and write-ahead log inside the worktree), all children concurrent on the SHARED budget.
+ * 2. **Merge.** In phase order, 3-way merge each DONE child's tree onto the accumulated result
+ * (`mergeTrees(base, acc, child)`), copying the child's compiler-authored verification files
+ * across (they are git-excluded, so no tree snapshot carries them). A textual conflict marks
+ * that child `unmerged` — nothing of it is applied.
+ * 3. **Promote + re-verify.** Promote the merged tree into the canonical workspace, then re-run
+ * each merged child's frozen DETERMINISTIC rungs against the combined tree — clean merges can
+ * still break each other semantically, and a merge is NEVER trusted. A red re-verify marks that
+ * child `unmerged` (its sub-goal re-runs sequentially on this very tree, so nothing is lost and
+ * nothing is greened). Judge rungs are not re-run here: each child already turned both keys in
+ * isolation, and the run's final ACCEPTANCE contract still gates the whole (two keys, LLM
+ * included) — the merged-tree guard is the ungameable deterministic bar in between.
+ * 4. **Checkpoint.** Snapshot the final canonical tree — the `WAVE_RAN.tree` baseline.
+ *
+ * Every failure shape degrades to `unmerged` (the classic sequential phase), never a throw out of
+ * `run()` for a per-child problem; the Driver additionally catches a wholesale throw and downgrades
+ * the entire wave.
+ */
+export class DefaultWaveRunner implements WaveRunner {
+ readonly #host: WorktreeHost;
+ readonly #workspace: Workspace;
+ readonly #workspaceRoot: string;
+ readonly #composeChild: ComposeChild;
+ readonly #verifyTimeoutMs: number | undefined;
+ readonly #log: Logger;
+
+ constructor(opts: {
+ host: WorktreeHost;
+ /** The CANONICAL workspace (fork point, promotion target, and re-verify scope). */
+ workspace: Workspace;
+ /** The canonical workspace's filesystem root (authored-file copy target). */
+ workspaceRoot: string;
+ composeChild: ComposeChild;
+ /** Per-rung kill timeout for the post-merge deterministic re-verify (the run's verify cap). */
+ verifyTimeoutMs?: number;
+ logger?: Logger;
+ }) {
+ this.#host = opts.host;
+ this.#workspace = opts.workspace;
+ this.#workspaceRoot = opts.workspaceRoot;
+ this.#composeChild = opts.composeChild;
+ this.#verifyTimeoutMs = opts.verifyTimeoutMs;
+ this.#log = opts.logger ?? noopLogger;
+ }
+
+ async run(phases: readonly WavePhaseSpec[], interrupted?: () => boolean): Promise {
+ const base = await this.#workspace.checkpoint();
+ this.#log.info('wave: forking children', {
+ phases: phases.map((p) => p.index).join(','),
+ base,
+ });
+
+ // Worktree creation is SEQUENTIAL (concurrent `git worktree add` calls contend on repo locks);
+ // only the child RUNS are concurrent. A preparation failure is already a fail-closed result.
+ const prepared: Prepared[] = [];
+ for (const spec of phases) prepared.push(await this.#prepareChild(spec, base, interrupted));
+ const children = await Promise.all(prepared.map((p) => this.#driveChild(p)));
+ try {
+ const { merged, outcomes, tree } = await this.#mergeAndReverify(base, children);
+ this.#log.info('wave: merged + re-verified', {
+ merged: merged.length,
+ total: children.length,
+ tree,
+ });
+ return { outcomes, tree };
+ } finally {
+ for (const child of children) {
+ if (child.worktree !== null) await this.#host.removeWorktree(child.worktree);
+ }
+ }
+ }
+
+ /** Create ONE child's worktree + deps (sequential stage). Never throws — a failure is a reason. */
+ async #prepareChild(
+ spec: WavePhaseSpec,
+ base: DiffHash,
+ interrupted?: () => boolean,
+ ): Promise {
+ // The worktree handle survives a later failure so the teardown sweep still removes it.
+ let worktree: Worktree | null = null;
+ try {
+ worktree = await this.#host.addWorktree(base);
+ const runId = asRunId(`run-wave-p${spec.index}-${randomUUID()}`);
+ const deps = await this.#composeChild(spec, worktree, runId, interrupted);
+ return { spec, worktree, deps, runId, reason: null };
+ } catch (e) {
+ return {
+ spec,
+ worktree,
+ deps: null,
+ runId: null,
+ reason: `child failed to start: ${e instanceof Error ? e.message : String(e)}`,
+ };
+ }
+ }
+
+ /** Drive ONE prepared child to a terminal outcome (concurrent stage). Never throws. */
+ async #driveChild(prepared: Prepared): Promise {
+ const { spec, worktree, deps, runId } = prepared;
+ if (worktree === null || deps === null || runId === null) {
+ return { spec, worktree, done: null, reason: prepared.reason ?? 'child not prepared', usage: undefined };
+ }
+ try {
+ this.#log.info('wave child starting', { phase: spec.index, runId, root: worktree.root });
+ const outcome = await drive(deps, spec.config, runId);
+ const usage = outcome.usage?.total;
+ if (outcome.status !== 'DONE') {
+ return {
+ spec,
+ worktree,
+ done: null,
+ reason: `child run ${outcome.status}${outcome.reason !== undefined ? `: ${outcome.reason}` : ''}`,
+ usage,
+ };
+ }
+ // The child's frozen contract (for the post-merge re-verify + authored-file copy) comes from
+ // ITS OWN write-ahead log. Fail-closed: no recoverable contract ⇒ unmerged — never an
+ // unverified merge.
+ const contract = await lastContract(deps);
+ if (contract === null) {
+ return { spec, worktree, done: null, reason: 'child log carried no frozen contract', usage };
+ }
+ const tree = await worktree.scope.diffHash();
+ return { spec, worktree, done: { tree, contract }, reason: null, usage };
+ } catch (e) {
+ return {
+ spec,
+ worktree,
+ done: null,
+ reason: `child failed to run: ${e instanceof Error ? e.message : String(e)}`,
+ usage: undefined,
+ };
+ }
+ }
+
+ /** Stages 2–4: sequential merge (+ authored-file copy), promote, deterministic re-verify, checkpoint. */
+ async #mergeAndReverify(
+ base: DiffHash,
+ children: readonly ChildResult[],
+ ): Promise<{ merged: ChildResult[]; outcomes: WaveOutcome[]; tree: DiffHash }> {
+ const ordered = [...children].sort((a, b) => a.spec.index - b.spec.index);
+ const outcomes: WaveOutcome[] = [];
+ const merged: ChildResult[] = [];
+ let acc: string = base;
+
+ for (const child of ordered) {
+ const { index } = child.spec;
+ const usage = child.usage !== undefined ? { usage: child.usage } : {};
+ if (child.done === null) {
+ outcomes.push({ kind: 'unmerged', index, reason: child.reason ?? 'child did not finish', ...usage });
+ continue;
+ }
+ try {
+ const m = await this.#host.mergeTrees(base, acc, child.done.tree);
+ if (m.kind === 'conflict') {
+ this.#log.warn('wave: merge conflict — phase downgrades to sequential', {
+ phase: index,
+ detail: m.detail,
+ });
+ outcomes.push({ kind: 'unmerged', index, reason: `merge conflict: ${m.detail}`, ...usage });
+ continue;
+ }
+ acc = m.tree;
+ merged.push(child);
+ } catch (e) {
+ outcomes.push({
+ kind: 'unmerged',
+ index,
+ reason: `merge failed: ${e instanceof Error ? e.message : String(e)}`,
+ ...usage,
+ });
+ }
+ }
+
+ if (merged.length > 0) {
+ await this.#host.promoteTree(acc);
+ // Authored verification files are git-excluded (never in a tree snapshot) — carry them over
+ // from each merged child's worktree so its frozen commands still have their inputs.
+ for (const child of merged) await this.#copyGeneratedFiles(child);
+ }
+
+ // Re-verify each merged child's frozen deterministic rungs against the COMBINED tree.
+ for (const child of merged) {
+ const verdict = await this.#reverify(child);
+ const usage = child.usage !== undefined ? { usage: child.usage } : {};
+ if (verdict === null) {
+ outcomes.push({ kind: 'merged', index: child.spec.index, ...usage });
+ } else {
+ this.#log.warn('wave: post-merge re-verify red — phase downgrades to sequential', {
+ phase: child.spec.index,
+ detail: verdict,
+ });
+ outcomes.push({
+ kind: 'unmerged',
+ index: child.spec.index,
+ reason: `post-merge re-verify failed: ${verdict}`,
+ ...usage,
+ });
+ }
+ }
+
+ const tree = await this.#workspace.checkpoint();
+ outcomes.sort((a, b) => a.index - b.index);
+ return { merged, outcomes, tree };
+ }
+
+ /** Run the child's frozen DETERMINISTIC rungs on the canonical tree; null = green, else the red detail. */
+ async #reverify(child: ChildResult): Promise {
+ const contract = child.done?.contract;
+ if (contract === undefined || contract === null) return 'no frozen contract to re-verify';
+ for (const rung of contract.rungs) {
+ if (rung.kind !== 'deterministic') continue;
+ const verifier = new DeterministicVerifier(rung.command, rung.label, this.#verifyTimeoutMs);
+ const verdict = await verifier.verify(this.#workspace, contract.goal, contract.rubric);
+ if (!verdict.pass) return verdict.detail;
+ }
+ return null;
+ }
+
+ /** Copy a merged child's compiler-authored (git-excluded) verification files into the canonical root. */
+ async #copyGeneratedFiles(child: ChildResult): Promise {
+ const contract = child.done?.contract;
+ const worktree = child.worktree;
+ if (contract === undefined || contract === null || worktree === null) return;
+ const canonicalRoot = resolve(this.#workspaceRoot);
+ for (const file of contract.generatedFiles) {
+ // Containment: the paths were validated at compile, but re-check before writing (fail-closed).
+ const src = resolve(worktree.root, file.path);
+ const dst = resolve(canonicalRoot, file.path);
+ if (!src.startsWith(resolve(worktree.root) + sep) || !dst.startsWith(canonicalRoot + sep)) {
+ throw new Error(`generated file escapes the workspace: ${file.path}`);
+ }
+ await mkdir(dirname(dst), { recursive: true });
+ await copyFile(src, dst);
+ }
+ }
+}
+
+/** The LAST frozen contract in a child's write-ahead log (revisions re-freeze; the last one ran). */
+async function lastContract(deps: DriverDeps): Promise {
+ try {
+ const stored = await deps.runlog.read();
+ if (stored === null) return null;
+ let contract: CompiledContract | null = null;
+ for (const entry of stored.entries) {
+ if (entry.event.tag === 'CONTRACT_COMPILED') contract = entry.event.contract;
+ }
+ return contract;
+ } catch {
+ return null;
+ }
+}
+
+/** Re-export the seam types so the composition root imports one module. */
+export type { WavePhaseSpec, WaveOutcome, WaveResult, WaveRunner };
diff --git a/src/driver/wave.ts b/src/driver/wave.ts
new file mode 100644
index 0000000..4f4b0fc
--- /dev/null
+++ b/src/driver/wave.ts
@@ -0,0 +1,35 @@
+import type { RunConfig } from '../domain/config';
+import type { OrchestratorEvent } from '../domain/events';
+import type { DiffHash } from '../domain/ids';
+
+/**
+ * EXPERIMENTAL — the cooperative parallel-wave seam (`--parallel-phases`). The Driver performs a
+ * `RUN_WAVE` command through this interface and feeds the reducer ONE `WAVE_RAN` event; everything
+ * concurrent, git-shaped, or LLM-adjacent lives behind it (invariant #1). The real implementation
+ * (`src/cli/wave-runner.ts`) runs each phase as its own frozen, two-key CHILD goaly run in an
+ * isolated worktree, merges the DONE children in phase order, and RE-VERIFIES each merged phase's
+ * frozen ladder on the combined tree; tests inject fakes. Like every seam it must not reject in
+ * normal operation — per-child failures become `unmerged` outcomes (the fail-closed sequential
+ * downgrade); the Driver additionally catches a thrown runner and downgrades the WHOLE wave.
+ */
+
+/** One wave member: the plan phase index + the phase config the reducer derived for it. */
+export type WavePhaseSpec = { readonly index: number; readonly config: RunConfig };
+
+/** Per-phase wave outcome — exactly the shape persisted in the `WAVE_RAN` event. */
+export type WaveOutcome = Extract['outcomes'][number];
+
+/** The whole wave's result: one outcome per member + the post-merge checkpoint tree. */
+export type WaveResult = {
+ readonly outcomes: WaveOutcome[];
+ /** The post-merge checkpoint tree (the diff baseline for the phases that follow). */
+ readonly tree: DiffHash;
+};
+
+export interface WaveRunner {
+ /**
+ * Run the wave. `interrupted` is the parent run's cooperative stop probe (Ctrl-C/SIGTERM) —
+ * threaded into every child's deps so children stop cleanly between steps like the parent does.
+ */
+ run(phases: readonly WavePhaseSpec[], interrupted?: () => boolean): Promise;
+}
diff --git a/src/index.ts b/src/index.ts
index 8403734..ac80f7a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -15,6 +15,9 @@ export {
type CheckpointDeps,
} from './driver/driver';
export { noopTelemetry, type Telemetry, type TelemetryEvent } from './telemetry/telemetry';
+// EXPERIMENTAL cooperative parallel waves (--parallel-phases): the seam + the composable executor.
+export type { WaveRunner, WavePhaseSpec, WaveOutcome, WaveResult } from './driver/wave';
+export { DefaultWaveRunner, type ComposeChild } from './driver/wave-runner';
export {
composeDeps,
buildLadder,
diff --git a/src/orchestrator/state.ts b/src/orchestrator/state.ts
index dc659be..0d291aa 100644
--- a/src/orchestrator/state.ts
+++ b/src/orchestrator/state.ts
@@ -20,6 +20,18 @@ export type PhaseCtx = {
readonly plan: PhasePlan;
/** 0-based phase index; `plan.phases.length` denotes the final cumulative acceptance phase. */
readonly index: number;
+ /**
+ * EXPERIMENTAL parallel waves: phase indices already COMPLETED by a merged-and-reverified wave
+ * child — the sequential advance skips them. Absent on a classic/sequential run (no field, so
+ * every existing PhaseCtx construction and equality stays byte-for-byte).
+ */
+ readonly skip?: readonly number[];
+ /**
+ * EXPERIMENTAL parallel waves: phase indices whose wave fan-out was already ATTEMPTED. A phase in
+ * this list never re-fans-out — an unmerged member re-runs as a classic sequential phase (the
+ * fail-closed downgrade), so a fully-conflicted wave can never fan out forever.
+ */
+ readonly waved?: readonly number[];
};
/**
@@ -133,6 +145,17 @@ export type OrchestratorState =
/** The phase position when this prepare belongs to a phased run (issue #48); else undefined. */
readonly phase?: PhaseCtx;
}
+ | {
+ /**
+ * EXPERIMENTAL — a cooperative parallel WAVE is in flight (`--parallel-phases`): the Driver is
+ * running the grouped phases at `indices` as concurrent, isolated, frozen two-key CHILD runs,
+ * then merging + re-verifying. Resolved by ONE `WAVE_RAN` event. Carries the wave's FIRST
+ * member's PhaseCtx (`phase.index === indices[0]`).
+ */
+ readonly tag: 'RUNNING_WAVE';
+ readonly phase: PhaseCtx;
+ readonly indices: readonly number[];
+ }
| { readonly tag: 'RUNNING_AGENT'; readonly ctx: LoopCtx }
| { readonly tag: 'VERIFYING'; readonly ctx: LoopCtx }
| { readonly tag: 'AWAIT_SIGNOFF'; readonly ctx: LoopCtx }
@@ -180,6 +203,8 @@ export function iterationCount(state: OrchestratorState): number {
case 'PREPARING':
case 'PLANNING':
case 'AWAIT_PLAN_SEAL':
+ // A wave's iterations belong to its CHILD runs (each has its own log); the parent counts none.
+ case 'RUNNING_WAVE':
return 0;
}
}
diff --git a/src/orchestrator/step.ts b/src/orchestrator/step.ts
index c84a27d..44a0e77 100644
--- a/src/orchestrator/step.ts
+++ b/src/orchestrator/step.ts
@@ -3,6 +3,7 @@ import type { RunConfig, VerifierIntent } from '../domain/config';
import { pickGatePolicy, pickLoopPolicy, pickDriverWiring } from '../domain/config';
import type { CompiledContract, Rung } from '../domain/contract';
import type { PhasePlan } from '../domain/plan';
+import { waveIndicesAt } from '../domain/plan';
import type { OrchestratorState, LoopCtx, PhaseCtx } from './state';
import { initialCtx } from './state';
import { decide, type Decision } from './decide';
@@ -42,6 +43,8 @@ export function step(state: OrchestratorState, event: OrchestratorEvent): StepRe
return stepAwaitPlanSeal(state.config, state.plan, state.reviseRound, event);
case 'ADVANCING_PHASE':
return stepAdvancingPhase(state.phase, event);
+ case 'RUNNING_WAVE':
+ return stepRunningWave(state.phase, state.indices, event);
case 'COMPILING':
return stepCompiling(state.config, state.reviseRound, state.compileRound, state.phase, event);
case 'AWAIT_SEAL':
@@ -146,12 +149,39 @@ function stepAwaitPlanSeal(
*/
function stepAdvancingPhase(phase: PhaseCtx, event: OrchestratorEvent): StepResult {
if (event.tag !== 'PHASE_ADVANCED') throw invalidTransition('ADVANCING_PHASE', event);
- const next: PhaseCtx = { ...phase, index: phase.index + 1 };
+ const next: PhaseCtx = { ...phase, index: nextPhaseIndex(phase, phase.index + 1) };
return startPhaseCompile(next);
}
-/** Begin a phase: COMPILING its derived config, carrying the phase position for the eventual advance. */
+/** The next phase index at or after `from`, skipping indices a wave already completed (merged). */
+function nextPhaseIndex(phase: PhaseCtx, from: number): number {
+ const skip = phase.skip ?? [];
+ let next = from;
+ while (skip.includes(next)) next += 1;
+ return next;
+}
+
+/**
+ * Begin a phase: COMPILING its derived config, carrying the phase position for the eventual advance.
+ * EXPERIMENTAL parallel waves: when the phase heads a not-yet-attempted group of consecutive
+ * same-`group` sub-goals AND `--parallel-phases` is on, the whole group is emitted as ONE `RUN_WAVE`
+ * command instead (still exactly one command per state — the Driver invariant). Everything else —
+ * ungrouped plans, the acceptance phase, a re-entered (already-attempted) member, the feature off —
+ * takes the classic sequential compile, byte-for-byte.
+ */
function startPhaseCompile(phase: PhaseCtx): StepResult {
+ const wave = pendingWaveAt(phase);
+ if (wave.length > 1) {
+ return [
+ { tag: 'RUNNING_WAVE', phase, indices: wave },
+ [
+ {
+ tag: 'RUN_WAVE',
+ phases: wave.map((index) => ({ index, config: phaseConfigFor({ ...phase, index }) })),
+ },
+ ],
+ ];
+ }
const config = phaseConfigFor(phase);
return [
{ tag: 'COMPILING', config, reviseRound: 0, compileRound: 0, phase },
@@ -159,6 +189,45 @@ function startPhaseCompile(phase: PhaseCtx): StepResult {
];
}
+/**
+ * The wave the current phase would fan out, or a singleton when it must run sequentially: the
+ * feature is off, the index is the acceptance phase, the group was ALREADY attempted (`waved` — an
+ * unmerged member re-runs sequentially, never re-fans-out), or the group has one live member.
+ */
+function pendingWaveAt(phase: PhaseCtx): readonly number[] {
+ if (!phase.baseConfig.parallelPhases) return [phase.index];
+ if (phase.index >= phase.plan.phases.length) return [phase.index];
+ if ((phase.waved ?? []).includes(phase.index)) return [phase.index];
+ const skip = phase.skip ?? [];
+ return waveIndicesAt(phase.plan, phase.index).filter((i) => !skip.includes(i));
+}
+
+/**
+ * EXPERIMENTAL parallel waves: fold the ONE `WAVE_RAN` event. `merged` members are recorded in
+ * `skip` (complete — the advance walks past them); every attempted index is recorded in `waved`
+ * (never re-fans-out); the machine advances to the FIRST not-merged wave member — a classic
+ * sequential re-run on the merged tree (the fail-closed downgrade) — or past the group when all
+ * merged. The plan, the contracts, and the two-key gate are untouched: an unmerged phase re-enters
+ * the same compile → Seal → loop path any sequential phase takes.
+ */
+function stepRunningWave(
+ phase: PhaseCtx,
+ indices: readonly number[],
+ event: OrchestratorEvent,
+): StepResult {
+ if (event.tag !== 'WAVE_RAN') throw invalidTransition('RUNNING_WAVE', event);
+ // Only indices that were actually part of this wave count (defense in depth on a replayed log).
+ const merged = event.outcomes
+ .filter((o) => o.kind === 'merged' && indices.includes(o.index))
+ .map((o) => o.index);
+ const next: PhaseCtx = {
+ ...phase,
+ skip: [...(phase.skip ?? []), ...merged],
+ waved: [...(phase.waved ?? []), ...indices],
+ };
+ return startPhaseCompile({ ...next, index: nextPhaseIndex(next, indices[0] ?? phase.index) });
+}
+
/**
* Derive the RunConfig for a phase from the frozen plan + the original config. A sub-goal phase
* (`index < phases.length`) inherits the operational knobs (iterations, budget, stuck policy,
@@ -171,7 +240,7 @@ function startPhaseCompile(phase: PhaseCtx): StepResult {
function phaseConfigFor(phase: PhaseCtx): RunConfig {
const base = phase.baseConfig;
if (phase.index >= phase.plan.phases.length) {
- return { ...base, phased: false };
+ return { ...base, phased: false, parallelPhases: false };
}
// A sub-goal phase: FRESH contract inputs authored per sub-goal (goal/verifier/rubric), but the
// SAME operational policy as the run — inherited wholesale by lifetime VIEW (gate / loop / wiring)
@@ -196,6 +265,9 @@ function phaseConfigFor(phase: PhaseCtx): RunConfig {
// frozen into the contract, so each phase's Sign-off uses the same panel.)
...(sub.rubric !== undefined ? { rubric: sub.rubric } : {}),
phased: false,
+ // A phase (whether run inline or as a wave CHILD) is a single-contract run — it must never
+ // decompose or fan out again (no nested waves).
+ parallelPhases: false,
};
}
diff --git a/src/orchestrator/step.wave.test.ts b/src/orchestrator/step.wave.test.ts
new file mode 100644
index 0000000..582f317
--- /dev/null
+++ b/src/orchestrator/step.wave.test.ts
@@ -0,0 +1,171 @@
+import { describe, it, expect } from 'vitest';
+import { initial, step } from './step';
+import type { OrchestratorState } from './state';
+import type { OrchestratorEvent, BudgetSnapshot } from '../domain/events';
+import { makeConfig, makeFakeContract, makeFakePlan, passVerdict, dh } from '../testing/fakes';
+
+const budget: BudgetSnapshot = { exceeded: false };
+/** Phases 0+1 share wave group 1; phase 2 is sequential. */
+const plan = makeFakePlan({
+ phases: [
+ { goal: 'wave member A', group: 1 },
+ { goal: 'wave member B', group: 1 },
+ { goal: 'sequential tail' },
+ ],
+});
+const contract = makeFakeContract();
+
+function agentRan(prev: string, post: string): OrchestratorEvent {
+ const [p, q] = dh(prev, post);
+ return {
+ tag: 'AGENT_RAN',
+ run: { output: '', sessionId: 'sess-1' as never, status: 'completed' },
+ prevDiffHash: p!,
+ diffHash: q!,
+ budget,
+ };
+}
+
+/** A phased+parallel run folded up to the plan-Seal approve (the wave decision point). */
+function approvedPlan(parallel: boolean): readonly [OrchestratorState, readonly unknown[]] {
+ const config = makeConfig({ phased: true, parallelPhases: parallel, autonomous: true });
+ const [s0] = initial(config);
+ const [s1] = step(s0, { tag: 'PLAN_COMPILED', plan });
+ return step(s1, { tag: 'PLAN_SEAL_DECIDED', decision: { kind: 'approve' } });
+}
+
+/** Drive a compiling phase through Seal → run → verify(pass) → sign-off(approve). */
+function runPhaseToBothKeys(compiling: OrchestratorState): OrchestratorState {
+ const [sealed] = step(compiling, { tag: 'CONTRACT_COMPILED', contract });
+ const [running] = step(sealed, { tag: 'SEAL_DECIDED', decision: { kind: 'approve' } });
+ const [verifying] = step(running, agentRan('0000000', '0000aaa'));
+ const [awaitSignoff] = step(verifying, { tag: 'VERIFIED', verdict: passVerdict() });
+ return step(awaitSignoff, { tag: 'SIGNOFF_DECIDED', approval: { veto: false } })[0];
+}
+
+const waveTree = dh('00cafe0')[0]!;
+
+describe('parallel waves reducer (EXPERIMENTAL --parallel-phases)', () => {
+ it('plan approve fans a grouped prefix out as ONE RUN_WAVE with per-phase derived configs', () => {
+ const [state, cmds] = approvedPlan(true);
+ expect(state.tag).toBe('RUNNING_WAVE');
+ if (state.tag === 'RUNNING_WAVE') expect(state.indices).toEqual([0, 1]);
+ expect(cmds).toHaveLength(1); // driver invariant: exactly one command per state
+ const cmd = cmds[0] as Extract;
+ expect(cmd.tag).toBe('RUN_WAVE');
+ expect(cmd.phases.map((p) => p.index)).toEqual([0, 1]);
+ expect(cmd.phases[0]!.config.goal).toBe('wave member A');
+ expect(cmd.phases[1]!.config.goal).toBe('wave member B');
+ // Each wave child is a normal single-contract, non-fanning run authored per sub-goal.
+ for (const p of cmd.phases) {
+ expect(p.config.verifier.kind).toBe('generate');
+ expect(p.config.phased).toBe(false);
+ expect(p.config.parallelPhases).toBe(false);
+ }
+ });
+
+ it('the feature is OPT-IN: a grouped plan without --parallel-phases runs strictly sequentially', () => {
+ const [state, cmds] = approvedPlan(false);
+ expect(state.tag).toBe('COMPILING');
+ if (state.tag === 'COMPILING') expect(state.config.goal).toBe('wave member A');
+ expect(cmds[0]).toMatchObject({ tag: 'COMPILE_VERIFIER' });
+ });
+
+ it('all members merged → the machine advances PAST the group to the next phase', () => {
+ const [wave] = approvedPlan(true);
+ const [next, cmds] = step(wave, {
+ tag: 'WAVE_RAN',
+ outcomes: [
+ { kind: 'merged', index: 0 },
+ { kind: 'merged', index: 1 },
+ ],
+ tree: waveTree,
+ });
+ expect(next.tag).toBe('COMPILING');
+ if (next.tag === 'COMPILING') {
+ expect(next.config.goal).toBe('sequential tail');
+ expect(next.phase).toMatchObject({ index: 2, skip: [0, 1] });
+ }
+ expect(cmds[0]).toMatchObject({ tag: 'COMPILE_VERIFIER' });
+ });
+
+ it('a partially-merged wave re-runs ONLY the unmerged member sequentially, then skips the merged one', () => {
+ const [wave] = approvedPlan(true);
+ const [fallback] = step(wave, {
+ tag: 'WAVE_RAN',
+ outcomes: [
+ { kind: 'merged', index: 0 },
+ { kind: 'unmerged', index: 1, reason: 'merge conflict: file.txt' },
+ ],
+ tree: waveTree,
+ });
+ // The unmerged member re-enters the CLASSIC sequential path (fresh compile, same sub-goal).
+ expect(fallback.tag).toBe('COMPILING');
+ if (fallback.tag === 'COMPILING') {
+ expect(fallback.config.goal).toBe('wave member B');
+ expect(fallback.phase).toMatchObject({ index: 1, skip: [0], waved: [0, 1] });
+ }
+ // When it completes both keys, the advance walks past the group to the tail — never back to 0.
+ const advancing = runPhaseToBothKeys(fallback);
+ expect(advancing.tag).toBe('ADVANCING_PHASE');
+ const [tail] = step(advancing, { tag: 'PHASE_ADVANCED', tree: waveTree });
+ expect(tail.tag).toBe('COMPILING');
+ if (tail.tag === 'COMPILING') expect(tail.config.goal).toBe('sequential tail');
+ });
+
+ it('a fully-unmerged wave NEVER re-fans-out — every member downgrades to sequential', () => {
+ const [wave] = approvedPlan(true);
+ const [first] = step(wave, {
+ tag: 'WAVE_RAN',
+ outcomes: [
+ { kind: 'unmerged', index: 0, reason: 'child run FAILED' },
+ { kind: 'unmerged', index: 1, reason: 'child run FAILED' },
+ ],
+ tree: waveTree,
+ });
+ expect(first.tag).toBe('COMPILING'); // sequential, NOT another RUNNING_WAVE
+ if (first.tag === 'COMPILING') expect(first.config.goal).toBe('wave member A');
+
+ const advancing = runPhaseToBothKeys(first);
+ const [second] = step(advancing, { tag: 'PHASE_ADVANCED', tree: waveTree });
+ expect(second.tag).toBe('COMPILING'); // member B also sequential — the `waved` guard holds
+ if (second.tag === 'COMPILING') expect(second.config.goal).toBe('wave member B');
+ });
+
+ it('a wave covering the LAST sub-goals advances into the cumulative ACCEPTANCE phase', () => {
+ const twoPhase = makeFakePlan({
+ phases: [
+ { goal: 'wave member A', group: 7 },
+ { goal: 'wave member B', group: 7 },
+ ],
+ });
+ const config = makeConfig({ phased: true, parallelPhases: true, autonomous: true });
+ const [s0] = initial(config);
+ const [s1] = step(s0, { tag: 'PLAN_COMPILED', plan: twoPhase });
+ const [wave] = step(s1, { tag: 'PLAN_SEAL_DECIDED', decision: { kind: 'approve' } });
+ expect(wave.tag).toBe('RUNNING_WAVE');
+ const [accept] = step(wave, {
+ tag: 'WAVE_RAN',
+ outcomes: [
+ { kind: 'merged', index: 0 },
+ { kind: 'merged', index: 1 },
+ ],
+ tree: waveTree,
+ });
+ expect(accept.tag).toBe('COMPILING');
+ if (accept.tag === 'COMPILING') {
+ // The acceptance phase is the ORIGINAL goal (decomposition can't green a broken whole).
+ expect(accept.config.goal).toBe(config.goal);
+ expect(accept.phase).toMatchObject({ index: 2 });
+ }
+ });
+
+ it('ungrouped plans and the acceptance phase never fan out', () => {
+ const linear = makeFakePlan({ phases: [{ goal: 'only phase' }] });
+ const config = makeConfig({ phased: true, parallelPhases: true, autonomous: true });
+ const [s0] = initial(config);
+ const [s1] = step(s0, { tag: 'PLAN_COMPILED', plan: linear });
+ const [state] = step(s1, { tag: 'PLAN_SEAL_DECIDED', decision: { kind: 'approve' } });
+ expect(state.tag).toBe('COMPILING');
+ });
+});
diff --git a/src/plan/plan.test.ts b/src/plan/plan.test.ts
index ceed953..81ac99c 100644
--- a/src/plan/plan.test.ts
+++ b/src/plan/plan.test.ts
@@ -4,6 +4,7 @@ import { StaticPlanner } from './static-planner';
import { AutoPlanGate, HumanPlanGate } from './plan-gates';
import { FakeLlm } from '../llm/provider';
import { freezePlan, hashPlan } from '../util/hash';
+import { canonicalPlanString, waveIndicesAt } from '../domain/plan';
import { makeConfig } from '../testing/fakes';
const config = makeConfig({ phased: true, goal: 'build a CLI', maxPhases: 5 });
@@ -22,6 +23,48 @@ describe('freezePlan / hashPlan (issue #48)', () => {
expect(frozen.planHash).toBe(hashPlan({ phases: [{ goal: 'only' }] }));
expect(frozen.phases).toHaveLength(1);
});
+
+ it('a wave `group` is FROZEN into the hash, and groupless plans keep their legacy hash (back-compat)', () => {
+ // Grouping is part of the frozen plan — re-shuffling it would be a different plan.
+ const grouped = hashPlan({ phases: [{ goal: 'x', group: 1 }, { goal: 'y', group: 1 }] });
+ const ungrouped = hashPlan({ phases: [{ goal: 'x' }, { goal: 'y' }] });
+ const regrouped = hashPlan({ phases: [{ goal: 'x', group: 1 }, { goal: 'y', group: 2 }] });
+ expect(grouped).not.toBe(ungrouped);
+ expect(grouped).not.toBe(regrouped);
+ // Back-compat: a plan WITHOUT groups canonicalizes exactly as before the field existed, so every
+ // pre-existing run log's planHash still matches on replay.
+ expect(canonicalPlanString({ phases: [{ goal: 'x' }] })).toBe(
+ JSON.stringify({ phases: [{ goal: 'x', intent: null, rubric: null }] }),
+ );
+ });
+});
+
+describe('waveIndicesAt — consecutive same-group members (EXPERIMENTAL parallel waves)', () => {
+ const plan = freezePlan({
+ phases: [
+ { goal: 'a', group: 1 },
+ { goal: 'b', group: 1 },
+ { goal: 'c' },
+ { goal: 'd', group: 2 },
+ ],
+ });
+
+ it('the group head fans out over its consecutive members', () => {
+ expect(waveIndicesAt(plan, 0)).toEqual([0, 1]);
+ });
+
+ it('a MID-group index never fans out (a sequential fallback walks members one at a time)', () => {
+ expect(waveIndicesAt(plan, 1)).toEqual([1]);
+ });
+
+ it('an ungrouped phase and a singleton group are singletons', () => {
+ expect(waveIndicesAt(plan, 2)).toEqual([2]);
+ expect(waveIndicesAt(plan, 3)).toEqual([3]);
+ });
+
+ it('an out-of-range index is a singleton (the acceptance phase)', () => {
+ expect(waveIndicesAt(plan, 4)).toEqual([4]);
+ });
});
describe('AgentPlanner — LLM-authored plan (issue #48)', () => {
diff --git a/src/runlog/replay.ts b/src/runlog/replay.ts
index 4531feb..319e1e6 100644
--- a/src/runlog/replay.ts
+++ b/src/runlog/replay.ts
@@ -168,6 +168,12 @@ export function replay(config: RunConfig, entries: readonly RunLogEntry[]): Repl
baseline = entry.event.tree;
phaseBaseline = entry.event.tree;
}
+ // EXPERIMENTAL parallel waves: like PHASE_ADVANCED, a wave both DRIVES the reducer (skip/advance
+ // bookkeeping) and records the post-merge checkpoint tree for baseline reconstruction on resume.
+ if (entry.event.tag === 'WAVE_RAN') {
+ baseline = entry.event.tree;
+ phaseBaseline = entry.event.tree;
+ }
// With extended budget caps, the persisted `exceeded` flags are re-judged against the new caps
// (raw spent numbers stay the persisted facts) — else the fold would re-abort at the old cap.
[state, commands] = step(state, budgetExtended ? rejudgeBudget(entry.event, effective) : entry.event);
diff --git a/src/runlog/usage.ts b/src/runlog/usage.ts
index 5ce2965..2bbd6e7 100644
--- a/src/runlog/usage.ts
+++ b/src/runlog/usage.ts
@@ -43,6 +43,13 @@ export function summarizeUsage(events: OrchestratorEvent[], budget: BudgetConfig
case 'SIGNOFF_DECIDED':
addLlmStep(approver, event.llm);
break;
+ case 'WAVE_RAN':
+ // EXPERIMENTAL parallel waves: each outcome carries its CHILD run's total spend (the child
+ // spends across all layers internally, metered by the SHARED budget). The parent report has
+ // no per-child columns, so the whole child total is bucketed under `harness` — the run's
+ // `total`/`budget` stay exact, which is what the cap and the summary line need.
+ for (const outcome of event.outcomes) addLlmStep(harness, outcome.usage);
+ break;
}
}
diff --git a/src/testing/fakes.ts b/src/testing/fakes.ts
index def8324..e422988 100644
--- a/src/testing/fakes.ts
+++ b/src/testing/fakes.ts
@@ -365,6 +365,28 @@ export class FakeWorktreeHost implements WorktreeHost {
this.promoted.push(treeish);
this.canonical?.setHash(treeish);
}
+
+ /** Scripted conflict paths (ours+theirs keys) — a pair listed here merges as a typed conflict. */
+ readonly conflicts = new Set();
+ /** Record of every mergeTrees call, for assertions. */
+ readonly mergedCalls: { base: string; ours: string; theirs: string }[] = [];
+
+ /**
+ * Fake 3-way merge (parallel waves): a pair scripted via {@link conflicts} (`"ours+theirs"`)
+ * conflicts; anything else merges "clean" to a deterministic synthetic tree id derived from the
+ * inputs, so tests can assert exactly which trees were combined without real git.
+ */
+ async mergeTrees(
+ base: string,
+ ours: string,
+ theirs: string,
+ ): Promise<{ kind: 'clean'; tree: string } | { kind: 'conflict'; detail: string }> {
+ this.mergedCalls.push({ base, ours, theirs });
+ if (this.conflicts.has(`${ours}+${theirs}`)) {
+ return { kind: 'conflict', detail: `scripted conflict merging ${theirs} onto ${ours}` };
+ }
+ return { kind: 'clean', tree: `${ours.slice(0, 3)}${theirs.slice(0, 4)}` };
+ }
}
export class ManualClock implements Clock {
diff --git a/src/ui/web/format.ts b/src/ui/web/format.ts
index 91b593d..66e98bc 100644
--- a/src/ui/web/format.ts
+++ b/src/ui/web/format.ts
@@ -74,6 +74,12 @@ export function feedLine(entry: RunLogEntry, iteration: number): FeedLine | null
];
return plain(`operator extension: ${parts.join(', ')}`);
}
+ case 'WAVE_RAN': {
+ const merged = e.outcomes.filter((o) => o.kind === 'merged').length;
+ const fallback = e.outcomes.length - merged;
+ const text = `wave: ${merged}/${e.outcomes.length} phase(s) merged + re-verified${fallback > 0 ? `, ${fallback} downgraded to sequential` : ''}`;
+ return { at, text, tone: fallback > 0 ? 'plain' : 'pass' };
+ }
case 'CHECKPOINTED':
return null; // internal diff-baseline plumbing — noise for a human
}
diff --git a/src/workspace/git-worktree-host.test.ts b/src/workspace/git-worktree-host.test.ts
index 4f746e4..51dab93 100644
--- a/src/workspace/git-worktree-host.test.ts
+++ b/src/workspace/git-worktree-host.test.ts
@@ -97,6 +97,54 @@ describe('GitWorktreeHost (integration, real git) — best-of-N (issue #85)', ()
expect(git(root, 'rev-parse', 'HEAD')).toBe(headBefore);
});
+ it('mergeTrees merges DISJOINT edits cleanly into a promotable tree (parallel waves)', async () => {
+ const h = host(root);
+ const base = await new GitWorkspace(root).diffHash(); // the fork point
+
+ // Two children fork from base and edit DIFFERENT files.
+ const a = await h.addWorktree('HEAD');
+ await writeFile(join(a.root, 'a.txt'), 'from child A\n');
+ const treeA = await a.scope.diffHash();
+ await h.removeWorktree(a);
+
+ const b = await h.addWorktree('HEAD');
+ await writeFile(join(b.root, 'b.txt'), 'from child B\n');
+ const treeB = await b.scope.diffHash();
+ await h.removeWorktree(b);
+
+ const merged = await h.mergeTrees(base, treeA, treeB);
+ expect(merged.kind).toBe('clean');
+ if (merged.kind !== 'clean') return;
+
+ // The merged tree promotes into the canonical workspace with BOTH children's work.
+ await h.promoteTree(merged.tree);
+ expect(await readFile(join(root, 'a.txt'), 'utf8')).toBe('from child A\n');
+ expect(await readFile(join(root, 'b.txt'), 'utf8')).toBe('from child B\n');
+ expect(await readFile(join(root, 'file.txt'), 'utf8')).toBe('base\n');
+ });
+
+ it('mergeTrees reports OVERLAPPING edits as a typed conflict and applies nothing', async () => {
+ const h = host(root);
+ const base = await new GitWorkspace(root).diffHash();
+
+ const a = await h.addWorktree('HEAD');
+ await writeFile(join(a.root, 'file.txt'), 'child A version\n');
+ const treeA = await a.scope.diffHash();
+ await h.removeWorktree(a);
+
+ const b = await h.addWorktree('HEAD');
+ await writeFile(join(b.root, 'file.txt'), 'child B version\n');
+ const treeB = await b.scope.diffHash();
+ await h.removeWorktree(b);
+
+ const merged = await h.mergeTrees(base, treeA, treeB);
+ expect(merged.kind).toBe('conflict');
+ if (merged.kind !== 'conflict') return;
+ expect(merged.detail).toContain('file.txt');
+ // Nothing was applied anywhere — the canonical tree is untouched.
+ expect(await readFile(join(root, 'file.txt'), 'utf8')).toBe('base\n');
+ });
+
it('promoteTree deletes a tracked file the winning tree dropped', async () => {
// Add a second tracked file in the canonical tree.
await writeFile(join(root, 'drop-me.txt'), 'temp\n');
diff --git a/src/workspace/git-worktree-host.ts b/src/workspace/git-worktree-host.ts
index 06f6715..2bcf5ad 100644
--- a/src/workspace/git-worktree-host.ts
+++ b/src/workspace/git-worktree-host.ts
@@ -101,6 +101,39 @@ export class GitWorktreeHost implements WorktreeHost {
}
}
+ /**
+ * EXPERIMENTAL (parallel waves) — 3-way merge `ours` and `theirs` against `base` using the modern
+ * plumbing `git merge-tree --write-tree --merge-base=` (git ≥ 2.40): a REAL recursive merge
+ * that writes only objects, never touching HEAD / index / working tree. Tree SHAs are wrapped in
+ * dangling commits first (the plumbing takes commit-ish). Exit 0 ⇒ clean (first stdout line is the
+ * merged tree OID); exit 1 ⇒ textual conflict (typed, with the conflicted paths — nothing is
+ * applied anywhere); anything else throws fail-closed.
+ */
+ async mergeTrees(
+ base: string,
+ ours: string,
+ theirs: string,
+ ): Promise<{ kind: 'clean'; tree: string } | { kind: 'conflict'; detail: string }> {
+ const b = await this.#toCommitish(base);
+ const o = await this.#toCommitish(ours);
+ const t = await this.#toCommitish(theirs);
+ const r = await this.#git(['merge-tree', '--write-tree', `--merge-base=${b}`, o, t]);
+ if (r.code === 0) {
+ const tree = r.stdout.trim().split('\n')[0] ?? '';
+ if (tree.length === 0) throw new Error('git merge-tree returned no tree OID');
+ return { kind: 'clean', tree };
+ }
+ if (r.code === 1) {
+ // Conflicted: stdout is \n. Surface the file names for the log.
+ const lines = splitLines(r.stdout).slice(1);
+ return {
+ kind: 'conflict',
+ detail: lines.length > 0 ? lines.slice(0, 10).join(', ') : 'textual merge conflict',
+ };
+ }
+ throw new Error(`git merge-tree failed (code ${r.code}): ${r.stderr.trim()}`);
+ }
+
/** Resolve `treeish` to a commit-ish: a ref/commit as-is, else wrap a bare tree SHA in a commit. */
async #toCommitish(treeish: string): Promise {
const commit = await this.#git(['rev-parse', '--verify', '--quiet', `${treeish}^{commit}`]);
diff --git a/src/workspace/workspace.ts b/src/workspace/workspace.ts
index 56f2716..087943a 100644
--- a/src/workspace/workspace.ts
+++ b/src/workspace/workspace.ts
@@ -64,6 +64,18 @@ export interface WorktreeHost {
* surfaces it to the outer loop, never a silent half-applied tree).
*/
promoteTree(treeish: string): Promise;
+ /**
+ * EXPERIMENTAL (parallel waves) — 3-way merge two trees against an explicit base, entirely with
+ * plumbing (`git merge-tree --write-tree`): no commit, no HEAD/branch/index movement, no working-tree
+ * touch. Returns the merged tree SHA on a clean merge, or a typed `conflict` (with the conflicted
+ * paths) — NEVER a half-merged tree: a conflicted merge writes nothing anywhere. Throws fail-closed
+ * only on a real git error (e.g. an unknown SHA), which the caller downgrades to `unmerged`.
+ */
+ mergeTrees(
+ base: string,
+ ours: string,
+ theirs: string,
+ ): Promise<{ kind: 'clean'; tree: string } | { kind: 'conflict'; detail: string }>;
}
/**
From 1eb123204264f93232cece19b83344869cc0ff2f Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 8 Jul 2026 05:29:35 +0000
Subject: [PATCH 6/6] fix(cli): adopt the resumed run's harness BEFORE the
preflight
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CI (no 'claude' on PATH) caught an ordering bug in the resume harness
adoption: the preflight validated the DEFAULT harness binary before the
resume branch swapped in the run's recorded harness, so a host without
the default CLI refused to resume a fake/codex run it could perfectly
continue ('the claude CLI was not found on PATH' instead of adopting
'fake'). The --resume validation block (missing/corrupt run, harness
adoption, DONE-extension guard, effective-config fold) now runs before
the preflight, which then checks the harness the resumed run will
actually use. Verified by running the adoption test with the claude
binary hidden from PATH — the exact CI condition.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01GQyZKAfKCeAkQZHvKv8EEa
---
src/cli/run-cmd.ts | 49 +++++++++++++++++++++++++---------------------
1 file changed, 27 insertions(+), 22 deletions(-)
diff --git a/src/cli/run-cmd.ts b/src/cli/run-cmd.ts
index f232cfe..d40f67b 100644
--- a/src/cli/run-cmd.ts
+++ b/src/cli/run-cmd.ts
@@ -210,28 +210,13 @@ export async function executeRun(parsed: ParsedArgs, io: RunIo): Promise