fix(loop): save stranded work to a branch before any edge removes it (#405, #400) - #434
Merged
Merged
Conversation
…else (#405) A coder that dies before its candidate is promoted leaves the only copy of its work in that tree, and every automatic edge that touched it was destructive: a re-dispatch ran create_worktree, whose "clean a prior run's leftovers" step is `git worktree remove --force` + `git branch -D`; promote_worktree cleared a stale canonical the same way; the by-id reap (merge, closed PR, cancel, done, health sweep) and the shutdown reap removed whatever was there. bd-ezs7's finished 170 lines (#400) survived only because its drive had hung in #423's unbounded proc.wait() and still held the card's file claim, so nothing re-dispatched it. Now "work" is every uncommitted change git reports (modified, staged, deleted, untracked), minus the board's own droppings (.proto/.cursor scratch and the node_modules links it adds), plus, on a candidate branch, commits no other ref holds. A tree holding work is never destroyed automatically: - a fresh build discards the drive's OWN previous attempt explicitly, then checks every tree the card has; if any holds work the card blocks under a new non-healing `stranded-work` class whose reason names each path, its branch, what is in it, and how to recover or discard it. No coder runs. - create_worktree / promote_worktree raise StrandedWorkError instead of force-cleaning such a tree. - reap_feature_worktree keeps it and logs it once; clean trees still reap. - shutdown keeps an interrupted tree holding work; its card blocks on the next dispatch. Max-Mode's losers are the drive's own judged candidates, so they are now discarded by path (like the solve ladder's), not through the guarded by-id reap. unpublished_work is a new local-git seam, REAL-covered against real git. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
… saving fails Blocking every card with stranded work turned each plugin roll into manual toil: a restart mid-build strands one tree per in-flight drive, and the board cannot tell a half-written tree from a finished one waiting on its gate. So instead of refusing, every edge that would remove such a tree now SAVES it first: preserve_worktree commits the tree's HEAD plus its working state (through stage_all, into a private GIT_INDEX_FILE, so the tree, its index and its branch are untouched; commit-tree so no hooks or signing prompt run; identity pinned) onto a NEW branch stranded/<tree id>/<UTC stamp>. update-ref with an empty old value never overwrites an existing branch, and the ref's tree is read back and compared before the tree may go. - a fresh build saves every stranded tree the card owns, removes them, leaves a card comment (branch, diffstat, `git diff origin/<base>...`, cherry-pick) and builds on; only a tree whose save FAILED blocks the card as stranded-work, kept intact. - shutdown saves the interrupted tree, comments, and reaps it; the next boot rebuilds as before. - the by-id reap and create_worktree/promote_worktree save then clear; the latter two refuse with StrandedWorkError only when saving fails. stage_all now also leaves out the node_modules symlink create_worktree links in: a `node_modules/` ignore pattern matches only a real directory, so the board's own link was committed into PRs. The brief no longer tells an edit-only coder to "push the branch if you can" (#400): it now says the loop commits, runs the pre-PR checks, pushes and opens the PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
This was referenced Sep 10, 2026
Adversarial review of #434 showed "every edge that would remove a tree holding unpublished work saves it first" did not hold yet. Each gap is now closed, with a real-git test: 1. Committed-but-unpushed work on the CARD's branch (the verified candidate coder_seam commits before open_pr pushes it) was not counted, so a restart or a failed push lost it. Every branch's commits count now - HEAD and the branch, no other ref holding them - unless their CONTENT is already published: `git merge-tree --write-tree` into origin/<branch>, origin/<base> or origin/HEAD changes nothing. That keeps rebased and squash-merged history from reading as stranded, and auto-merge now reaps the tree before deleting the PR branch, while that tracking ref still proves it. 2. Cancel removed the tree unsaved, racing the cancel route's save-first reap. Every removal now goes through one locked save-then-remove (discard_worktree), so two edges on one tree give one save and one removal. 3. Our own naming made saves fail: the ref dropped the title slug and stamped to the second. It is now stranded/<tree dir>/<UTC ms>, retried with a suffix on a taken name, never an overwrite. 4. A saved tree git would not remove was reported removed, stayed, and was re-saved (or blocked) on every edge; a husk was kept forever. After a verified save the tree is deleted anyway (never a locked one), reports say truthfully whether it went, a husk is moved aside with its bytes and its branch's commits saved, and the block's discard command works on a husk. 5. Commits on a detached HEAD were lost; HEAD is counted and saved, with the branch tip as a second parent when HEAD moved off it. 6. A fresh build swept the operator's live test-rung tree; card sweeps now leave feat-<id>.test* trees to the diagnostic that owns them. 7. A nested git repository was half saved (a gitlink) and its files deleted; such a tree is now refused and kept whole. 8. A drive's terminal block (a push GitHub refused, say) removed the finished, committed tree unsaved; block and cancel removals now save first. Saves run with hooks off (core.hooksPath=/dev/null) - update-ref fires reference-transaction - and the brief only promises pre-PR checks when a gate is configured. The seam registry gains the three new helpers (27 REAL). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
Resolves the overlaps with #425/#432/#435/#428: failures.py keeps main's seam shapes beside STRANDED_WORK_CLASS; the kernel exports both TOO_WIDE_CLASS and STRANDED_WORK_CLASS; the terminal-block path takes main's structure (the #378 ask moved to the park path) with this branch's save-first discard; max-mode's all-raised fan-out keeps #425's representative failure and discards its own candidates by path. The new too-wide park removal saves first too, and the #425 test now expects the candidates discarded by path, not reaped by id. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
|
|
Brings in #430 and #437. The one conflict was the worktree seam contract: main's #402 made `pr_identity` REAL (24) while this branch added #405's four stranded-work seams (27), so the contract is now 28 REAL / 3 EXEMPT / 0 UNCOVERED, and the README says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
Brings in #431, #438 and #436. #436 overlaps this branch semantically: it re-reads the card before every drive-side block, retry, publish and hand-off, and a drive whose card was moved on under it stands aside. It keeps the worktree, and a held or done card gets no PR, so the finished work stays unpushed in that tree. This branch saves any tree holding work before removing it. The combined rule: - Every block site takes #436's gate (`_block_or_stand_aside`), and the removal after a real block is this branch's save-then-remove (`_discard_tree`), never a bare `remove_worktree`. The six conflicting sites are resolved that way. - The stranded-work block (a fresh build that could not save a leftover tree) now goes through `_block_or_stand_aside` too, so it never overwrites a card someone moved. - A build that stood aside has let go of its tree (`_stand_aside` drops it from `_inflight`), so shutdown leaves it alone. The by-id reap and the card's next fresh build save its work to a `stranded/…` branch before removing it. tests/test_moved_build_tree_405.py runs that case end to end on real git and a real `br` board: a human holds the card while its coder finishes, and the build stands aside with no PR. Shutdown then leaves the tree, and the reap saves it to `stranded/…`. The card keeps the human's state, reason and class, with no PR. A second test lifts the hold, and the next round's clean-up saves the first round's work before building fresh. Both tests fail on main (nothing is saved) and on this branch's previous head (the held card gets a PR). docs/lifecycle.md keeps both new sections, cross-referenced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
…pt away (#398, #405) A retry that rebuilds from scratch removes the drive's own failed attempt without saving it. That is by design (#405): the drive built that tree and judged it. But #436's ownership check sits at the top of the attempt, and the prompt is built after it, including a knowledge-graph fetch that can take seconds. A human hold that landed in that window turned the tree into a moved build's, and it was removed unsaved anyway. A coder that fell over half-way through leaves its partial work there. The drive now re-reads the card right before that removal. A card moved on keeps its tree through the same stand-aside as every other moved build, and whatever ends that tree later saves it first. The test fails without the re-read: the retry is dispatched on the held card and attempt 1's tree is gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
No conflicts. #433 moves the working-state snapshot onto its own refresher task, which `stop()` now cancels alongside the tick. Shutdown's save-then-remove of in-flight trees still runs after both. `clear_blocked` now drops any blocked-class label, and that includes `stranded-work`, so unblocking a card after recovering its tree works as the docs say. The seam ratchet, recomputed from the scanner: worktree.py has 31 seams (28 REAL, 3 EXEMPT, 0 UNCOVERED) and store.py has 49 (33 REAL, 16 UNCOVERED). The constants are exact: MAX_UNCOVERED_WORKTREE 0, MAX_EXEMPT_WORKTREE 3, MAX_UNCOVERED_STORE 16. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
|
|
mabry1985
added a commit
that referenced
this pull request
Sep 11, 2026
main since the last merge carries only #434's squash (ca9498f), whose tree is identical to #434's branch tip, which this branch already contains; conflicts resolved to this branch's side, and the resulting tree equals df54273's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
mabry1985
added a commit
that referenced
this pull request
Sep 11, 2026
#439) * fix(loop): never destroy a worktree holding work that exists nowhere else (#405) A coder that dies before its candidate is promoted leaves the only copy of its work in that tree, and every automatic edge that touched it was destructive: a re-dispatch ran create_worktree, whose "clean a prior run's leftovers" step is `git worktree remove --force` + `git branch -D`; promote_worktree cleared a stale canonical the same way; the by-id reap (merge, closed PR, cancel, done, health sweep) and the shutdown reap removed whatever was there. bd-ezs7's finished 170 lines (#400) survived only because its drive had hung in #423's unbounded proc.wait() and still held the card's file claim, so nothing re-dispatched it. Now "work" is every uncommitted change git reports (modified, staged, deleted, untracked), minus the board's own droppings (.proto/.cursor scratch and the node_modules links it adds), plus, on a candidate branch, commits no other ref holds. A tree holding work is never destroyed automatically: - a fresh build discards the drive's OWN previous attempt explicitly, then checks every tree the card has; if any holds work the card blocks under a new non-healing `stranded-work` class whose reason names each path, its branch, what is in it, and how to recover or discard it. No coder runs. - create_worktree / promote_worktree raise StrandedWorkError instead of force-cleaning such a tree. - reap_feature_worktree keeps it and logs it once; clean trees still reap. - shutdown keeps an interrupted tree holding work; its card blocks on the next dispatch. Max-Mode's losers are the drive's own judged candidates, so they are now discarded by path (like the solve ladder's), not through the guarded by-id reap. unpublished_work is a new local-git seam, REAL-covered against real git. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av * docs(changelog): stranded-work fragment for #434 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av * fix(loop): save stranded work to a branch and build on; block only if saving fails Blocking every card with stranded work turned each plugin roll into manual toil: a restart mid-build strands one tree per in-flight drive, and the board cannot tell a half-written tree from a finished one waiting on its gate. So instead of refusing, every edge that would remove such a tree now SAVES it first: preserve_worktree commits the tree's HEAD plus its working state (through stage_all, into a private GIT_INDEX_FILE, so the tree, its index and its branch are untouched; commit-tree so no hooks or signing prompt run; identity pinned) onto a NEW branch stranded/<tree id>/<UTC stamp>. update-ref with an empty old value never overwrites an existing branch, and the ref's tree is read back and compared before the tree may go. - a fresh build saves every stranded tree the card owns, removes them, leaves a card comment (branch, diffstat, `git diff origin/<base>...`, cherry-pick) and builds on; only a tree whose save FAILED blocks the card as stranded-work, kept intact. - shutdown saves the interrupted tree, comments, and reaps it; the next boot rebuilds as before. - the by-id reap and create_worktree/promote_worktree save then clear; the latter two refuse with StrandedWorkError only when saving fails. stage_all now also leaves out the node_modules symlink create_worktree links in: a `node_modules/` ignore pattern matches only a real directory, so the board's own link was committed into PRs. The brief no longer tells an edit-only coder to "push the branch if you can" (#400): it now says the loop commits, runs the pre-PR checks, pushes and opens the PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av * fix(loop): close the gaps review found in save-before-remove (#405) Adversarial review of #434 showed "every edge that would remove a tree holding unpublished work saves it first" did not hold yet. Each gap is now closed, with a real-git test: 1. Committed-but-unpushed work on the CARD's branch (the verified candidate coder_seam commits before open_pr pushes it) was not counted, so a restart or a failed push lost it. Every branch's commits count now - HEAD and the branch, no other ref holding them - unless their CONTENT is already published: `git merge-tree --write-tree` into origin/<branch>, origin/<base> or origin/HEAD changes nothing. That keeps rebased and squash-merged history from reading as stranded, and auto-merge now reaps the tree before deleting the PR branch, while that tracking ref still proves it. 2. Cancel removed the tree unsaved, racing the cancel route's save-first reap. Every removal now goes through one locked save-then-remove (discard_worktree), so two edges on one tree give one save and one removal. 3. Our own naming made saves fail: the ref dropped the title slug and stamped to the second. It is now stranded/<tree dir>/<UTC ms>, retried with a suffix on a taken name, never an overwrite. 4. A saved tree git would not remove was reported removed, stayed, and was re-saved (or blocked) on every edge; a husk was kept forever. After a verified save the tree is deleted anyway (never a locked one), reports say truthfully whether it went, a husk is moved aside with its bytes and its branch's commits saved, and the block's discard command works on a husk. 5. Commits on a detached HEAD were lost; HEAD is counted and saved, with the branch tip as a second parent when HEAD moved off it. 6. A fresh build swept the operator's live test-rung tree; card sweeps now leave feat-<id>.test* trees to the diagnostic that owns them. 7. A nested git repository was half saved (a gitlink) and its files deleted; such a tree is now refused and kept whole. 8. A drive's terminal block (a push GitHub refused, say) removed the finished, committed tree unsaved; block and cancel removals now save first. Saves run with hooks off (core.hooksPath=/dev/null) - update-ref fires reference-transaction - and the brief only promises pre-PR checks when a gate is configured. The seam registry gains the three new helpers (27 REAL). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av * feat(board): salvage a stranded card's worktree without a coder (#427) bd-ezs7's coder finished 170 lines and died before publishing them, and recovering that work needed a coder: every publication step was only reachable by dispatching one, and that day the coder delegate was down for an unrelated reason. `board_salvage_feature` / `POST /features/{fid}/salvage` runs the board's own tail instead: commit what the tree holds, the pre-PR gate, push, open the PR, move the card to in_review. It refuses, touching nothing, while a live drive or another salvage owns the card, for a card that is not stranded (in_progress with no drive, or blocked - a ready card could be claimed mid-publish), and when no worktree - or several, unless `tree` names one - has changes vs base. The gate runs where the tree stands: a red gate publishes nothing and hands back the output's tail unless force=true, which opens a DRAFT PR carrying that output. A candidate is promoted to the card's branch first; with review_gate on the card enters review-pending. The card is reserved in _inflight_files for the duration, so no sweep requeues it, reaps its trees or auto-unblocks it mid-publish. The crash salvage of a verified candidate (#91) and this edge now share the publish tail (_gate_tree + _open_tree_pr); open_pr gains draft=. The docs guard now also scans async tools - board_dispatch (#390) had shipped undocumented because it only looked at plain defs; it is documented now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av * docs(changelog): salvage fragment for #439 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av * fix(board): salvage proves its tree, keeps its PR honest, and holds its card (#427) An adversarial review of the salvage found it could do damage no drive can: - A leftover `feat-…` directory with no `.git` sits inside the main checkout, so git run in it answered for THAT: the salvage counted the operator's own commit as the card's work and committed the card's title onto the operator's branch. Every tree is now proven first (`own_worktree`: its own top level, and registered by the repo), and `commits_ahead` refuses to measure a directory that is not one. - `force` against a card that already had a PR adopted the READY PR and reported "draft". The adopted PR is now converted (`gh pr ready --undo`), the gate output is posted on it, and `draft` is read back from GitHub rather than assumed. A repo without drafts gets an error naming the branch it already pushed. - A card blocked out of in_review had its block cleared and then failed open_review's in_progress precondition. It now goes back to in_review on its own PR. - With review_gate on and merge_poll off nothing ever ran the review gate on a salvaged PR; the salvage now runs it inline, as a drive does. - A cancel during the publish still opened a PR for a cancelled card, and its reap could remove the tree mid-gate. The salvage re-checks the cancel before and after opening the PR (closing one it just opened), and holds its trees so the reap waits. - The claim scan and boot recovery ignored the salvage's reservation, and the salvage released the reservation unconditionally. Both now skip a reserved card, and the salvage releases only its own (by identity). The gate output is fenced with more backticks than it contains. Docs state the salvage is an operator override (goal/ledger/source-issue checks skipped; CI and the review gate still apply), that format_cmd may rewrite the tree even on a refusal, and how a hung drive has to be handled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av * fix(loop): a retry re-reads its card before throwing its failed attempt away (#398, #405) A retry that rebuilds from scratch removes the drive's own failed attempt without saving it. That is by design (#405): the drive built that tree and judged it. But #436's ownership check sits at the top of the attempt, and the prompt is built after it, including a knowledge-graph fetch that can take seconds. A human hold that landed in that window turned the tree into a moved build's, and it was removed unsaved anyway. A coder that fell over half-way through leaves its partial work there. The drive now re-reads the card right before that removal. A card moved on keeps its tree through the same stand-aside as every other moved build, and whatever ends that tree later saves it first. The test fails without the re-read: the retry is dispatched on the held card and attempt 1's tree is gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #405. Fixes #400. #400's own root cause needed correcting, and the correction is below.
What was wrong
1. Stranded work was destroyed (#405). When a coder dies before its candidate is promoted, the only copy of its work is left in that worktree. Every automatic edge that could reach that tree deleted it:
create_worktreegit worktree remove --force+git branch -Don the exact path the next build reusespromote_worktreereap_feature_worktree(merge, closed PR, cancel, done, health sweep)stop()2. The brief contradicted itself at the publish step (#400). It said both "You cannot run shell commands (edit-only)" and "Push the branch if you can".
3. The board committed its own
node_modulessymlink into PRs.create_worktreelinks the main checkout'snode_modulesinto every tree. Most repos ignore it asnode_modules/, with a trailing slash, and that pattern matches only a real directory, not a symlink. Sostage_all'sadd -Astaged the link and it rode into the PR.Evidence (bd-ezs7, protoEngineer, 2026-09-06). From the log and board DB, read-only:
23:25:50: codex reportsturn complete (stopReason=end_turn); thecoder-monitor:comment recordselapsed_s: 480.7..g2, noverified, noexhausted, and no exception. The drive task stayed alive: at00:15the claim scan skips bd-ezs7 ashot-fileoverlapping itself.d105175. Its acceptance-test timeout killed only/bin/shand then did an unboundedawait proc.wait(). That is the hang bug: gate/test subprocesses inherit the server's stdin and are killed shell-only — a hung pnpm strands drives for hours and leaks orphans #423 fixed.07:15 UTC: the PM requeued the card. The 170 lines survived only because the hung drive still held the file claim. A re-dispatch would have force-removed them.#400's root cause, corrected:
open_pr).test_a_timed_out_acceptance_test_returns_instead_of_hanging_the_drive).The fix
Save to a branch, then proceed (
worktree.preserve_worktree).stranded/<tree id>/<UTC stamp>.stage_allinto a privateGIT_INDEX_FILE, so the tree, its index and its branch are untouched.commit-treeis used so no hooks run; there is no signing prompt, and the identity is pinned.update-ref <ref> <commit> ""refuses to overwrite an existing branch.What counts as work (
worktree.unpublished_work):.proto/.cursorscratch and the board'snode_moduleslinks._drive)git diff origin/<base>...stranded/…, and how to cherry-pick it. Then it builds on.create_worktree/promote_worktreeBlock only when saving fails. If the commit or branch step fails, the tree is kept exactly as it is: work that could not be saved is never destroyed. What happens next depends on the edge:
stranded-work, a class that does not self-heal. The reason names the path, what the tree holds, why saving failed, and how to recover or discard it.create_worktree/promote_worktreeraiseStrandedWorkError.Other changes:
stage_allexcludes untracked, un-ignorednode_modulessymlinks, alongsideCODER_SCRATCH.store.reapsassertions intests/test_loop.pynow readstore.removes.Tests
tests/test_stranded_work_405.pyruns real git against a bare origin + clone.tests/test_brief_publication_400.pychecks the brief.stranded/…branch is never overwritten. If saving fails, the tree is kept and the call refuses; once the branch is freed it saves and proceeds (the recovery path).commit_worktreeno longer carries thenode_moduleslink under anode_modules/ignore.stranded-workonly when saving fails, with no coder dispatched.Red-check: I ran the final two test files against a pristine
git archive origin/main. 14 of 15 fail, each on the defect itself: "the untracked change was destroyed, not saved", "the coder's commit was dropped with its branch", "unsaved work was destroyed", "the commit carried the board's link: ['feature.py', 'node_modules']", "the drive built over stranded work without saving it", "shutdown reaped an in-flight tree holding work without saving it", "the brief still asks the coder to push".The 15th,
test_a_drive_still_discards_its_own_failed_attempt, is a labelled regression guard. It passes on main by construction. With the drive's own-attempt discard removed, it fails: the drive saves and comments on its own leftovers.unpublished_workandpreserve_worktreeare new local-git seams. Both are registered REAL intests/test_external_seams.py, taking the contract from 23 to 25 REAL; the README is updated to match.Gates:
ruff format --check .✓ ·ruff check .✓ ·pytest -q→ 1918 passed, 13 skipped (exit 0).Notes
stranded/*branches accumulate: one per saved tree. They are listed in the card comment; clear them withgit branch --list 'stranded/*'/git branch -D.stranded_worktrees()/preserve_worktree().Review round (2f3c8ea): the gaps adversarial review found
An adversarial review found that "every edge that removes a tree holding unpublished work saves it first" did not hold yet. Each gap is now closed, with a real-git test:
coder_seamcommits beforeopen_prpushes it: a plugin roll or a failed push lost it.HEADand its branch and by no other ref. Content already published doesn't count: ifgit merge-tree --write-treeintoorigin/<branch>,origin/<base>ororigin/HEADwould change nothing, the commits are published. So rebased and squash-merged history never reads as stranded. Auto-merge now reaps before deleting the PR branch, while that tracking ref still proves it.discard_worktree), locked per tree path. Two edges hitting one tree get one save and one removal.stranded/<tree dir>/<UTC ms>, retried with a-2… suffix on a taken name, never an overwrite..worktrees/.stranded/, bytes intact, with its branch's commits saved. The discard command works on a husk.HEADis counted and saved. The branch tip becomes a second parent whenHEADhas moved off it.feat-<id>.test*; the test-rung diagnostic reaps its own trees.stranded-workwith a clear reason.workflowscope) removed the finished, committed tree.Docs: saves run with hooks off (
-c core.hooksPath=/dev/null, sinceupdate-reffiresreference-transaction); this is tested with a rejecting hook. The brief only promises pre-PR checks whenlocal_gate_cmdis set. The_promote_adopted_draftdocstring and the changelog now match what the code does.Red-check: the new/changed tests were run against a pristine
git archiveof the prior PR headdd4c684. 26 fail, including:blockingcontains "reference already exists"FileNotFoundError)stranded/branchFive pass there. Three are labelled regression guards, each proven by mutation to fail without its fix:
The other two (the
node_moduleslink commit, and the brief's push line) were already fixed in dd4c684 and red-checked against origin/main earlier.Gates:
ruff format --check .✓ ·ruff check .✓ ·pytest -q→ 1934 passed, 13 skipped (exit 0). Seam registry: 27 REAL (preserve_worktree,_tree_status,_unique_commits,_create_stranded_ref;unpublished_worknow reads through them).Merged
main(#430, #437), 2026-09-10. The only conflict was the worktree seam contract: main's #402 madepr_identityREAL, so with #405's four stranded-work seams it is now 28 REAL / 3 EXEMPT / 0 UNCOVERED, and the README says so. #437 adds no worktree removal, so save-before-remove still covers every edge. Gates after the merge:ruff format --check .✓ ·ruff check .✓ ·pytest -q→ 2072 passed, 16 skipped (exit 0).Merged
mainagain (#431, #438, #436, #433), 2026-09-11. #436 overlaps this PR by design, not just textually. A drive whose card is moved on under it (held, done, requeued, cancelled) stands aside. It blocks nothing, and a held or done card gets no PR, so the finished work stays unpushed in its tree. The combined contract:_block_or_stand_aside). The removal after a real block is this PR's save-then-remove (_discard_tree), never a bareremove_worktree. That is how all six conflicting sites are resolved, and the stranded-work block goes through the gate too.stranded/…before removing it. No stand-aside path blocks or overwrites the card.tests/test_moved_build_tree_405.py(real git, realbr):stranded/…→ the card keeps the human's state, reason and class, with no PR;Red-check: the first two fail on
main(nothing is saved) and on this PR's previous headd302afb(the held card gets a PR). The third fails with the re-read reverted: the retry is dispatched on the held card.Seam ratchet, recomputed from the scanner: worktree.py 31 = 28 REAL / 3 EXEMPT / 0 UNCOVERED; store.py 49 = 33 REAL / 16 UNCOVERED (=
MAX_UNCOVERED_STORE).Gates:
ruff format --check .✓ ·ruff check .✓ ·pytest -q→ 2173 passed, 16 skipped (exit 0).🤖 Generated with Claude Code
https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av