Skip to content

Keep beads_update off the merge lane's critical path - #5

Open
rsetia wants to merge 3 commits into
mainfrom
merge-lane-off-beads-update
Open

Keep beads_update off the merge lane's critical path#5
rsetia wants to merge 3 commits into
mainfrom
merge-lane-off-beads-update

Conversation

@rsetia

@rsetia rsetia commented Jul 31, 2026

Copy link
Copy Markdown
Owner

What

The merge lane exists to stop two merge_resolve nodes racing each other into targetBranch. It was chained through each bead's terminal node, which is the beads_update node whenever includeBeadsUpdate is on (the default). So every later merge waited on the previous bead's issue-tracker bookkeeping.

Chain merge → merge instead. beads_update still hangs off its own merge; it just no longer gates the next one.

before:  implement-a → merge-a → update-a → merge-b → update-b
after:   implement-a → merge-a → merge-b
                            ↘ update-a      ↘ update-b

beads_update is a single bd update <id> execFile in beadsRepo ?? cwd (cli-builtins.ts:355-372) — no git, no gh, no target branch. merge_resolve is a Codex agent that merges through GitHub and never direct-pushes the target (codex-contracts.ts:304-344), running in its own provisioned worktree. The two contend for nothing, so the edge between them was pure latency.

What this does not change

  • Ordering is still alphabetical by bead id. orderedPlans breaks topological ties with compareStrings, so among independent beads the merge order is fixed before the run starts and has nothing to do with which PR is ready first.
  • The failure cascade is still there. Because the lane is a dependency edge and propagateBlockedNodes iterates to a fixpoint, one failed implement still blocks every downstream merge — including PRs that already passed review.

Both need the lane to become an engine-level lease (a named resource granted in readiness order) rather than an edge. That is a larger change to dispatchReadyNodes and the graph schema, and is out of scope here.

Regenerating graphs

This is a generator change, so graphs already emitted keep the old shape until regenerated with prism beads-dag. Old-shape graphs remain valid and runnable — update-a → merge-a → implement-a is a real, satisfiable path, just a longer one. No schema change, graph version stays 1, nothing to migrate.

Runs in flight are unaffected either way: engine.resume replays stored.graph from the run store, so a run is pinned to the snapshot taken at creation.

Test plan

Three commits: the fix, a behavioral test, and coverage for the flag that governs it.

  • Shapemerge-b → [implement-b, merge-a] and update-a → [merge-a].
  • Behavior — runs the generated graph on the real engine at maxConcurrency: 4 with a beads_update executor that never resolves for update-a, and asserts merge-b ran anyway. This is the claim the shape assertion only implies: an earlier bead's bookkeeping can hang forever without delaying a later merge.
  • Opt-outserializeMerges: false had no coverage at any level, SDK or CLI, despite being the flag governing these exact lines. Now asserts both merges stay independent.

Reverting the one-line fix fails both new tests, with expected [ 'merge-a' ] to include 'merge-b' and expected [ 'implement-b', 'update-a' ] to deeply equal [ 'implement-b', 'merge-a' ] — so they are regression tests, not restatements of the diff. The behavioral one flushes a fixed number of macrotask ticks rather than awaiting merge-b, so the old behavior fails in ~20ms instead of hanging to a vitest timeout.

Full suite: 501 tests pass. npm run lint and npm run typecheck clean.

Not covered: merge_resolve's actual GitHub behavior. Its implementation is instruction text executed by an LLM, and its only seam is the ExecutionBackend that launches the codex worker — faking that is stubbing the agent. There is no fake-gh rung to drop to without running a real model. That gap doesn't widen here, since this PR changes when merge_resolve may start, not what it does.

🤖 Generated with Claude Code

rsetia and others added 3 commits July 30, 2026 23:43
The merge lane exists to stop two merge_resolve nodes touching targetBranch
at once. It was chained through each bead's terminal node, which is the
beads_update node whenever includeBeadsUpdate is on (the default) — so every
later merge waited on the previous bead's issue-tracker bookkeeping, an
executor that never touches the target branch.

Chain merge -> merge instead. beads_update still hangs off its own merge, it
just no longer gates the next one.

This does not change the lane's ordering (still alphabetical by bead id) or
its failure semantics (a failed implement still blocks every downstream
merge, because the lane is a dependency edge). Both need the lane to become
an engine-level lease rather than an edge; that is a separate change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shape assertion says merge-b depends on merge-a. This adds the
behavioral claim underneath it: hold update-a open indefinitely and
merge-b still runs.

Reverting the generator change fails both tests — the shape one on the
edge list, this one on `expected [ 'merge-a' ] to include 'merge-b'` —
so it is a real regression test, not a restatement. It flushes a fixed
number of macrotask ticks rather than awaiting merge-b, so the old
behavior fails in milliseconds instead of hanging to a vitest timeout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The flag governing the lane had no test at any level — not the SDK
option, not the --no-serialize-merges switch. Its entire contract is
"emit no lane edges", so a regression that leaked edges into the
opt-out path would have passed the whole suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant