fix(kanban): worker-initiated block must not be auto-promoted (#28712) - #28726
Closed
xxxigm wants to merge 3 commits into
Closed
fix(kanban): worker-initiated block must not be auto-promoted (#28712)#28726xxxigm wants to merge 3 commits into
xxxigm wants to merge 3 commits into
Conversation
…search#28712) When a worker calls ``kanban_block(reason="review-required: ...")`` to hand a task off for human review, the dispatcher's ``recompute_ready`` was treating the resulting ``blocked`` status as eligible for auto-promotion — exactly the same as a circuit-breaker block. On the next tick the task flipped back to ``ready``, a fresh worker spawned, found nothing to do (work already applied, review-required comment already posted), exited cleanly, got recorded as ``protocol_violation`` → ``gave_up`` → ``blocked``, and the dispatcher promoted again. Infinite loop until manual ``hermes kanban reclaim`` + ``kanban block``. Add ``_has_sticky_block`` which distinguishes the two block sources using the cheapest available signal: the most recent ``"blocked"``/``"unblocked"`` event in ``task_events``. * Worker / operator ``kanban_block`` emits ``"blocked"`` → ``_has_sticky_block`` returns True → ``recompute_ready`` skips the task entirely. ``unblock_task`` emits ``"unblocked"`` which flips the predicate back, so the only legitimate exit is the documented human-in-the-loop path. * Circuit-breaker ``_record_task_failure`` emits ``"gave_up"`` (not ``"blocked"``) → predicate stays False → original parent-completion-recovery semantics from #40c1decb3 are preserved. * Tasks blocked purely by direct DB manipulation also recover, since they have no ``"blocked"`` event row at all — matches the existing ``test_recompute_ready_promotes_blocked_with_done_parents`` fixture behaviour.
…ousResearch#28712) ``init_db`` on a kanban.db that pre-dates the ``session_id``, ``tenant`` or ``idempotency_key`` migrations crashed with ``no such column: <name>`` because ``SCHEMA_SQL`` asserted the index on those columns before ``_migrate_add_optional_columns`` had a chance to ADD the columns. On legacy DBs the ``CREATE TABLE IF NOT EXISTS`` at the top of the script was a no-op, so the table never grew the new columns, and the next ``CREATE INDEX`` blew up the entire init sequence — including the migration that would have fixed it. Reporter had to manually ``ALTER TABLE`` + ``CREATE INDEX`` to unstick their install. Move ``idx_tasks_tenant``, ``idx_tasks_idempotency`` and ``idx_tasks_session_id`` out of ``SCHEMA_SQL`` and into ``_migrate_add_optional_columns``, unconditionally and with ``IF NOT EXISTS`` so they're a cheap no-op on fresh DBs where the columns and indexes were created together. The additive ``ALTER TABLE`` for each column happens first; the index assertion runs after, by which point the column is guaranteed to exist on both new and legacy schemas.
…arch#28712) Seven regression tests pinning the contract that was broken in NousResearch#28712: Dispatcher (``recompute_ready``): * ``test_worker_block_is_not_auto_promoted_by_recompute_ready`` — ``kanban_block`` survives five back-to-back ticks (compressed dispatcher loop). * ``test_worker_block_on_child_with_done_parents_is_still_sticky`` — the parent-completion code path was the worst false-positive; even when every parent is ``done``, an explicit worker block stays blocked. * ``test_circuit_breaker_block_still_auto_promotes`` — preserves the pre-NousResearch#28712 recovery semantics for circuit-breaker blocks (direct ``UPDATE`` + no ``"blocked"`` event). * ``test_gave_up_event_alone_does_not_make_block_sticky`` — explicit guard so the ``gave_up`` event is never accidentally treated as sticky; covers the second leg of the protocol_violation loop. * ``test_unblock_clears_sticky_state_and_lets_block_recover`` — only ``unblock_task`` resolves the sticky state; subsequent circuit-breaker blocks recover normally. * ``test_protocol_violation_loop_is_broken`` — full bug-shaped reproduction: block → tick → (would-be) crash + gave_up → next tick still blocked. Without the fix this would loop indefinitely. Schema init: * ``test_init_db_recovers_from_legacy_tasks_table_without_session_id`` — hand-crafts a pre-``tenant`` / pre-``idempotency_key`` / pre- ``session_id`` ``tasks`` table, calls ``init_db``, and asserts all three columns + indexes end up present and legacy rows survive.
This was referenced May 19, 2026
Contributor
|
Salvaged via #28994 (merged d35f893 + 4dac6e7-ish — both commits preserve your authorship per rebase-merge). Thanks for the clean RCA + thorough test coverage — the The schema-init half of your PR (the Closing this PR; your sticky-block fix + 6 regression tests are now on main. |
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.
What does this PR do?
#28712 describes a kanban infinite loop:
kanban_block(reason="review-required: ...")to hand off to a human.recompute_ready()flips the task back toreadyon the next tick.detect_crashed_workersrecordsprotocol_violation→_record_task_failure(failure_limit=1)→gave_up→blocked.recompute_readypromotes again → goto 3.Result: burned API calls, phantom "crashed" runs polluting task history, misleading
repeated_crashesflag fromkanban diag, and amplified pressure on rate-limited providers (the reporter hit 429s during the loop on Kiro/Anthropic).Root cause —
recompute_readywas treating everyblockedtask with satisfied parents as eligible for promotion, with no way to distinguish:kanban_block) — deliberate human-in-the-loop handoff; must stay blocked until explicitkanban_unblock, per the documented kanban-orchestrator skill contract._record_task_failuretripping on repeated crashes) — should auto-recover when conditions change (parents complete, transient infra clears). This is the original intent of #40c1decb3 ("promote blocked tasks when parent dependencies complete").Fix — distinguish the two using the cheapest available signal: the most recent
"blocked"/"unblocked"event intask_events.kanban_blockalready emits a"blocked"event row (with thereview-required: …reason).kanban_unblockalready emits an"unblocked"event row._record_task_failureemits"gave_up", not"blocked".New helper
_has_sticky_block(conn, task_id)returns True iff the most recent of the two block-related events is"blocked".recompute_readyconsults it and skips sticky-blocked tasks. The only legitimate exit isunblock_task(), which emits"unblocked"and flips the predicate back — exactly the documented human-in-the-loop pattern.Also fixes the tangentially related schema-init crash the reporter flagged at the bottom of #28712 (
init_dbfailed withno such column: session_idon a kanban.db that pre-dated thesession_idmigration). ThreeCREATE INDEXstatements ontasks(<late-added-column>)were sitting at the top ofSCHEMA_SQL, where they run before the additive-column migrations. On a legacy DB the table'sCREATE TABLE IF NOT EXISTSis a no-op, the column doesn't exist yet, and the index DDL crashes the whole init script — including the migration that would have fixed it. The reporter had toALTER TABLE+CREATE INDEXby hand to unstick their install. Moved all three (idx_tasks_tenant,idx_tasks_idempotency,idx_tasks_session_id) into_migrate_add_optional_columns, after theALTERcalls that guarantee the columns exist.Related Issue
Fixes #28712
Type of Change
Changes Made
hermes_cli/kanban_db.py—_has_sticky_block(conn, task_id) -> boolhelper that reads the most recent"blocked"/"unblocked"event for a task.recompute_ready()nowcontinues pastblockedtasks whose latest block-related event is"blocked"; circuit-breaker blocks (with no event, or"gave_up"event) continue to auto-recover when parents complete.idx_tasks_tenant,idx_tasks_idempotencyandidx_tasks_session_idout ofSCHEMA_SQLand into_migrate_add_optional_columns, asserted unconditionally withIF NOT EXISTSso new DBs get them on first init and legacy DBs get them after the additiveALTER TABLEcalls.tests/hermes_cli/test_kanban_blocked_sticky.py— 344 lines, 7 new tests:test_worker_block_is_not_auto_promoted_by_recompute_ready— five back-to-back ticks leave the task blocked.test_worker_block_on_child_with_done_parents_is_still_sticky— the worst false-positive (parent-completion path) is closed.test_circuit_breaker_block_still_auto_promotes— preserves the pre-kanban: dispatcher auto-promotes blocked task → respawn worker → protocol_violation loop #28712 recovery semantics for the original40c1decb3intent.test_gave_up_event_alone_does_not_make_block_sticky— explicit guard so the protocol_violation loop's second leg can't regress.test_unblock_clears_sticky_state_and_lets_block_recover— the only legitimate exit, and subsequent circuit-breaker blocks still auto-recover.test_protocol_violation_loop_is_broken— full bug reproduction: block → tick → (would-be) crash +gave_up→ next tick still blocked. Would loop indefinitely without the fix.test_init_db_recovers_from_legacy_tasks_table_without_session_id— hand-crafted pre-tenant/ pre-idempotency_key/ pre-session_idtaskstable, callsinit_db, asserts all three columns + indexes end up present and legacy rows survive.How to Test
Manual reproduction of the loop fix:
kanban_blockwithreason="review-required: please verify".hermes kanban unblock, wait through one or more dispatcher ticks (or callhermes kanban dispatchdirectly).ready, fresh worker spawns, exits cleanly withprotocol_violation, repeats.blockedindefinitely until you runhermes kanban unblock <id>. Once unblocked, normal promotion + claim semantics resume.Manual reproduction of the schema-init fix:
Checklist
Code
fix(kanban):,test(kanban):)scripts/run_tests.sh tests/hermes_cli/test_kanban_blocked_sticky.py -q(7 passed)Documentation & Housekeeping
kanban_block()to wait for input. Dispatcher respawns after/unblock."), which the bug was violating.sqlite3, no platform-specific codekanban_block/kanban_unblocktool surfaces are unchanged; only the dispatcher's interpretation of their outputs changes)