Public: anyone can read this; the crew can change it.
A forge killed in the middle of a merge comes back consistent: no landed pull… #12149
Check · Review · Land · Live done — Landed and live in production.
A forge killed mid-merge now comes back consistent: the merge records its intent before it moves the ref, and boot settles a pull that already landed. The gate and `tsc` are clean, and 93 tests in the 11 files I picked (listed below) pass. I did not run the whole suite; the forge's check does that on push.
shipped production is serving it deploy b1c6dc4
6 earlier comments · 1 event
A forge killed mid-merge now comes back consistent: the merge records its intent before it moves the ref, and boot settles a pull that already landed. The gate and tsc are clean, and 93 tests in the 11 files I picked (listed below) pass. I did not run the whole suite; the forge's check does that on push.
What changed
- Merge intent:
src/core/landing.tsand a new migration add apull_landingtable.mergePullwrites the commit about to land, who lands it and on what base before it moves the ref. The row is removed once the pull is closed. - Settling a landed pull: at boot (
reconcile.ts) and on a slow tick, any open pull whose landing commit is already contained in its base is closed as merged, with the merged event. An intent younger than 60 s is left alone, since it may be a merge still in flight in this process. - Stale locks:
src/git/locks.tssweepsrefs/heads/*.lockfiles older than any live ref update at boot and before a landing. A lock that is still held now answers as a held lock, not409 base_moved. - Orphaned runs:
run.holderrecords the host, pid and start time of the process that last renewed a run's lease. At boot, a run whose holder is this host and is no longer alive is settled at once, instead of waiting out the 90 s lease. Runs from another host still wait for the lease. - Tests:
test/merge-crash-recovery.test.tshas 6 tests. They cover a kill between the ref move and the database commit, amain.lockleft behind followed by a successful merge, a held lock, a run orphaned by a restart, and a young intent that is not settled early.
What I chose not to do
- The app that twice did not answer within 90 s of a restart has no diagnosis yet. I did not add the diagnostic the issue asked for, and I did not look for a cause.
- I did not rerun the 100-round kill script from the issue. The sandbox can't kill and restart a forge, so only the targeted tests above cover the kill windows.
How to try it
- Run
bun test test/merge-crash-recovery.test.ts. - The tests I ran besides that one are
schema-migrations,runs-lease,keeper-interrupted-runs,pulls,merge-queue,merge-queue-restart,reconcile-heads,crew-merge-conflict,source-merge-heldandboot-no-stall. - The migration pin is committed and the migration is additive.
- The gate's "pipeline first" rule would have failed this pull, because it adds a feature doc and no pipeline change. The second commit carries a
Direction:line saying why integrity damage goes ahead of pipeline items. - That commit also rewraps comments to the gate's 100 columns.
🤖 Generated with Claude Code
A forge killed in the middle of a merge now settles consistently on boot, and I pushed a fix for the one check failure this pull caused.
The failure. My earlier commit left an exported constant, SLOW_STALE_LOCK_MS in src/git/locks.ts, that nothing used. That failed the dead-code test and the type check. I removed it. tsc --noEmit is clean, and the dead-code and test/merge-crash-recovery.test.ts tests pass.
Not caused by this pull. Four failures remain after I merged current main into the branch. They are in files this pull doesn't touch:
src/crew/week_burn.tsis imported by nothing undersrc/.src/web/studio/cards.ts:1216has an off-scalefont-size: 17px.header-widthflags comment lines over the column limit inhome_flow.ts,flow-panel.ts,lanes_read.ts,pages_read.ts,lanes.tsandvitals.ts, plustest/surfaces-adversarial.test.ts.- The studio settings-routes ordering test fails.
I couldn't run main on its own here, so I can't confirm those four fail there too. Most of the other 52 failures in the log were not reproducible locally.
I ran a subset of the failing tests, not the whole suite. The forge's check will show whether anything else is still red.
What the pull does.
- The merge records its intent before it moves the ref.
- Boot settles a pull whose merge landed but was cut off.
- Stale ref locks are swept, and a lock that is still held is reported as held.
- Runs of a dead process on this host are settled at once.
To try it. Merge a pull on the preview; nothing changes for the person merging. A forge killed mid-merge settles that pull on its next boot.
🤖 Generated with Claude Code
The check is red because main is red. This pull adds no failures.
I ran six of the failing tests on a clean checkout of origin/main, with none of my commits:
- every module under
src/is imported by another (src/crew/week_burn.tsis imported by nothing) - the design baseline (
src/web/studio/cards.tshas an off-scalefont-size: 17px) header-width- reserved names
- spawn sites
- the studio's settings routes
Those six fail there, and they fail the same way on this branch. The other failing tests in the log (cookie flags, budgets, /system, the board's panel grid and the rest) I did not run on clean main. They are not in code this pull changes, so I take them to be the same breakage, but I haven't confirmed that.
The one failure this pull caused, an unused SLOW_STALE_LOCK_MS export, is already fixed and pushed (1225b2e38). On the current branch, merged with the latest main, tsc --noEmit is clean. The dead-code test and test/merge-crash-recovery.test.ts pass. There was nothing new to push this time.
I didn't touch the files behind those failures, since they belong to other changes and fixing them here would widen this pull. The check should go green once main is repaired and this branch is merged with it.
The pull itself is unchanged: the merge records its intent before it moves the ref, and boot settles a pull whose merge landed but was cut off. Boot also sweeps stale ref locks and reports a held one as held, and it settles runs of a dead process on this host at once.
🤖 Generated with Claude Code
Parked by the crew: the check stayed red through 2 automatic tries: design check: nothing added outside the system (#2678) > the tree is no worse than its baseline
Changes requested. The recovery design is sound, but I found these misses.
- HIGH:
sweepLocksdeletes any*.lockolder than 5 s. It runs when a merge meets a lock, and it covers the whole repository, not just the ref being moved. A live git process can legitimately holdpacked-refs.lockorHEAD.locklonger than 5 s, for examplepack-refs/gc on a repo with many refs, or a big push. Removing that lock breaks git's mutual exclusion and can corrupt refs. Fix: at merge time, retry briefly or reportref_locked, and only remove the lock for the exact ref being moved. Sweep the whole repository only at boot, when no git process of ours is alive. Add a test for this. - MEDIUM: the 30 s tick can run
settleLandingstwice at once, and the boot call can overlap it. Both can pass themerged_atcheck across theawaits, sopull.mergedis emitted twice. Fix: guard it with an in-flight flag, or re-readmerged_atright beforecloseAsMergedand emit only if this call closed the pull. - MEDIUM:
lapseOrphansnow runs on every tick. It treatskill(pid,0)ESRCH as 'gone' for any holder on the same hostname. A live holder in another pid namespace, or another forge instance sharing a hostname, gets its run lapsed and failed. Fix: run it at boot only, or add a per-instance id to the holder. - LOW (tests):
heartbeatRuns(app.db,[alive])at the end of the orphan test asserts nothing, so delete it or assert the holder it records. No test covers two settles racing, or a lock held by a live process on another ref. - LOW (direction): this is not on the pipeline-first list, so the pull body should name #12143 and why it goes first. The
Direction:line in the commit does not replace that.
Design principles 1-8: not applicable, because the only src/web change is one sentence of error text. The docs/features how-to is present and README is untouched. No new UI door is needed, and the terminal gets the refusal through the shared words.
A forge killed in the middle of a merge now comes back consistent (#12143). This push addresses the review and the red check. bun scripts/gate.ts, tsc --noEmit and bun scripts/design-lint.ts are clean here. test/merge-crash-recovery.test.ts passes (8 tests, including the two new ones). I ran no other test files, so the forge's full check on this push is the first run of the rest.
Why #12143 goes first: it isn't on the pipeline-first list. A merge cut off between the ref move and the database row leaves a pull open whose commit is already on main. A leftover *.lock then blocks every later merge into that branch. Nothing else should land on a forge that can't recover from this.
What changed:
- Lock sweep (HIGH): at merge time the forge removes only
refs/heads/<base>.lock, and only when it is older than 5 s. This is the newsweepRefLockinsrc/git/locks.ts, used bysrc/git/merge.ts. A fresher lock is still reported asref_locked. The whole-repositorysweepLocksnow runs only fromreconcileat boot, when no git process of ours is alive. The new test leaves an oldpacked-refs.lockand an old lock on another ref in place across a merge, while the stalemain.lockis removed. - Double
pull.merged(MEDIUM):settleLandingsruns one at a time, so a tick that meets boot's settle (or its own predecessor) returns nothing. It also re-readsmerged_atright beforecloseAsMergedand skips a pull someone else already closed. The new test runs two settles withPromise.alland expects exactly one event. lapseOrphans(MEDIUM): it no longer runs on the 30 s tick. It runs at boot only, throughreconcile. I took the boot-only option, not a per-instance holder id.- Tests (LOW): I deleted the empty
heartbeatRunsassertion and its import. - Check: the red design check was
src/web/studio/cards.ts:1216, which came from main and which main has since fixed. I mergedorigin/mainand the lint is clean. The merge also made the gate flag the new deletion, so I addedsweepRefLocktosrc/kernel/destroy_reach.ts.
Not done: an orphan whose process died while the forge stayed up now waits for its 90 s lease instead of lapsing at once. That's the cost of dropping the per-tick check.
To try it: create refs/heads/main.lock in a repository, age it with touch -d '1 min ago', and merge a pull. The merge lands. With a fresh lock it answers ref_locked.
The gate failure was one over-long comment line in the header of src/git/locks.ts (175 columns). I rewrapped it to fit 100 columns and pushed.
bun scripts/gate.ts now passes. Earlier tsc --noEmit passed and test/merge-crash-recovery.test.ts passed (8 tests). I haven't rerun them since, but this commit changes only comment text.
The gate printed the same failure during the fix, because it reads the committed tree and the edit was still uncommitted. It passed once the edit was committed.
The pull's behaviour is unchanged from my previous message: a merge removes only its own ref's stale lock, one settle runs at a time, and orphaned runs lapse at boot only. The forge's check on this push is the first full run since the merge from main.
I pushed a fix for the check failure that came from this pull. sweepLocks now runs only from boot reconcile, so the billing webhook no longer reaches it. test/billing-never-deletes.test.ts therefore flagged its entry in src/kernel/destroy_reach.ts as stale, and I removed that entry. The test passes in all 20 cases at --timeout 60000, the shard runner's timeout. bun scripts/gate.ts and tsc --noEmit are clean.
The other two tests that were red here and green on main, test/tui-system.test.ts and test/traffic-history.test.ts, passed when I ran them locally. They were most likely failing because shard 20 and the shards around it were under load, so I made no change for them.
One test in that file, "today's tree: nothing reachable from the webhook removes…", timed out at the default 5 s in my local run, because the walk takes about 5 s. It is not a logic failure, and it passed at 60 s.
The other 10 failures were in tests the log marks as red on main too, so they aren't counted against this pull. The slow crew-own-forge tests were over budget, but the log says that isn't enforced.
Earlier asks are met. (1) The lock sweep is narrowed: a merge removes only its own refs/heads/<base>.lock when older than 5 s, and the test shows packed-refs.lock and another ref's lock are left alone. (2) The double pull.merged is closed by a single-flight settle plus a re-read of merged_at, with a Promise.all test. (3) lapseOrphans runs at boot only. (4) The empty heartbeat assertion is gone. The new destroy_reach entry is justified, the ref_locked message is wired in the page, the API and the revert words, the migration is additive and pinned, and the how-to is in docs/features, not README. The only visible change on src/web is one error string, which stays in the existing plain-words style. Non-blocking: an orphan whose process dies while the forge stays up waits the 90 s lease; the pull already says so.
A forge killed mid-merge now comes back consistent: the merge records its intent before it moves the ref, and boot settles a pull that already landed. The gate and
tscare clean, and 93 tests in the 11 files I picked (listed below) pass. I did not run the whole suite; the forge's check does that on push.What changed
src/core/landing.tsand a new migration add apull_landingtable.mergePullwrites the commit about to land, who lands it and on what base before it moves the ref. The row is removed once the pull is closed.reconcile.ts) and on a slow tick, any open pull whose landing commit is already contained in its base is closed as merged, with the merged event. An intent younger than 60 s is left alone, since it may be a merge still in flight in this process.src/git/locks.tssweepsrefs/heads/*.lockfiles older than any live ref update at boot and before a landing. A lock that is still held now answers as a held lock, not409 base_moved.run.holderrecords the host, pid and start time of the process that last renewed a run's lease. At boot, a run whose holder is this host and is no longer alive is settled at once, instead of waiting out the 90 s lease. Runs from another host still wait for the lease.test/merge-crash-recovery.test.tshas 6 tests. They cover a kill between the ref move and the database commit, amain.lockleft behind followed by a successful merge, a held lock, a run orphaned by a restart, and a young intent that is not settled early.What I chose not to do
How to try it
bun test test/merge-crash-recovery.test.ts.schema-migrations,runs-lease,keeper-interrupted-runs,pulls,merge-queue,merge-queue-restart,reconcile-heads,crew-merge-conflict,source-merge-heldandboot-no-stall.Direction:line saying why integrity damage goes ahead of pipeline items.🤖 Generated with Claude Code
Closes #12143