feat(loop): name the cards stranded outside the ready lane (#406) - #438
Merged
Merged
Conversation
The loop claims only `ready` cards, and the only waiting it re-checks is
ready + depends_on (the dag gate). A card left in backlog to wait for its
dependencies, or blocked there for the same reason, was never looked at
again once they closed. It was not a claim candidate and appeared in no
skip diagnostic, and on a live board a dependency-complete card sat
waiting on a mark_ready nobody knew was owed.
`store.stranded_posture` finds the two shapes from the row alone (state,
labels, depends_on / open_depends_on, no extra read):
- backlog, every dependency closed -> "dependencies closed — promote",
hint board_mark_ready(<id>). The Ready gate still decides. Deferred and
designing cards are excluded because they are parked for another reason.
- blocked in backlog, every dependency closed -> "blocked — dependencies
closed". The block may be only that wait or may be unrelated, so it is
surfaced and never cleared.
A card with no recorded depends_on is never "stranded": without a
recorded edge there is nothing to say has cleared. Nothing is promoted
or unblocked for anyone.
Where it shows: `annotate_next_action` (board_list, GET /features, a new
console chip), the agent's working-state snapshot, one sweep log line
the first time each card is seen stranded, and one more operator alert
when the last dependency of a blocked card closes (the deps-closed state
is part of the incident key).
The sweep now annotates the listing before publishing the snapshot. The
snapshot was built to reuse the board's own next_action_hint, but it was
fed a bare listing, and only annotate_next_action writes that field, so
every hint in the agent's working state was empty. The snapshot also now
admits a backlog card that the board names a step for.
A block set by hand (board_block_feature, POST /features/{fid}/block)
now always carries the terminal class. Its class used to be guessed from
the reason by the coder-failure classifier, which reads prose as an error
message. "waiting on the network team" came out transient, and the
blocked sweep cleared the hold and requeued the card to ready, skipping
the Ready gate for a card that had never passed it (reproduced against
real br on origin/main). A human's block is a decision, and only its
author knows when it is over. Deliberate behavior change: a hand-set
block no longer self-heals whatever its wording, and is lifted with
board_unblock_feature.
Tests are on real br. Against origin/main, 19 of 21 fail. The two that
pass pin the dag-gate release and the parked-card exclusions, which main
already had.
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
board_dispatch answered a bare `empty-queue` whenever the claim scan found nothing. On a live board it said so while a ready card waited on an open dependency (#398's thread) and while backlog cards sat stranded with every dependency closed. The queue was empty; the board was not. When nothing is claimable, dispatch_now now makes one fresh board read and classifies what is held, with the same stranded_posture the listing and the sweep use, so the surfaces cannot disagree: - dependencies-closed-promote (step: board_mark_ready) - blocked-dependencies-closed (step: read the reason, unblock, mark ready) - ready-waiting-on-dependencies (step: none, the dag gate releases it) - backlog-waiting-on-dependencies - blocked:<class> (self-healing classes say the sweep retries) Each reason carries a count, its first 5 ids and the step, in a new `held` field that is present, possibly empty, on every record. An empty queue with anything held is now outcome `held`, with a bounded detail sentence. `empty-queue` now means nothing is held either. `all-candidates-held` keeps its outcome and gains the board-wide breakdown. The read is best-effort: if it fails, the record is returned exactly as the scan made it. Also: - tests/test_docs_reference.py matched only ast.FunctionDef, so board_dispatch, the one `async def` tool, was never checked against docs/tools.md, and it was undocumented. The guard now matches async tools too, and the row is added. - docs/lifecycle.md no longer says a list row always shows an empty block reason, which has been wrong since #416. The same stale claim in `_recover_blocked`'s comment is fixed. The blocked-cards section documents the stranded surfacing, the `held` dispatch outcome, and why a hand-set block is always terminal. Auto-promotion stays out on purpose: a backlog card may sit there by design. Red-check against origin/main: the real-board dispatch returns `empty-queue` with five cards held, `held` is missing, the helpers are absent, and the fixed docs guard fails on the undocumented tool. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
|
|
Resolves the two store-import lines against #432 (open_requirement_ids, open_requirements_note) beside MANUAL_BLOCK_CLASS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
Resolves against #437: the store import gains knob_bool beside MANUAL_BLOCK_CLASS; docs/tools.md keeps board_attach_pr and this branch's board_block_feature and board_dispatch rows (the latter with #437's lifecycle marker); tests/test_docs_reference.py takes main's async-tool guard, which is the same fix this branch had made. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av
Five confirmed findings on 993e249, none blocking: 1. The sweep still promoted OLD hand blocks past the Ready gate. The self-heal clears and requeues any self-healing class, and requeue adds `ready`, including for a backlog card that never passed the gate. That covers hand blocks written before hand blocks were always terminal, and any caller that omits a class. Closed structurally: the self-heal never moves a card blocked before it was ever ready (store.blocked_before_ready: open, no `ready` label, blocked; the loop only blocks ready and in-flight cards). Such a card goes to a human, and the alert says why. 2. Stranded backlog cards ranked above in-flight work in the 12-card working state, so a dozen of them evicted the PR awaiting merge. They now rank last. And the PM can say "parked on purpose": the existing DESIGNING state is exposed as board_mark_designing (the store method already existed, and board_mark_ready already unparks it). A parked card is not called stranded. It is the smallest honest option: a new `deferred` verb would need mark_ready to reopen the status, which is a lifecycle change, not a flag. 3. board_dispatch's `held` said "the sweep retries a transient block by itself" when the card's unblock-retry budget was spent (the sweep escalates those) or the card was blocked by hand. It now judges each card as the sweep will. 4. A cancelled dependency counts as closed (as beads' own gate does), but the hint read as "every card it waited on is done". annotate_next_action and _held_summary pass the listing's cancelled ids, and the hint names a cancelled dependency and asks to confirm the card still makes sense. 5. Mutating away the task-card fallback survived: the pure-posture param never exercised that branch. New real-br test through the listing, killed by that mutation. Doc nits: the board_dispatch row says when `held` is filled; the board_list docstring (which the model reads) lists the new next_action values; flag_blocked's refusal no longer tells hand callers to block as transient; lifecycle.md says the stranded log line is per process and documents the ranking, board_mark_designing, cancelled dependencies and the backlog self-heal rule. Red-check against 34ccd4d: 5 of the new or changed tests fail for the behavioural reasons (in-flight cards evicted, the card promoted to `ready`, the cancelled dependency unnamed, a retry promised, no tool). The task-card test fails under the reviewer's M9 mutation. The reviewer's R1, R3 and R4 now fail; R2 still passes, as the review intended. 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
… 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
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 #406. Auto-promotion is deliberately out of scope; see "Scope" at the end.
The problem, confirmed
The loop claims only
readycards, and the only waiting it re-checks isready+depends_on(br readyexcludes a card with an open blocker, and the dag gate releases it when the blocker closes). A card left in backlog to wait for its dependencies, or blocked in backlog for the same reason, is not in the claim pool and not in any skip diagnostic. Nothing ever looks at it again.While checking the issue's second invariant ("do not auto-clear genuine human/external blocked reasons") against origin/main, I found it was already broken. A block set by hand was auto-cleared whenever its reason contained a keyword.
board_block_feature/POST …/blockpass no class, soflag_blockedguessed one from the reason with the coder-failure classifier. Reproduced against realbron main:That promoted a backlog card that never passed the Ready gate straight to
ready."blocked until the rebase onto #3360 lands"getsmerge-conflict, with the same result.The fix
store.stranded_posture(feature)is a pure function over the row (state, labels,depends_on/open_depends_on, no extra read), a sibling ofreview_fix_posture. It recognises two shapes, kept apart because only one of them is safe to act on:next_actiondependencies closed — promoteboard_mark_ready(<id>), and the Ready gate still appliesblocked — dependencies closedboard_unblock_featurethenboard_mark_ready. Never cleared on anyone's behalf.Excluded:
deferred/designingbacklog cards (parked for another reason), blocks in the ready lane or mid-build (loop holds, or build failures),ready+depends_on(the dag gate's case), and any card with no recorded edge (without a recorded edge there is nothing to say has cleared).Where it surfaces. Nothing is moved, so no card can be claimed twice.
annotate_next_action, which feedsboard_list,GET /featuresand a new console chip.next_action_hint, but it was fed a bare listing, and onlyannotate_next_actionwrites that field, so every hint in the agent's working state was empty. That was a separate gap, fixed here because the stranded card's verb needs that field to reach the agent. The snapshot also admits a backlog card that the board names a step for, ranked right after blocked cards.deps-closedis part of the incident key, so the alert fires once and is not repeated.Hand-set blocks always carry
terminal(store.MANUAL_BLOCK_CLASS, used by the tool and the route). This is a deliberate behavior change: a block set by hand no longer self-heals, whatever its reason says. It is lifted withboard_unblock_feature. An empty reason was already refused (classify("")is terminal, and #414 guards it), so that is unchanged. Docs updated:docs/lifecycle.md(new section), plus thedocs/tools.mdanddocs/api.mdrows.Tests:
tests/test_stranded_cards_406.py(new, realbr)ready+depends_onis still released by the dag gate alone and is not called stranded. Deferred, designing and dependency-free cards are not called stranded.POST …/block("rebase").merge #42). A card is logged once, not on every sweep.Red-check: I ran the file on an
origin/mainworktree, with a shim for the new names so the tests collect. Result: 19 failed, 2 passed. On main the external block endsready, the route's block ismerge-conflict, and the working-state hint is''. The two passes pin the dag-gate release and the parked-card exclusions, which main already had.Full suite passes on br 0.2.16 and 0.1.23: 1924 passed, 13 skipped.
ruff checkandruff format --checkare clean.tests/test_api.py's fakeflag_blockedgained thecategoryparameter.board_dispatch: name what is held, don't answer a bareempty-queue(second commit)board_dispatchused to sayempty-queuewhenever the claim scan found nothing. On a live board it said that while a ready card waited on an open dependency (#398's thread) and while backlog cards sat stranded. The queue was empty; the board was not.When nothing is claimable,
dispatch_nownow does one fresh board read (on the no-dispatch path only) and classifies what is held. It uses the samestranded_postureas the listing and the sweep, so the surfaces can't disagree. Every record gains aheldfield ({}when nothing is held). Each reason maps to{count, ids (first 5), next}:dependencies-closed-promoteboard_mark_ready. The Ready gate still appliesblocked-dependencies-closedready-waiting-on-dependenciesbacklog-waiting-on-dependenciesboard_mark_readyblocked:<class>held, with a bounded detail sentence:nothing is claimable, but 5 card(s) are held: 1 backlog with every dependency closed (bd-x); ….empty-queuenow also means nothing is held.all-candidates-heldkeeps its outcome, since itsskippedalready explains the ready candidates, and gains the board-wide breakdown.A docs-guard blind spot, fixed along the way.
tests/test_docs_reference.pymatched onlyast.FunctionDef, soboard_dispatch, the oneasync deftool, was never checked againstdocs/tools.md, and it was undocumented. The guard now matches async tools, and the row is added.docs/lifecycle.md. It no longer says a list row always shows an empty block reason, which has been wrong since #416. The same stale claim in_recover_blocked's comment is fixed too. The blocked-cards section now documents the stranded surfacing, theheldoutcome, and why a hand-set block is alwaysterminal.Tests (added to the same file): a real board with one card of each shape, where
dispatch_nowreturnsheldwith exactly the right ids per reason and moves nothing.empty-queueonly when nothing is held. The bound (9 blocked cards give count 9 and 5 ids, and the sentence ends…).all-candidates-heldkeeps its outcome and gainsheld. An unreadable board leaves the record untouched. Red-check onorigin/main: the real-board dispatch returnsempty-queuewith five cards held,heldis missing, the helpers are absent, and the fixed docs guard fails on the undocumented tool. After the second commit the suite is 1929 passed, 13 skipped, on br 0.2.16 and 0.1.23.Review follow-ups (commit 0f2a0c7)
Five confirmed findings on 993e249 are fixed, each with a red-check against the previous head:
ready. The fix is structural: the self-heal never moves a card blocked before it was ever ready (store.blocked_before_ready: open, noreadylabel, blocked). The loop only blocks ready and in-flight cards, so such a block was set by hand. It goes to a human, and the alert says why. This is what makes the listing's "a block is never cleared for you" true.board_mark_designing. That exposes the existing DESIGNING state: the store method already existed, andboard_mark_readyunparks it. A parked card is not called stranded. This was the smallest honest option. A newdeferredverb would needmark_readyto reopen the status, which is a lifecycle change rather than a flag.heldno longer promises a retry the sweep won't make. Each card is judged as the sweep will judge it: itsunblock-retrybudget, and whether it was blocked before it was ever ready.heldpromote step says the same.Doc nits are done: the
board_dispatchrow says whenheldis filled; theboard_listdocstring (the one the model reads) lists the newnext_actionvalues;flag_blocked's refusal no longer tells hand callers to block as transient; and lifecycle.md says the stranded log line is per process. The branch is merged with main (#432, #435, #437; resolved against #437's async-tool docs guard andboard_attach_pr). The full suite passes on br 0.2.16 and 0.1.23: 2072 passed, 16 skipped.Scope
🤖 Generated with Claude Code
https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av