fix(loop): a card moved under its drive is not blocked at the hand-off (#398) - #436
Merged
Merged
Conversation
#398) bd-p8ft went terminal on a live board with an open PR and no new cause. The loop's own CI-fix round was building it (in_progress, PR attached) when the PM called board_requeue_ci_fix for another round. The tool accepts that, because a card mid-round has the same in_progress + open-PR shape as a card parked by a CI bounce. The requeue moved the card to ready. When the round finished, open_review refused ("expects in_progress, got 'ready'") and the drive's catch-all blocked the card terminally on that message. The bead records it: `blocked: open_review expects in_progress, got 'ready'`, seven minutes after the requeue. The hand-off now checks why it was refused. If the card left in_progress while the coder worked (requeued, blocked by a human, merged), the move is the newer decision and it stands. The drive leaves a trail comment, frees its slot and keeps the worktree. Its commits are already pushed, and a requeued card's next round resumes that branch, carrying the PM's queued feedback. A refusal while the card is still in_progress still blocks as before. A cancelled card still takes the cancel edge, which closes the PR. This also stops the catch-all from overwriting a human's block. An operator who blocked a card mid-round used to see their reason replaced by the hand-off's refusal, because the drive's later `blocked:` comment is the one the board shows. Tests run on a real `br` board. The coder, git and GitHub are faked. The PM's requeue goes through the real tool while the round is live. Against origin/main the requeue test ends `blocked`, class terminal, reason "open_review expects in_progress, got 'ready'", which is the incident exactly. The human-block test sees its reason overwritten. A third test, where the refusal comes while the card is still in_progress, passes on both, because that path still blocks. 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
|
|
Review of the first cut found five defects. The hand-off was the only edge that respected a move, and a move it did respect could still lose work: 1. A FIRST build requeued mid-round published a PR the card never recorded. The next round ran resume=False: create_worktree wiped the tree and open_pr's --force-with-lease overwrote the pushed branch. 2. Every other build-end path still blocked over a move. A human hold placed mid-round plus a transient error re-dispatched the coder three times on the held card, re-blocked it `transient`, and the sweep then auto-unblocked it, so the hold was lost. 3. Only `cancelled` was checked before publishing. A held or done card still got a PR, and the done case left an open PR nothing tracked. 4. The trail comment was not best-effort. If it threw, the outer handler re-blocked the card with that error and overwrote a human's hold. 5. A failed read after the refusal meant "not moved": the original incident again. A drive owns its card only while it is in_progress. `_moved_under_drive` now fails toward not overwriting: an unreadable card is never assumed to be the drive's own. A refused hand-off names the state it found, and that is used. Otherwise the answer is `unknown`, and a card left in_progress with no drive is the sweep's. Before retrying, publishing, handing off or blocking (all block sites go through `_block_or_stand_aside`), the drive re-reads the card. A drive that lost its card calls `_stand_aside`: - it does not block, retry or send the card to review; - it records a PR it opened on the card (new store.record_pr_url, state untouched), so the next round resumes that branch; - it keeps the worktree, and a held, done or elsewhere-reviewed card gets no PR at all; - it resets the drive's fix budgets, so the next round starts fresh; - it leaves one best-effort comment. A cancel still takes the #211 edge. The trigger is also closed: board_requeue_feature, board_requeue_ci_fix, POST /ci and POST /review refuse a card the loop is still working. That is a live drive, a claimed build or a review gate, the same three signals #437's attach uses. The message says to wait for the round or cancel. Task drives (sister-agent and self tasks) still block through their own path: their test fake does not model the claim's state change, and they have no retries. Red-check: all 15 tests run against the previous head; 14 fail for the behavioural reasons (the requeue accepted, the PR unrecorded, a PR opened for a held/done card, a terminal block after the move, 3 dispatches on a held card, the hold overwritten by the comment failure, a block on an unreadable card, budgets not reset). The in-progress refusal pin passes on both. The reviewer's reproducers R1, R2, R3a, R3b, R4, R5, R7 and R9 now fail, which is to say the defects they demonstrated are gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
Resolves against #435/#432: keeps main's drive-local timeout_note beside the attempt counter, and takes main's move of the #378 decomposition request into _park_for_split (the terminal block site keeps only the stand-aside helper). _park_for_split blocks the card as too-wide, so it now asks first like every other drive-side block: a held card is not re-blocked as too wide and gets no split task (new test; without the guard the hold is replaced by too-wide). Main's task drives already re-read before blocking (#432). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
Resolves store.py against #437: record_pr_url sits beside attach_pr. The requeue guard's three liveness signals are the same ones #437's attach checks (loop/attach.py _worked_by_the_loop); the two stay separate because each words its refusal for its own verb. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
#398) Main's #432 task-drive failure path re-read the card before blocking it, but only exempted a delivered card (in_review / done). A re-read that FAILED still blocked "as it always did", and a card a human held while the delegate worked was blocked over, its reason and class replaced. That is the opposite of the coding drive's rule. _task_dispatch_failed now uses the same ownership read as the coding drive (_moved_under_drive): it blocks only a card still in_progress. Delivered stands (#432's case, unchanged). Any other move stands. An unreadable card is never assumed to be the drive's own: that is logged, and a card left in_progress with no drive is the sweep's to reconcile. Tests on real br (tests/test_task_drive_ownership_398.py): the re-read fault-injected gives no block; a human hold placed mid-dispatch keeps its reason and class; a card still in flight is blocked as before. Red-check against origin/main (81a9787): the first two fail (the card `blocked` after the failed read, the hold's reason replaced by the dispatch error), and the third passes on both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
Brings in #431 (the stalled-br fix); auto-merged cleanly. 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
|
|
mabry1985
added a commit
that referenced
this pull request
Sep 11, 2026
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
mabry1985
added a commit
that referenced
this pull request
Sep 11, 2026
…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
mabry1985
added a commit
that referenced
this pull request
Sep 11, 2026
… 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
This was referenced Sep 11, 2026
mabry1985
added a commit
that referenced
this pull request
Sep 11, 2026
…405, #400) (#434) * 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 * 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>
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 #398. #416 (already on main) fixed half of it, this PR fixes the remaining live defect, and the issue's stated root cause turned out to be wrong. Evidence for each symptom is below.
What the three terminal cards actually were
I read the board's own record for each card (
~/.protoagent/protoEngineer-ba4c/.beads/issues.jsonl, read-only) against the timeline in the issue.blocked''bd-6agqboard_block_feature, aftermark_readywas refusedBoard shared-file gate prevents concurrent readiness with #3360a … Unblock and mark ready after bd-ezs7 merges(06:17:54Z)br listomits comments. Fixed by #416bd-w535blocked:comments, each with a reasonbd-p8ftopen_review expects in_progress, got 'ready'(07:12:30Z)The shared-file gate never blocks a card. It raises from
mark_readyand the card stays in backlog. It also never refusedbd-6agqfor colliding with its own dependency. Pairs linked bydepends_onin either direction have been exempt since v0.37.0 (#159,test_shared_file_gate_passes_with_a_depends_on_edge). It refusedbd-6agqbecause ofbd-ezs7(#3360a), an unrelated card sharingplugins/delegates/adapters.pywith no edge between them. The operator had asked for no edge there. That is the case the issue itself calls "a real conflict a human should resolve".dag_blocked: falseis also correct by construction:dag_blockedmeans ready but waiting on an open blocker, and a card flaggedblockedis not in the ready lane.The empty
blocked_reasonwas #414, fixed by #416. The listing now carries the comment thread for blocked rows, andflag_blockedrefuses a terminal block that has no reason.The live defect:
bd-p8ftagent.log and the bead comments give this sequence. The loop's own CI-fix round (attempt 2/2) claimed
bd-p8ftat 07:02:31 and started building it (in_progress, PR #3364 attached). At 07:05:38 the PM calledboard_requeue_ci_fix. The tool accepted, because a card mid-round has the same in_progress + open PR shape as a card parked by a CI bounce. The requeue moved the card toready. The next ticks skipped it ashot-file, overlappingbd-ezs7andbd-p8ftitself, i.e. its own live drive. At 07:12:30 the round finished,open_reviewrefusedexpects in_progress, got 'ready', and the drive's catch-allflag_blocked(str(exc))classified that asterminal.That is the "hold that became permanent on its own" described in the issue.
The fix (reworked after adversarial review, commit 527bf99)
The first cut respected a move only at the review hand-off. Review found five defects: a first build requeued mid-round lost its work, because the PR went unrecorded and the next round ran
resume=Falseand force-pushed over it; every other build-end path still blocked over a move; a held or done card still got a PR; the trail comment could throw into a re-block; and an unreadable card counted as still owned. The rework:A drive owns its card only while it is
in_progress, and asks before every edge that would change it. It re-reads the card before starting another attempt, before publishing, after publishing, at the hand-off, and at every block site. All eight block sites now go through_block_or_stand_aside. A drive that has lost its card stands aside (_stand_aside):store.record_pr_url), so the next round runsresume=Trueon that branch;It fails toward not overwriting. An unreadable card is never assumed to be the drive's own. A refused hand-off names the state it found (
expects in_progress, got 'ready'), and that is used. With nothing to go on the answer isunknown: the drive stands aside, and the sweep reconciles a card left in_progress with no drive.The trigger is closed.
board_requeue_feature,board_requeue_ci_fix,POST …/ciandPOST …/reviewrefuse a card the loop is still working. That check usesworked_by_the_loop: a live drive, a claimed build or a running review gate, the same three signals #437's attach uses. The message says to wait for the round or cancel the card.Merged with main (latest: 113a190, which includes #431). #435 moved the #378 decomposition ask into
_park_for_split, which blocks the card astoo-wide. That park now asks first like every other drive-side block, so a held card isn't re-blocked or given a split task (new test, red without the guard). Task drives follow the same rule (6ff0e71). #432's_task_dispatch_failedexempted only a delivered card. A re-read that failed still blocked, and a human hold placed mid-dispatch was blocked over. It now blocks only a card stillin_progress. An unreadable card is logged and left for the sweep, and any other move stands (tests/test_task_drive_ownership_398.py; against main 81a9787 the unreadable and hold tests fail, and the in-flight pin passes). A store-level race between the drive's ownopen_reviewread and its write (reviewer's R6) is now reachable only by a direct in-processstore.requeuewhile the drive lives, which no external edge can issue any more.Tests:
tests/test_requeue_under_drive_398.py(realbr)resume=True).record_pr_urlon ready/blocked/done cards through realbr(classified REAL in the seam registry)._moved_under_drive's fail-toward-not-overwriting table.Red-check: the new file against the previous head (0a218ff): 14 failed, 1 passed. Each failure is behavioural: the requeue was accepted (
state: ready),pr_urlwas'', a PR was opened for a held or done card, the card wasblockedafter the move, there were3dispatches, the hold was overwritten by the comment failure, and the budgets were not reset. The pass is the in-progress refusal pin, which should pass on both. The reviewer's reproducers R1, R2, R3a, R3b, R4, R5, R7 and R9 now fail, meaning the bad behaviour they asserted no longer happens. R2b (cancel contrast), R6 and R8 (pure git) still pass.Full suite passes on br 0.2.16 and 0.1.23: 1918 passed, 13 skipped.
ruff checkandruff format --checkare clean.docs/lifecycle.mdhas a new "A card moved under its build" section, and the rows indocs/tools.mdanddocs/api.mdare updated.🤖 Generated with Claude Code
https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av