feat(board): salvage a stranded card's worktree without a coder (#427) - #439
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
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
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
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
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
…ts 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
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
…salvage-427 Resolutions, taking #437's text where both branches did the same job: - tests/test_docs_reference.py: #437's async-aware `_tools()`, verbatim. - docs/tools.md: #437's `board_dispatch` row (with its⚠️ ), in its place after `board_deliver`; the salvage PR's own row for it is dropped. - loop/__init__.py `__all__`: `attach_external_pr` and `request_salvage` both exported. - api.py / docs/api.md: the attach-pr and salvage routes side by side. - Worktree seam contract: 30 REAL (main's 24 with `pr_identity`, #405's four, and #427's `commits_ahead` and `own_worktree`), in the test and the README. - tests/test_board_tools.py: #437 extended the never-raise sweep to async tools, so `board_salvage_feature` joins `_NO_STORE_ARGS` (its store reads run inside the loop). #437 added claim-guarded re-reads before the orphan reconcile and the blocked sweep move a card, because an attach can land during their gh round-trip. A salvage can start in the same window. Both re-reads now treat a reserved card as owned, and the orphan reconcile stops after its gh read if the card was reserved meanwhile, so a crash salvage never runs on a tree an operator salvage is publishing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
9f3f0f1 to
b7c8fe6
Compare
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
… feat/salvage-427 No textual conflicts. The main changes meet the salvage in these places: - #436's requeue refusal reads `_inflight_files`, so a card under a salvage cannot be requeued out from under it, and #437's attach refuses it the same way. - A build that stands aside from a held card (#398) leaves exactly what the salvage publishes: the card blocked, and the finished work unpushed in its tree. A new test in tests/test_moved_build_tree_405.py runs that end to end on real git and a real `br` board. A human holds the card mid-build, and the build stands aside with no PR. The salvage then publishes the tree as it stands, on the card's own branch, and puts the card in review. Nothing is set aside. - docs/lifecycle.md says so in the salvage section. 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
The one conflict was module constants in loop/reconcile.py: #433's refresher poll interval and the salvage's gate-output constants. Both are kept. #431 bounds a stalled `br` call by raising. A salvage whose first board read raised used to escape as an exception, which the route turned into a 500. It now returns an `error` record and still releases the card's reservation. The failures after the push already land in the record. The new test fails without this. The seam ratchet, recomputed from the scanner: worktree.py has 33 seams (30 REAL, 3 EXEMPT, 0 UNCOVERED) and store.py has 49 (33 REAL, 16 UNCOVERED, which is exactly MAX_UNCOVERED_STORE). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
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
There was a problem hiding this comment.
QA panel review — PASS
code-review-structural · head df542732f6ca · formal
⚠️ PR advanced 2 commit(s) during this round (df542732f6ca→62f12192563a); 0 finding(s) in the delta were demoted to possibly addressed.
All four review angles (correctness, removed-behavior, conventions, and the partial structural pass) returned zero findings, and the verifier confirmed the empty array stands as-is. Overall risk is low; there is nothing to fix first. The panel did not disagree on any point. The one coverage gap worth noting: the structural pass was only partial, so dependency-graph and cross-module concerns may not be fully exercised — if the diff touches shared interfaces or public APIs, a targeted follow-up on those seams is advisable.
No findings — the review came back clean.
findings JSON (machine-readable)
[]
Fixes #427.
Stacked on #434 (base
fix/publish-reap-400-405). The salvage relies on three things #434 adds:stage_allfix, so a salvage commit can't carry the board'snode_moduleslink;promote_worktree's save-before-clear;feature_worktreesandhold_treessit on.Merge #434 first. ⚠ Retarget this PR to
mainbefore #434's branch is deleted: deleting a stacked PR's base branch can close the child instead of retargeting it.What was missing
When a card's worktree already held finished work, the only way to publish it was to dispatch a coder, because every publication step (gate, commit, push, PR) sat behind a dispatch. On 2026-09-07 bd-ezs7's recovery stalled exactly there. The finished 170 lines were stranded in
feat-bd-ezs7.g1, and the coder delegate was down for an unrelated reason: protoCLI under Node v26 failed every model call.What this adds
board_salvage_feature(feature_id, force=False, tree="")andPOST /features/{fid}/salvage({force?, tree?}). They run the board's own publishing steps with no coder in the path. The crash salvage (_salvage_verified_candidate) now shares its two halves:_gate_tree(fixups + gate) and_open_tree_pr.It is an operator override: it skips the drive's goal, requirement-ledger and source-issue checks. CI still applies, and so does the review gate when it is on.
Refuses, changing nothing:
in_progresswith no drive, orblocked, qualifies; areadycard could be claimed mid-publish, so the operator blocks it first;treedoesn't pick one.Every tree is proven first.
worktree.own_worktreechecks that the tree is its own top level and that the repo registered it. A leftover with no.git, a husk, or a separate clone under the card's tree name is left untouched and named in the refusal.The gate runs where the tree stands.
format_cmdfixups run first, as in a drive, so they may rewrite files even when the gate then refuses. A red gate publishes nothing and returnsgate-redwith the output's tail.force=trueon a red gate:gh pr ready --undo) and the output is posted on it as a comment;draftin the record is read back from GitHub. A refused conversion is reported as "NOT a draft";gh pr createcommand.The output is fenced with more backticks than it contains.
Publish, then review. A candidate tree is promoted to the card's canonical branch, then committed, pushed and opened (or pushed onto the card's PR). Under the claim lock, the card goes to
in_review:in_progressgoes throughopen_review;in_review(CI-fix rounds spent, or a review-gate block) goes straight back there, on its own PR.With
review_gateon, the salvage runs the gate inline, as a drive does. The reconcile only picks upreview-pendingwhenmerge_pollis on.A cancel mid-publish stops it before the PR, or closes the PR it has just opened (
cancelled, 409). The salvage holds its trees (hold_trees, reentrant for its own promotion), so the cancel's reap waits, then saves and removes the tree as usual.The reservation. The card is reserved in
_inflight_files, the same claim a drive holds, and only the salvage that made it releases it (identity check). The claim scan (reserved, not counted as a livelock), boot recovery, the orphan sweep, the blocked sweep and the worktree sweep all skip a reserved card. feat(board): attach an externally opened PR to the card it belongs to (#402) #437's claim-guarded re-reads treat a card reserved during their gh round-trip as owned.HTTP status by outcome:
published200 ·gate-red/refused/cancelled409 ·not-found404 ·loop-not-running503 ·error502. The record carriesbranch. The route is on the bearer-gated operator surface. It and the tool reach the running loop throughrequest_salvage, asboard_dispatchdoes.docs/lifecycle.mdalso states the hung-drive limitation. The salvage refuses while a drive owns the card, and no verb stops a drive on its own. Cancelling the card or restarting the host saves the tree to astranded/…branch and removes it, so that branch then has to be published by hand.Review round
An adversarial review found seven defects. All are fixed, each with a test.
feat-…dir with no.gitlet git answer for the main checkout: the salvage counted the operator's own commit as the card's work and committed onto the operator's branchown_worktreeguard on every tree;commits_aheadrefuses non-treesforceon a card with an open PR adopted the READY PR and reported "draft"in_reviewfailedopen_review's precondition after its block was clearedin_reviewon its PRreview_gateon +merge_polloff: nothing ever reviewed a salvaged PR#437 overlaps, resolved in the merge: #437's⚠️ , after
_tools()docs guard verbatim; #437'sboard_dispatchrow (board_deliver), so this PR's duplicate is dropped;__all__exports bothattach_external_prandrequest_salvage; the worktree seam contract is 30 REAL / 3 EXEMPT / 0 UNCOVERED;board_salvage_featurejoins the async-aware never-raise sweep.Tests
tests/test_salvage_427.pyhas 27 tests:brboard, so every card transition is the store's own;_ghfaked. It modelspr create"already exists", repos without drafts,pr ready --undo,pr close,pr viewstate/isDraft, and the comments API.Red-check (the final test file run against pristine
git archiveexports):d302afb(fix(loop): save stranded work to a branch before any edge removes it (#405, #400) #434's head, no salvage): 26/26 fail.6ea90ee(this PR before the review round): 16 fail, one per new or changed behaviour. 10 pass: the unchanged behaviour, plus the blocked-sweep skip, which already existed.Mutation-checked on the fixed code. Reverting each guard fails its test:
hold_trees--undoown_worktreefilter ("committed into another repo")Gates:
ruff format --check .✓ ·ruff check .✓ ·pytest -q→ 2201 passed, 16 skipped (exit 0), onmainthrough #433.Later merges (
main#431, #438, #436 and #433, via #434):_inflight_files, so neither can move a card that is being salvaged.tests/test_moved_build_tree_405.py, real git and realbr) holds a card mid-build, and then the salvage publishes the finished tree as it stands.brread raise. The salvage now turns that into anerrorrecord, and still releases its reservation, where it used to raise into the route as a 500. The test fails without it.Residual risk
forceonto an existing PR leaves it ready. The record, the card comment and the PR comment all say so, and CI plus the review gate still stand between it and a merge.brtests run on br 0.2.16 locally. CI's full-suite leg uses br 0.1.23.🤖 Generated with Claude Code
https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av