Skip to content

fix(loop): save stranded work to a branch before any edge removes it (#405, #400) - #434

Merged
mabry1985 merged 9 commits into
mainfrom
fix/publish-reap-400-405
Sep 11, 2026
Merged

mabry1985 merged 9 commits into
mainfrom
fix/publish-reap-400-405

Conversation

@mabry1985

@mabry1985 mabry1985 commented Sep 10, 2026

Copy link
Copy Markdown
Member

Fixes #405. Fixes #400. #400's own root cause needed correcting, and the correction is below.

What was wrong

1. Stranded work was destroyed (#405). When a coder dies before its candidate is promoted, the only copy of its work is left in that worktree. Every automatic edge that could reach that tree deleted it:

Edge What it did
re-dispatch → create_worktree ran git worktree remove --force + git branch -D on the exact path the next build reuses
promote_worktree force-cleared a stale canonical tree before moving the winner in
reap_feature_worktree (merge, closed PR, cancel, done, health sweep) force-removed the canonical and every candidate
stop() force-removed the in-flight tree, including a finished implementation waiting on its gate

2. The brief contradicted itself at the publish step (#400). It said both "You cannot run shell commands (edit-only)" and "Push the branch if you can".

3. The board committed its own node_modules symlink into PRs. create_worktree links the main checkout's node_modules into every tree. Most repos ignore it as node_modules/, with a trailing slash, and that pattern matches only a real directory, not a symlink. So stage_all's add -A staged the link and it rode into the PR.

Evidence (bd-ezs7, protoEngineer, 2026-09-06). From the log and board DB, read-only:

  • 23:25:50: codex reports turn complete (stopReason=end_turn); the coder-monitor: comment records elapsed_s: 480.7.
  • After that: no .g2, no verified, no exhausted, and no exception. The drive task stayed alive: at 00:15 the claim scan skips bd-ezs7 as hot-file overlapping itself.
  • The installed plugin was d105175. Its acceptance-test timeout killed only /bin/sh and then did an unbounded await proc.wait(). That is the hang bug: gate/test subprocesses inherit the server's stdin and are killed shell-only — a hung pnpm strands drives for hours and leaks orphans #423 fixed.
  • 07:15 UTC: the PM requeued the card. The 170 lines survived only because the hung drive still held the file claim. A re-dispatch would have force-removed them.

#400's root cause, corrected:

The fix

Save to a branch, then proceed (worktree.preserve_worktree).

  • Target: before any edge removes a tree holding work that exists nowhere else, its work goes onto a new branch, stranded/<tree id>/<UTC stamp>.
  • What the commit holds: the tree's HEAD plus its working state. It is staged through stage_all into a private GIT_INDEX_FILE, so the tree, its index and its branch are untouched. commit-tree is used so no hooks run; there is no signing prompt, and the identity is pinned.
  • No overwrite: update-ref <ref> <commit> "" refuses to overwrite an existing branch.
  • Verified: the branch's tree is read back and compared before the tree may go.

What counts as work (worktree.unpublished_work):

  • every uncommitted change: modified, staged, deleted, untracked;
  • on a candidate branch, also commits that no other ref holds. For those, the branch points at the tree's HEAD.
  • Not counted: .proto/.cursor scratch and the board's node_modules links.
Edge Now
fresh build (_drive) first discards its own previous attempt explicitly, as retries always did. Then saves every stranded tree the card owns, removes them, and posts a card comment: each branch, its diffstat, git diff origin/<base>...stranded/…, and how to cherry-pick it. Then it builds on.
shutdown saves the tree, comments on the card, reaps it. The next boot rebuilds as before, so no toil on a plugin roll.
by-id reap saves the tree, logs the branch, reaps it
create_worktree / promote_worktree save the tree, then clear it. This backstops callers other than the drive.

Block only when saving fails. If the commit or branch step fails, the tree is kept exactly as it is: work that could not be saved is never destroyed. What happens next depends on the edge:

  • a fresh build blocks the card as stranded-work, a class that does not self-heal. The reason names the path, what the tree holds, why saving failed, and how to recover or discard it.
  • the reap keeps the tree and logs it once.
  • shutdown keeps the tree, and the next dispatch retries.
  • create_worktree / promote_worktree raise StrandedWorkError.

Other changes:

Tests

tests/test_stranded_work_405.py runs real git against a bare origin + clone. tests/test_brief_publication_400.py checks the brief.

  • A re-dispatch saves tracked / staged / untracked work (parametrized) as one commit on the tree's HEAD, then rebuilds fresh. A candidate commit is kept as the branch tip.
  • The board's droppings are neither work nor saved, even alongside real work.
  • An existing stranded/… branch is never overwritten. If saving fails, the tree is kept and the call refuses; once the branch is freed it saves and proceeds (the recovery path).
  • Promotion saves a stranded canonical tree, then promotes.
  • The reap saves the dirty candidate and still reaps everything.
  • commit_worktree no longer carries the node_modules link under a node_modules/ ignore.
  • The drive saves stranded work, comments where it went (branch, diffstat, diff command), and builds on. It blocks as stranded-work only when saving fails, with no coder dispatched.
  • Shutdown saves the interrupted tree, comments, and reaps it; a clean tree is reaped as before.
  • The brief no longer hands the coder a publishing step, and says the loop publishes.

Red-check: I ran the final two test files against a pristine git archive origin/main. 14 of 15 fail, each on the defect itself: "the untracked change was destroyed, not saved", "the coder's commit was dropped with its branch", "unsaved work was destroyed", "the commit carried the board's link: ['feature.py', 'node_modules']", "the drive built over stranded work without saving it", "shutdown reaped an in-flight tree holding work without saving it", "the brief still asks the coder to push".

The 15th, test_a_drive_still_discards_its_own_failed_attempt, is a labelled regression guard. It passes on main by construction. With the drive's own-attempt discard removed, it fails: the drive saves and comments on its own leftovers.

unpublished_work and preserve_worktree are new local-git seams. Both are registered REAL in tests/test_external_seams.py, taking the contract from 23 to 25 REAL; the README is updated to match.

Gates: ruff format --check . ✓ · ruff check . ✓ · pytest -q → 1918 passed, 13 skipped (exit 0).

Notes

Review round (2f3c8ea): the gaps adversarial review found

An adversarial review found that "every edge that removes a tree holding unpublished work saves it first" did not hold yet. Each gap is now closed, with a real-git test:

# Gap Fix
1 Committed-but-unpushed work on the card's own branch was destroyed. This covers the verified candidate coder_seam commits before open_pr pushes it: a plugin roll or a failed push lost it. Every branch counts: commits held by HEAD and its branch and by no other ref. Content already published doesn't count: if git merge-tree --write-tree into origin/<branch>, origin/<base> or origin/HEAD would change nothing, the commits are published. So rebased and squash-merged history never reads as stranded. Auto-merge now reaps before deleting the PR branch, while that tracking ref still proves it.
2 Cancel removed the tree unsaved, and raced the route's save-first reap. Every removal goes through one save-then-remove (discard_worktree), locked per tree path. Two edges hitting one tree get one save and one removal.
3 Our own naming caused failed saves. The ref dropped the slug and was stamped to the second, so saves collided about 5 times in 10. stranded/<tree dir>/<UTC ms>, retried with a -2… suffix on a taken name, never an overwrite.
4 A saved tree that git wouldn't remove was reported removed, re-saved on every sweep, then blocked as "could not be saved". Husks were kept forever. After a verified save the tree is deleted anyway (never a locked one), and reports say truthfully whether it went. A husk is moved aside to .worktrees/.stranded/, bytes intact, with its branch's commits saved. The discard command works on a husk.
5 Detached-HEAD commits were lost. HEAD is counted and saved. The branch tip becomes a second parent when HEAD has moved off it.
6 A fresh build swept the operator's live test-rung tree. Card sweeps skip feat-<id>.test*; the test-rung diagnostic reaps its own trees.
7 A nested git repo was half-saved (only a gitlink) and its files deleted. That tree is refused and kept whole. At a fresh build the card blocks as stranded-work with a clear reason.
8 A terminal block (for example, a PAT push rejected for workflow scope) removed the finished, committed tree. Drive-side block and cancel removals save first.

Docs: saves run with hooks off (-c core.hooksPath=/dev/null, since update-ref fires reference-transaction); this is tested with a rejecting hook. The brief only promises pre-PR checks when local_gate_cmd is set. The _promote_adopted_draft docstring and the changelog now match what the code does.

Red-check: the new/changed tests were run against a pristine git archive of the prior PR head dd4c684. 26 fail, including:

  • "the card branch's unpushed commit is gone"
  • "the detached HEAD's commit was lost with its tree"
  • blocking contains "reference already exists"
  • "the saved tree was never removed"
  • "the husk still blocks its path"
  • the nested repo's files deleted (FileNotFoundError)
  • the test-rung tree swept
  • the rejecting hook failing the save
  • "the blocked card's finished commit was lost with its tree"
  • the operator cancel leaving no stranded/ branch
  • "the brief promised a check no gate will run"

Five pass there. Three are labelled regression guards, each proven by mutation to fail without its fix:

  • published history is not stranded work: fails with the content check disabled;
  • the drive discards its own failed attempt: fails without the own-attempt discard;
  • an auto-merged card is reaped before its branch goes: fails without reap-before-delete.

The other two (the node_modules link commit, and the brief's push line) were already fixed in dd4c684 and red-checked against origin/main earlier.

Gates: ruff format --check . ✓ · ruff check . ✓ · pytest -q → 1934 passed, 13 skipped (exit 0). Seam registry: 27 REAL (preserve_worktree, _tree_status, _unique_commits, _create_stranded_ref; unpublished_work now reads through them).

Merged main (#430, #437), 2026-09-10. The only conflict was the worktree seam contract: main's #402 made pr_identity REAL, so with #405's four stranded-work seams it is now 28 REAL / 3 EXEMPT / 0 UNCOVERED, and the README says so. #437 adds no worktree removal, so save-before-remove still covers every edge. Gates after the merge: ruff format --check . ✓ · ruff check . ✓ · pytest -q → 2072 passed, 16 skipped (exit 0).

Merged main again (#431, #438, #436, #433), 2026-09-11. #436 overlaps this PR by design, not just textually. A drive whose card is moved on under it (held, done, requeued, cancelled) stands aside. It blocks nothing, and a held or done card gets no PR, so the finished work stays unpushed in its tree. The combined contract:

  • Every drive-side block takes fix(loop): a card moved under its drive is not blocked at the hand-off (#398) #436's gate (_block_or_stand_aside). The removal after a real block is this PR's save-then-remove (_discard_tree), never a bare remove_worktree. That is how all six conflicting sites are resolved, and the stranded-work block goes through the gate too.
  • A build that stood aside has let go of its tree, so shutdown leaves it alone. The by-id reap and the card's next fresh build save its work to stranded/… before removing it. No stand-aside path blocks or overwrites the card.
  • New fix: a retry that rebuilds removes the drive's own failed attempt unsaved (by design). But fix(loop): a card moved under its drive is not blocked at the hand-off (#398) #436's ownership check ran before the retry's prompt was built, which includes a KG fetch. A hold landing in that window lost the tree. The drive now re-reads the card right before that removal.

tests/test_moved_build_tree_405.py (real git, real br):

  • a card held mid-build → the build stands aside with no PR → shutdown leaves the tree → the reap saves it to stranded/… → the card keeps the human's state, reason and class, with no PR;
  • the hold is lifted → the next round's clean-up saves the first round's work before building fresh;
  • a hold landing while a retry is prepared keeps the failed attempt's tree.

Red-check: the first two fail on main (nothing is saved) and on this PR's previous head d302afb (the held card gets a PR). The third fails with the re-read reverted: the retry is dispatched on the held card.

Seam ratchet, recomputed from the scanner: worktree.py 31 = 28 REAL / 3 EXEMPT / 0 UNCOVERED; store.py 49 = 33 REAL / 16 UNCOVERED (= MAX_UNCOVERED_STORE).

Gates: ruff format --check . ✓ · ruff check . ✓ · pytest -q → 2173 passed, 16 skipped (exit 0).

🤖 Generated with Claude Code

https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av

mabry1985 and others added 3 commits September 10, 2026 15:07
…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
@mabry1985 mabry1985 changed the title fix(loop): never destroy a worktree holding work that exists nowhere else (#405) fix(loop): save stranded work to a branch before any edge removes it (#405, #400) Sep 10, 2026
mabry1985 and others added 2 commits September 10, 2026 16:30
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
@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 44d435c205f3. No verdict was posted.
A new push will re-trigger the review.

mabry1985 and others added 4 commits September 10, 2026 17:10
Brings in #430 and #437. The one conflict was the worktree seam contract: main's #402
made `pr_identity` REAL (24) while this branch added #405's four stranded-work seams
(27), so the contract is now 28 REAL / 3 EXEMPT / 0 UNCOVERED, and the README says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
Brings in #431, #438 and #436. #436 overlaps this branch semantically: it re-reads the
card before every drive-side block, retry, publish and hand-off, and a drive whose card
was moved on under it stands aside. It keeps the worktree, and a held or done card gets
no PR, so the finished work stays unpushed in that tree. This branch saves any tree
holding work before removing it. The combined rule:

- Every block site takes #436's gate (`_block_or_stand_aside`), and the removal after a
  real block is this branch's save-then-remove (`_discard_tree`), never a bare
  `remove_worktree`. The six conflicting sites are resolved that way.
- The stranded-work block (a fresh build that could not save a leftover tree) now goes
  through `_block_or_stand_aside` too, so it never overwrites a card someone moved.
- A build that stood aside has let go of its tree (`_stand_aside` drops it from
  `_inflight`), so shutdown leaves it alone. The by-id reap and the card's next fresh
  build save its work to a `stranded/…` branch before removing it.

tests/test_moved_build_tree_405.py runs that case end to end on real git and a real
`br` board: a human holds the card while its coder finishes, and the build stands aside
with no PR. Shutdown then leaves the tree, and the reap saves it to `stranded/…`. The
card keeps the human's state, reason and class, with no PR. A second test lifts the
hold, and the next round's clean-up saves the first round's work before building fresh.
Both tests fail on main (nothing is saved) and on this branch's previous head (the held
card gets a PR).

docs/lifecycle.md keeps both new sections, cross-referenced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
…pt away (#398, #405)

A retry that rebuilds from scratch removes the drive's own failed attempt without saving
it. That is by design (#405): the drive built that tree and judged it. But #436's
ownership check sits at the top of the attempt, and the prompt is built after it,
including a knowledge-graph fetch that can take seconds. A human hold that landed in
that window turned the tree into a moved build's, and it was removed unsaved anyway. A
coder that fell over half-way through leaves its partial work there.

The drive now re-reads the card right before that removal. A card moved on keeps its
tree through the same stand-aside as every other moved build, and whatever ends that
tree later saves it first. The test fails without the re-read: the retry is dispatched
on the held card and attempt 1's tree is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
No conflicts. #433 moves the working-state snapshot onto its own refresher task, which
`stop()` now cancels alongside the tick. Shutdown's save-then-remove of in-flight trees
still runs after both. `clear_blocked` now drops any blocked-class label, and that
includes `stranded-work`, so unblocking a card after recovering its tree works as the
docs say.

The seam ratchet, recomputed from the scanner: worktree.py has 31 seams (28 REAL,
3 EXEMPT, 0 UNCOVERED) and store.py has 49 (33 REAL, 16 UNCOVERED). The constants are
exact: MAX_UNCOVERED_WORKTREE 0, MAX_EXEMPT_WORKTREE 3, MAX_UNCOVERED_STORE 16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
@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 d302afbe9176. No verdict was posted.
A new push will re-trigger the review.

@mabry1985
mabry1985 merged commit ca9498f into main Sep 11, 2026
4 of 5 checks passed
@mabry1985
mabry1985 deleted the fix/publish-reap-400-405 branch September 11, 2026 01:21
mabry1985 added a commit that referenced this pull request Sep 11, 2026
main since the last merge carries only #434's squash (ca9498f), whose tree is
identical to #434's branch tip, which this branch already contains; conflicts
resolved to this branch's side, and the resulting tree equals df54273's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
mabry1985 added a commit that referenced this pull request Sep 11, 2026
#439)

* fix(loop): never destroy a worktree holding work that exists nowhere else (#405)

A coder that dies before its candidate is promoted leaves the only copy of its
work in that tree, and every automatic edge that touched it was destructive:
a re-dispatch ran create_worktree, whose "clean a prior run's leftovers" step
is `git worktree remove --force` + `git branch -D`; promote_worktree cleared a
stale canonical the same way; the by-id reap (merge, closed PR, cancel, done,
health sweep) and the shutdown reap removed whatever was there. bd-ezs7's
finished 170 lines (#400) survived only because its drive had hung in #423's
unbounded proc.wait() and still held the card's file claim, so nothing
re-dispatched it.

Now "work" is every uncommitted change git reports (modified, staged, deleted,
untracked), minus the board's own droppings (.proto/.cursor scratch and the
node_modules links it adds), plus, on a candidate branch, commits no other
ref holds. A tree holding work is never destroyed automatically:

- a fresh build discards the drive's OWN previous attempt explicitly, then
  checks every tree the card has; if any holds work the card blocks under a
  new non-healing `stranded-work` class whose reason names each path, its
  branch, what is in it, and how to recover or discard it. No coder runs.
- create_worktree / promote_worktree raise StrandedWorkError instead of
  force-cleaning such a tree.
- reap_feature_worktree keeps it and logs it once; clean trees still reap.
- shutdown keeps an interrupted tree holding work; its card blocks on the
  next dispatch.

Max-Mode's losers are the drive's own judged candidates, so they are now
discarded by path (like the solve ladder's), not through the guarded by-id
reap. unpublished_work is a new local-git seam, REAL-covered against real git.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av

* docs(changelog): stranded-work fragment for #434

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av

* fix(loop): save stranded work to a branch and build on; block only if saving fails

Blocking every card with stranded work turned each plugin roll into manual toil:
a restart mid-build strands one tree per in-flight drive, and the board cannot
tell a half-written tree from a finished one waiting on its gate. So instead of
refusing, every edge that would remove such a tree now SAVES it first:
preserve_worktree commits the tree's HEAD plus its working state (through
stage_all, into a private GIT_INDEX_FILE, so the tree, its index and its branch
are untouched; commit-tree so no hooks or signing prompt run; identity pinned)
onto a NEW branch stranded/<tree id>/<UTC stamp>. update-ref with an empty old
value never overwrites an existing branch, and the ref's tree is read back and
compared before the tree may go.

- a fresh build saves every stranded tree the card owns, removes them, leaves a
  card comment (branch, diffstat, `git diff origin/<base>...`, cherry-pick) and
  builds on; only a tree whose save FAILED blocks the card as stranded-work,
  kept intact.
- shutdown saves the interrupted tree, comments, and reaps it; the next boot
  rebuilds as before.
- the by-id reap and create_worktree/promote_worktree save then clear; the
  latter two refuse with StrandedWorkError only when saving fails.

stage_all now also leaves out the node_modules symlink create_worktree links
in: a `node_modules/` ignore pattern matches only a real directory, so the
board's own link was committed into PRs. The brief no longer tells an edit-only
coder to "push the branch if you can" (#400): it now says the loop commits,
runs the pre-PR checks, pushes and opens the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av

* fix(loop): close the gaps review found in save-before-remove (#405)

Adversarial review of #434 showed "every edge that would remove a tree holding
unpublished work saves it first" did not hold yet. Each gap is now closed, with
a real-git test:

1. Committed-but-unpushed work on the CARD's branch (the verified candidate
   coder_seam commits before open_pr pushes it) was not counted, so a restart
   or a failed push lost it. Every branch's commits count now - HEAD and the
   branch, no other ref holding them - unless their CONTENT is already
   published: `git merge-tree --write-tree` into origin/<branch>, origin/<base>
   or origin/HEAD changes nothing. That keeps rebased and squash-merged history
   from reading as stranded, and auto-merge now reaps the tree before deleting
   the PR branch, while that tracking ref still proves it.
2. Cancel removed the tree unsaved, racing the cancel route's save-first reap.
   Every removal now goes through one locked save-then-remove
   (discard_worktree), so two edges on one tree give one save and one removal.
3. Our own naming made saves fail: the ref dropped the title slug and stamped
   to the second. It is now stranded/<tree dir>/<UTC ms>, retried with a
   suffix on a taken name, never an overwrite.
4. A saved tree git would not remove was reported removed, stayed, and was
   re-saved (or blocked) on every edge; a husk was kept forever. After a
   verified save the tree is deleted anyway (never a locked one), reports say
   truthfully whether it went, a husk is moved aside with its bytes and its
   branch's commits saved, and the block's discard command works on a husk.
5. Commits on a detached HEAD were lost; HEAD is counted and saved, with the
   branch tip as a second parent when HEAD moved off it.
6. A fresh build swept the operator's live test-rung tree; card sweeps now
   leave feat-<id>.test* trees to the diagnostic that owns them.
7. A nested git repository was half saved (a gitlink) and its files deleted;
   such a tree is now refused and kept whole.
8. A drive's terminal block (a push GitHub refused, say) removed the finished,
   committed tree unsaved; block and cancel removals now save first.

Saves run with hooks off (core.hooksPath=/dev/null) - update-ref fires
reference-transaction - and the brief only promises pre-PR checks when a gate
is configured. The seam registry gains the three new helpers (27 REAL).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av

* feat(board): salvage a stranded card's worktree without a coder (#427)

bd-ezs7's coder finished 170 lines and died before publishing them, and
recovering that work needed a coder: every publication step was only reachable
by dispatching one, and that day the coder delegate was down for an unrelated
reason. `board_salvage_feature` / `POST /features/{fid}/salvage` runs the
board's own tail instead: commit what the tree holds, the pre-PR gate, push,
open the PR, move the card to in_review.

It refuses, touching nothing, while a live drive or another salvage owns the
card, for a card that is not stranded (in_progress with no drive, or blocked -
a ready card could be claimed mid-publish), and when no worktree - or several,
unless `tree` names one - has changes vs base. The gate runs where the tree
stands: a red gate publishes nothing and hands back the output's tail unless
force=true, which opens a DRAFT PR carrying that output. A candidate is
promoted to the card's branch first; with review_gate on the card enters
review-pending. The card is reserved in _inflight_files for the duration, so no
sweep requeues it, reaps its trees or auto-unblocks it mid-publish.

The crash salvage of a verified candidate (#91) and this edge now share the
publish tail (_gate_tree + _open_tree_pr); open_pr gains draft=. The docs guard
now also scans async tools - board_dispatch (#390) had shipped undocumented
because it only looked at plain defs; it is documented now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av

* docs(changelog): salvage fragment for #439

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av

* fix(board): salvage proves its tree, keeps its PR honest, and holds its card (#427)

An adversarial review of the salvage found it could do damage no drive can:

- A leftover `feat-…` directory with no `.git` sits inside the main checkout, so git
  run in it answered for THAT: the salvage counted the operator's own commit as the
  card's work and committed the card's title onto the operator's branch. Every tree is
  now proven first (`own_worktree`: its own top level, and registered by the repo), and
  `commits_ahead` refuses to measure a directory that is not one.
- `force` against a card that already had a PR adopted the READY PR and reported
  "draft". The adopted PR is now converted (`gh pr ready --undo`), the gate output is
  posted on it, and `draft` is read back from GitHub rather than assumed. A repo without
  drafts gets an error naming the branch it already pushed.
- A card blocked out of in_review had its block cleared and then failed open_review's
  in_progress precondition. It now goes back to in_review on its own PR.
- With review_gate on and merge_poll off nothing ever ran the review gate on a salvaged
  PR; the salvage now runs it inline, as a drive does.
- A cancel during the publish still opened a PR for a cancelled card, and its reap could
  remove the tree mid-gate. The salvage re-checks the cancel before and after opening the
  PR (closing one it just opened), and holds its trees so the reap waits.
- The claim scan and boot recovery ignored the salvage's reservation, and the salvage
  released the reservation unconditionally. Both now skip a reserved card, and the
  salvage releases only its own (by identity).

The gate output is fenced with more backticks than it contains. Docs state the salvage
is an operator override (goal/ledger/source-issue checks skipped; CI and the review gate
still apply), that format_cmd may rewrite the tree even on a refusal, and how a hung
drive has to be handled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av

* fix(loop): a retry re-reads its card before throwing its failed attempt away (#398, #405)

A retry that rebuilds from scratch removes the drive's own failed attempt without saving
it. That is by design (#405): the drive built that tree and judged it. But #436's
ownership check sits at the top of the attempt, and the prompt is built after it,
including a knowledge-graph fetch that can take seconds. A human hold that landed in
that window turned the tree into a moved build's, and it was removed unsaved anyway. A
coder that fell over half-way through leaves its partial work there.

The drive now re-reads the card right before that removal. A card moved on keeps its
tree through the same stand-aside as every other moved build, and whatever ends that
tree later saves it first. The test fails without the re-read: the retry is dispatched
on the held card and attempt 1's tree is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant