Repository navigation
Conversation
…eady create_task set status='blocked' but emitted only a `created` event, never a `blocked` one, so `_has_sticky_block` returned False and `recompute_ready` promoted the parentless card back to `ready`. Because `recompute_ready` runs inside `dispatch_once`, the promotion and the claim+spawn land on the same tick — a card explicitly parked for human ops gets a worker anyway. Emit the same `blocked` event `block_task` emits at creation time. `unblock_task` remains the legitimate exit. 4 new tests (3 RED-prove the fix; the 4th is the unblock-exit control).
`reclaim_task` accepted `blocked` in its WHERE clause while writing an unconditional literal `status='ready'`, so an operator reclaim on a card parked in `blocked` promoted it and the dispatcher claimed + spawned a worker on the next tick. That defeats every hold the board has at once: `--initial-status blocked`, a worker's review-required handoff, and the circuit breaker's auto-block. There is a second-order tell that confirms the shape: reclaim emits only a `reclaimed` event, never `unblocked`, so `_has_sticky_block` still returns True afterwards. The row lands `status='ready'` **and** sticky-blocked at the same time — an incoherent state no other writer can produce, and one that `recompute_ready` and the dispatcher then actively disagree about. A reclaim releases a *claim*; it is not a promotion. Rows in `blocked` / `triage` / `scheduled` now keep their status and shed only the stale claim residue (`claim_lock` / `claim_expires` / `worker_pid`). `unblock_task` and `promote_task` remain the only ways out of `blocked`. The preserved status is recorded in the `reclaimed` event payload and surfaced by `hermes kanban reclaim`, so an operator can see why the card did not go ready. Whole bug class, not just the reported site: all five sibling claim-release paths (`release_stale_claims`, `detect_crashed_workers` ×2, `enforce_max_runtime`, `_record_task_failure`) already scope their UPDATE to `status = 'running'` and are correct. A source-level invariant test pins that, so a future sibling that widens its WHERE clause the same way fails here. 9 new tests; 7 RED-prove the fix, 2 are controls (running→ready still works, no-claim-release-path-promotes invariant).
f511d37 to
044f3ff
Compare
teknium1
left a comment
There was a problem hiding this comment.
Thanks — the main reclaim bug is real: hermes_cli/kanban_db.py:4486-4490 writes literal ready while accepting a locked blocked task.
Problems
hermes_cli/kanban.py:1846givesunblockguidance fortriage, butunblock_taskonly acceptsblockedandscheduled(hermes_cli/kanban_db.py:5808). A reclaimed triage card therefore receives a command that fails; its supported transition is the triage specify/decompose path (hermes_cli/kanban_db.py:5829).tests/hermes_cli/test_kanban_reclaim_blocked_status_guard.py:216usesinspect.getsource()and regexes SQL.AGENTS.md:1382explicitly bans source-code-reading tests because they test implementation shape rather than behavior.
Suggested changes
- Make the CLI remediation status-specific and add a triage-output regression test.
- Replace the source-regex test with behavioral tests for the actual claim-release paths.
Automated hermes-sweeper review.
| # operator assumes the card is queued and waits for a worker forever. | ||
| print( | ||
| f"Reclaimed {args.task_id} (status held at {status!r} — " | ||
| f"run 'hermes kanban unblock {args.task_id}' to re-queue it)" |
There was a problem hiding this comment.
triage is included in this branch, but unblock_task only accepts blocked and scheduled (hermes_cli/kanban_db.py:5808). This prints a recovery command that fails for triage; direct triage to its specify/decompose transition instead.
| import inspect | ||
| import re | ||
|
|
||
| src = inspect.getsource(kb) |
There was a problem hiding this comment.
Please replace this source inspection with behavioral coverage. AGENTS.md bans tests that read production source because implementation-shape assertions break valid refactors without proving the runtime contract.
|
Closing — implemented on Re-verified at I also executed the laundering scenario end to end against a temp board on current Branch preserved; reopenable if a genuine blocked-plus-claim path turns up. |
fix(kanban): reclaim must not launder a blocked card into ready
Symptom
An operator reclaim on a card parked in
blockedpromotes it toready,handing it straight to the dispatcher, which claims it and spawns a worker on
the next tick. This silently defeats every hold mechanism the board has:
--initial-status blocked, a worker'sreview-requiredhandoff, and thespawn-failure circuit breaker alike.
Root cause
hermes_cli/kanban_db.py,reclaim_task:The WHERE clause accepts
blockedwhile the SET writes an unconditionalliteral
'ready'. A reclaim releases a claim; it is not a promotion.The row it produces is worse than merely promoted:
reclaim_taskemits only areclaimedevent, neverunblocked, so_has_sticky_blockstill reportsTrueafterwards. The card ends upstatus='ready'and sticky-blockedsimultaneously — a state no other writer can produce, and one that
recompute_readyand the dispatcher disagree about.Whole bug class
The sibling reclaim paths were already correct —
release_stale_claims(:4355),
detect_crashed_workers(:7066, :7192),enforce_max_runtime(:7450)and the failure-recording path (:7728) all scope their UPDATE to
status = 'running'. Only the operator-drivenreclaim_taskaccepted a parkingstatus, so this is the whole class. A source-level invariant test pins that, so
a future writer widening a sibling's WHERE clause has to confront it.
triageandscheduledare the same category of pre-dispatch parking columnand get the same treatment.
triageadditionally gains the ability to bereclaimed: it wasn't in the old WHERE clause at all, so a triage row carrying
stale claim residue simply returned
Falseand stayed stuck.Fix
blocked/triage/scheduledkeep their status and shed onlythe stale claim residue (
claim_lock/claim_expires/worker_pid).unblock_taskandpromote_taskremain the only ways out ofblocked.reclaimedevent recordsstatus_preserved, so board history shows whya reclaimed blocked card did not go ready.
hermes kanban reclaimreports the held status and points atunblock,instead of printing a bare
Reclaimed <id>that reads as "it's queued again"and leaves the operator waiting for a worker forever.
No new state, no signature changes. +38/−4 across two files.
Tests
New
tests/hermes_cli/test_kanban_reclaim_blocked_status_guard.py, 9 tests:..._initial_status_blocked_cardblockedtest_dispatcher_does_not_spawn_a_reclaimed_blocked_carddispatch_once:spawn_fnnever called..._coherent_with_sticky_blockstatus='ready'+ sticky-blocked row..._preserves_worker_review_required_blockreview-requiredhandoff survives..._promote_a_triage_cardtriageis now reclaimable and not promotedtest_no_claim_release_path_promotes_a_parked_rowUPDATE..._records_the_preserved_status_in_board_history(control)status_preservedin the event payloadtest_reclaim_still_returns_a_running_task_to_ready(control)running→ready→ dispatchabletest_cli_reclaim_tells_the_operator_the_card_is_still_heldThe
running→ready→ actually spawns control is deliberate: it caught anearlier version of this suite that was false-greening on
skipped_nonspawnable(the dispatcher refuses assignees that aren't realprofiles, so "did not spawn" can pass trivially). The fixture patches
profile_existsso the negative assertions prove the status guard, not theprofile guard.
RED proof (both production changes reverted, tests unchanged): 7 of 9
fail. The two survivors are the controls. See
TEST-EVIDENCE.md.GREEN: 259 passed across
test_kanban_db.py, this file,test_kanban_decompose_db.py,test_kanban_worktree_isolation.py.