Skip to content

fix(loop): a card moved under its drive is not blocked at the hand-off (#398) - #436

Merged
mabry1985 merged 8 commits into
mainfrom
fix/requeue-under-drive-398
Sep 11, 2026
Merged

mabry1985 merged 8 commits into
mainfrom
fix/requeue-under-drive-398

Conversation

@mabry1985

@mabry1985 mabry1985 commented Sep 10, 2026

Copy link
Copy Markdown
Member

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.

card what put it in blocked the reason it had why the board showed ''
bd-6agq the PM agent, via board_block_feature, after mark_ready was refused Board shared-file gate prevents concurrent readiness with #3360a … Unblock and mark ready after bd-ezs7 merges (06:17:54Z) br list omits comments. Fixed by #416
bd-w535 the PM agent, a deliberate hold, re-blocked daily five blocked: comments, each with a reason same, #416
bd-p8ft the loop's drive, at the review hand-off open_review expects in_progress, got 'ready' (07:12:30Z) same, #416. The block itself is the defect fixed here

The shared-file gate never blocks a card. It raises from mark_ready and the card stays in backlog. It also never refused bd-6agq for colliding with its own dependency. Pairs linked by depends_on in either direction have been exempt since v0.37.0 (#159, test_shared_file_gate_passes_with_a_depends_on_edge). It refused bd-6agq because of bd-ezs7 (#3360a), an unrelated card sharing plugins/delegates/adapters.py with 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: false is also correct by construction: dag_blocked means ready but waiting on an open blocker, and a card flagged blocked is not in the ready lane.

The empty blocked_reason was #414, fixed by #416. The listing now carries the comment thread for blocked rows, and flag_blocked refuses a terminal block that has no reason.

The live defect: bd-p8ft

agent.log and the bead comments give this sequence. The loop's own CI-fix round (attempt 2/2) claimed bd-p8ft at 07:02:31 and started building it (in_progress, PR #3364 attached). At 07:05:38 the PM called board_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 to ready. The next ticks skipped it as hot-file, overlapping bd-ezs7 and bd-p8ft itself, i.e. its own live drive. At 07:12:30 the round finished, open_review refused expects in_progress, got 'ready', and the drive's catch-all flag_blocked(str(exc)) classified that as terminal.

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=False and 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):

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 is unknown: 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 …/ci and POST …/review refuse a card the loop is still working. That check uses worked_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 as too-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_failed exempted 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 still in_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 own open_review read and its write (reviewer's R6) is now reachable only by a direct in-process store.requeue while the drive lives, which no external edge can issue any more.

Tests: tests/test_requeue_under_drive_398.py (real br)

  • The requeue verbs and routes refuse a card under a live round, move nothing and queue nothing; after the round ends the guard lifts.
  • A first build moved to ready: the PR is recorded, the card stays unblocked, and the next claim carries the PR (so resume=True).
  • Held mid-round: no PR, the hold kept, the worktree kept. Marked done mid-round: no PR.
  • A requeued card whose round then fails to publish is not blocked.
  • A held card with a transient error: 1 dispatch (not 3), the hold intact, and the sweep leaves it alone.
  • The stand-aside comment failing never becomes a block. An unreadable card after a refusal is read from the refusal.
  • The drive's fix budgets are reset. A refusal while the card is still in_progress still blocks.
  • record_pr_url on ready/blocked/done cards through real br (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_url was '', a PR was opened for a held or done card, the card was blocked after the move, there were 3 dispatches, 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 check and ruff format --check are clean. docs/lifecycle.md has a new "A card moved under its build" section, and the rows in docs/tools.md and docs/api.md are updated.

🤖 Generated with Claude Code

https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av

mabry1985 and others added 2 commits September 10, 2026 15:11
#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
@protoreview

protoreview Bot commented Sep 10, 2026

Copy link
Copy Markdown

⚠️ QA panel exhausted — this PR has not been reviewed.
The review panel failed after 2 attempt(s) on head 54650a8081f9. No verdict was posted.
A new push will re-trigger the review.

mabry1985 and others added 6 commits September 10, 2026 16:10
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
mabry1985 merged commit 700885d into main Sep 11, 2026
4 checks passed
@mabry1985
mabry1985 deleted the fix/requeue-under-drive-398 branch September 11, 2026 00:28
@protoreview

protoreview Bot commented Sep 11, 2026

Copy link
Copy Markdown

⚠️ QA panel exhausted — this PR has not been reviewed.
The review panel failed after 2 attempt(s) on head 527bf99b46c7. No verdict was posted.
A new push will re-trigger the review.

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
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shared-file gate lands a dependent card in terminal-blocked with an empty reason, and leaves dag_blocked false

1 participant