Skip to content

feat(loop): name the cards stranded outside the ready lane (#406) - #438

Merged
mabry1985 merged 6 commits into
mainfrom
feat/stranded-cards-406
Sep 11, 2026
Merged

mabry1985 merged 6 commits into
mainfrom
feat/stranded-cards-406

Conversation

@mabry1985

@mabry1985 mabry1985 commented Sep 10, 2026

Copy link
Copy Markdown
Member

Fixes #406. Auto-promotion is deliberately out of scope; see "Scope" at the end.

The problem, confirmed

The loop claims only ready cards, and the only waiting it re-checks is ready + depends_on (br ready excludes 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 …/block pass no class, so flag_blocked guessed one from the reason with the coder-failure classifier. Reproduced against real br on main:

board_block_feature(card, "waiting on the network team to open the VPN route")
  → blocked-class: transient          (matched `network`)
_recover_blocked()                    (the blocked sweep)
  → cleared + requeued → board_state: ready

That promoted a backlog card that never passed the Ready gate straight to ready. "blocked until the rebase onto #3360 lands" gets merge-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 of review_fix_posture. It recognises two shapes, kept apart because only one of them is safe to act on:

shape next_action hint
backlog, every dependency closed dependencies closed — promote board_mark_ready(<id>), and the Ready gate still applies
blocked in backlog (never promoted, so never blocked by the loop), every dependency closed blocked — dependencies closed if they were the reason: board_unblock_feature then board_mark_ready. Never cleared on anyone's behalf.

Excluded: deferred / designing backlog 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 feeds board_list, GET /features and a new console chip.
  • The agent's working-state snapshot. The sweep now annotates the listing before publishing it. The snapshot was designed to reuse the board's 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. 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.
  • One sweep log line the first time each card is seen stranded. This is the audit trail.
  • One more operator alert when the last dependency of a blocked card closes. deps-closed is 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 with board_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 the docs/tools.md and docs/api.md rows.

Tests: tests/test_stranded_cards_406.py (new, real br)

  • A dependency-complete backlog card names the promote verb. ready + depends_on is still released by the dag gate alone and is not called stranded. Deferred, designing and dependency-free cards are not called stranded.
  • A card blocked through the real tool is surfaced once its dependency closes and is never cleared. It gets one new alert with a new incident key, and the sweep log names it.
  • A real external block is never auto-cleared ("network" in the reason, over three sweeps, which is more than the retry budget), including through POST …/block ("rebase").
  • The working state names a stranded backlog card with its verb. An in_review card's hint is now actually present (merge #42). A card is logged once, not on every sweep.
  • The posture is checked edge by edge (10 cases), plus the console chip and the snapshot ranking.

Red-check: I ran the file on an origin/main worktree, with a shim for the new names so the tests collect. Result: 19 failed, 2 passed. On main the external block ends ready, the route's block is merge-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 check and ruff format --check are clean. tests/test_api.py's fake flag_blocked gained the category parameter.

board_dispatch: name what is held, don't answer a bare empty-queue (second commit)

board_dispatch used to say empty-queue whenever 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_now now does one fresh board read (on the no-dispatch path only) and classifies what is held. It uses the same stranded_posture as the listing and the sweep, so the surfaces can't disagree. Every record gains a held field ({} when nothing is held). Each reason maps to {count, ids (first 5), next}:

reason the step that moves it
dependencies-closed-promote board_mark_ready. The Ready gate still applies
blocked-dependencies-closed read the block reason; if the dependencies were why, unblock, then mark ready
ready-waiting-on-dependencies none. The dag gate releases it by itself
backlog-waiting-on-dependencies none yet. Once the dependencies close it still needs board_mark_ready
blocked:<class> a self-healing class says the sweep retries it; any other class says a human decides
  • An empty queue with anything held is now outcome held, with a bounded detail sentence: nothing is claimable, but 5 card(s) are held: 1 backlog with every dependency closed (bd-x); …. empty-queue now also means nothing is held.
  • all-candidates-held keeps its outcome, since its skipped already explains the ready candidates, and gains the board-wide breakdown.
  • The board read is best-effort. If it fails, the record is returned exactly as the scan made it: the explanation must never turn the diagnostic into an error.

A docs-guard blind spot, fixed along the way. 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, 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, the held outcome, and why a hand-set block is always terminal.

Tests (added to the same file): a real board with one card of each shape, where dispatch_now returns held with exactly the right ids per reason and moves nothing. empty-queue only when nothing is held. The bound (9 blocked cards give count 9 and 5 ids, and the sentence ends ). all-candidates-held keeps its outcome and gains held. An unreadable board leaves the record untouched. Red-check on 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. 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:

  1. The sweep still promoted old hand blocks past the Ready gate. Hand blocks written before this PR keep the class their wording guessed, and the self-heal's requeue adds ready. The fix is structural: 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, 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.
  2. The working state could evict in-flight work. Stranded cards now rank last (blocked, in_review, in_progress, ready, then stranded), so they fill only the slots live cards leave. The PM can now say "parked on purpose" with board_mark_designing. That exposes the existing DESIGNING state: the store method already existed, and board_mark_ready unparks it. A parked card is not called stranded. This was the smallest honest option. A new deferred verb would need mark_ready to reopen the status, which is a lifecycle change rather than a flag.
  3. held no longer promises a retry the sweep won't make. Each card is judged as the sweep will judge it: its unblock-retry budget, and whether it was blocked before it was ever ready.
  4. A cancelled dependency is named. It still counts as closed, as beads' own gate counts it. The hint says it was CANCELLED, not merged, and asks to confirm the card still makes sense. The held promote step says the same.
  5. The task-card fallback is now tested through the real listing. The reviewer's M9 mutation, which removes that fallback, now fails the new test.

Doc nits are done: the board_dispatch row says when held is filled; the board_list docstring (the one the model reads) lists the new next_action values; 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 and board_attach_pr). The full suite passes on br 0.2.16 and 0.1.23: 2072 passed, 16 skipped.

Scope

  • Auto-promotion is deliberately out of scope, and there is no opt-in key. The issue marks it optional, and a backlog card may sit in backlog on purpose, so the board names the promote step and leaves the decision where it was.

🤖 Generated with Claude Code

https://claude.ai/code/session_01F2V6GRejF7mNukAoYjj2Av

mabry1985 and others added 3 commits September 10, 2026 15:27
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
@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 14f7f8a817d3. No verdict was posted.
A new push will re-trigger the review.

mabry1985 and others added 3 commits September 10, 2026 16:50
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
mabry1985 merged commit 945249d into main Sep 11, 2026
4 checks passed
@mabry1985
mabry1985 deleted the feat/stranded-cards-406 branch September 11, 2026 00:19
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
@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 812d8821db00. No verdict was posted.
A new push will re-trigger the review.

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
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.

feat(loop): surface and promote stranded cards whose recorded blockers have cleared

1 participant