08 · Parallel work — waves and worktrees¶
← 07 Setup and the three lanes · Contents · Next: 09 Governance →
Parallel streams (opt-in)¶
The default is one task at a time. But when a milestone's frontier is several tasks that do not depend on each other, you can build them concurrently — one builder per ready task, each behind its own frozen contract, each isolated in its own git worktree. This is opt-in and additive: a milestone that never fans out behaves exactly as the three-beat loop already does.
The engine stays a NO-EXEC notary. It does not spawn builders and it does not run the method. It does exactly two things for a wave — it plans the wave (and proves the plan is safe) and it joins the results (losslessly). You create the worktrees and spawn the builders; the engine records.
Be honest about the gain. With one human reviewer you cannot beat review_time × N_tasks; the human-led decision points are serial. So the win is not N× throughput — it is that the reviewer is never blocked waiting on a build. While a person reviews task A's frozen contract, the builds for B, C, and D run behind their frozen contracts. You hide build latency under human-review latency; do not promise more.
Plan the wave — the engine proves it is safe¶
add wave <milestone> # derive the DAG schedule: topological levels,
# each a set of mutually-independent tasks
add wave <milestone> --streams a,b,c # record ONE level as the active wave
A level is a set the engine has proven safe to run at once. It refuses an unsafe wave, so you never fan out into a race:
- R:CYCLE — a dependency cycle among the tasks; no parallel plan exists on a cyclic graph.
- R:INTRADEP — two streams with a dependency path between them; they must sequence across waves, not within one.
- R:OVERLAP — two streams whose
scope:shares a file; disjoint scope is the write-safety invariant, so a shared file is refused.
Recording a wave writes the active level down — the engine tracks which tasks are building together, so a stale plan cannot be joined by accident.
Streams can be persona-assigned. A wave pick may carry a lens as slug:persona, assigning that stream to a seeded persona:
add wave <milestone> --streams payments:backend-systems,checkout-ui:frontend-ux
The engine checks the lens is a real Persona node in the bundle (else R:BADPERSONA — seed it first). A persona is a lens on the work, never a lowered floor: it never buys back a gate, and security stays HARD-STOP whatever persona wears the stream.
Isolate and build — one worktree per stream¶
Give each stream its own git worktree on its own branch, forked from the join point, each carrying its own .add/. Because the wave guaranteed disjoint scope, the streams only ever touch different files — so the build phase cannot race; the only reconciliation is the join.
Inside its worktree, each stream runs its own full three-beat loop: direction is already frozen, so it builds to green and records its own verdict — add gate <slug> in its worktree. A stream that hits a security finding or an unmet Must gates HARD-STOP there, and does not merge.
Join — fold the worktrees back, PASS-only¶
add join <stream-1>/.add <stream-2>/.add … # one bundle path per worktree
join reconciles by the bundle format's own invariants:
- PASS-only — a HARD-STOP stream is structurally un-mergeable; no union or flag softens it.
- Task nodes copied byte-for-byte — disjoint scope made this lossless.
- Spec deltas union-merged — every stream's lessons land; a same-lesson / different-disposition divergence is FLAGGED for you, never silently double-kept.
- The graph is regenerated, never copied — it is a rebuildable cache, so the joined bundle recomputes it.
Rollback is just dropping a worktree. Join leaves every other stream byte-intact, so a bad stream is discarded without touching its siblings.
Design for failure (required)¶
Concurrency multiplies the ways a run can go wrong, so the wave is built to fail safely:
- Worktree isolation — a builder owns only its own worktree and its own
.add/; two concurrent builds physically cannot collide, because the wave proved their scopes disjoint. - Lease + timeout — lease each stream to its builder with a timeout; if a builder dies, release the claim rather than trusting partial work. A builder that stops-and-escalates blocks only its own stream; siblings keep running.
- Serial join + integration verify — bring worktrees back one at a time and run an integration verify for the concurrency and architecture conflicts that two-green-in-isolation tasks can still produce. The notary never auto-passes that step.
- Circuit-break to sequential — if several streams fail in one wave, trip the breaker and fall back to one task at a time. Repeated failure means the scope was wrong, not that you need more parallelism.
The floors hold for N builders exactly as for one¶
- No stream owns a gate. Each stream gates its own task in its worktree; join only records the outcome — it never manufactures a PASS. You (or the human) still own the milestone-level decision.
- security = HARD-STOP — per stream and at the join. A HARD-STOP stream can never be merged.
- High-risk still escalates to the human — a wave is a scheduling tool, not a lowered floor.
- Each stream stays inside its
scope:and never edits a frozengives:; the wave's disjoint-scope refusal is what makes that mechanical rather than merely asked-for.
The full builder contract and the per-runner spawn adapter live in the skill's streams.md; this chapter is the why and the safety frame, not the operational recipe. The engine plans and joins; you build; the floors never move.