{"repo":"NousResearch/hermes-agent","public_id":"28712","display_label":"Kanban blocked tasks auto-promoted when they should stay blocked","summary":"A cluster of bugs and fixes around the Kanban dispatcher incorrectly auto-promoting tasks that should remain blocked, including initial-status blocks, human approval gates, protocol violations, and budget-exhaustion states. The fixes address sticky-block guard failures, cross-connection WAL visibility, and related respawn/loop consequences.","subsystem":"cron","counts":{"members":22,"open_issues":1,"open_issues_distinct":1,"open_prs":2,"closed_issues":8,"merged_prs":2,"attempt_prs":11},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":28781},"resolution":{"state":"partially_resolved","by":"pr-28994"},"freshness":{"state":"verified","last_verified_at":"2026-05-20T00:26:24+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.07619047619047618},"fix_edges":[{"issue_number":28464,"pr_number":28781,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":28554,"pr_number":28781,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":28617,"pr_number":28781,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":28654,"pr_number":28781,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":28698,"pr_number":28781,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":28712,"pr_number":28781,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":28712,"pr_number":28994,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":28712,"title":"kanban: dispatcher auto-promotes blocked task → respawn worker → protocol_violation loop","url":null,"state":"closed"},{"kind":"issue","number":28903,"title":"[Bug]: Kanban auto-unblock is too eager","url":null,"state":"closed"},{"kind":"issue","number":28944,"title":"Kanban: blocked tasks auto-promoted by daemon without explicit human unblock","url":null,"state":"closed"},{"kind":"issue","number":29014,"title":"Kanban dispatcher repeatedly respawns blocked/manual-gate tasks, causing provider quota drain","url":null,"state":"closed"},{"kind":"issue","number":34689,"title":"Kanban _has_sticky_block guard ineffective in cross-connection WAL-mode production (recurrence of #28712 loop)","url":null,"state":"closed"},{"kind":"issue","number":37689,"title":"Circuit-breaker (gave_up) blocks are auto-resurrected by recompute_ready → deterministic-failure tasks loop forever","url":null,"state":"closed"},{"kind":"issue","number":39609,"title":"[Bug]: Tasks created with --initial-status blocked auto-promote to ready ~1s later with no actor — human approval gate bypassed","url":null,"state":"closed"},{"kind":"issue","number":47777,"title":"Kanban: kanban_create(initial_status=\"blocked\") does not stick — dispatcher auto-promotes parked tasks to ready and can spawn an unwanted worker (data-integrity risk)","url":null,"state":"open"},{"kind":"issue","number":62828,"title":"Circuit breaker defeated: recompute_ready re-promotes tasks after gave_up","url":null,"state":"closed"},{"kind":"pr","number":28726,"title":"fix(kanban): worker-initiated block must not be auto-promoted (#28712)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d35f8932e","number":0},{"kind":"pr","value":"#28994","number":28994},{"kind":"pr","value":"#28754","number":28754},{"kind":"pr","value":"#28781","number":28781}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":28781,"title":"fix(kanban): hoist all additive-column indexes out of SCHEMA_SQL (#28461)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/kanban_db.py","tests/hermes_cli/test_kanban_db.py"],"fix_hunks":[{"file":"hermes_cli/kanban_db.py","hunks":["@@ -865,8 +865,6 @@ class Event:\n     session_id           TEXT\n );\n \n-CREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id);\n-\n CREATE TABLE IF NOT EXISTS task_links (\n     parent_id  TEXT NOT NULL,\n     child_id   TEXT NOT NULL,","@@ -937,13 +935,10 @@ class Event:\n \n CREATE INDEX IF NOT EXISTS idx_tasks_assignee_status ON tasks(assignee, status);\n CREATE INDEX IF NOT EXISTS idx_tasks_status          ON tasks(status);\n-CREATE INDEX IF NOT EXISTS idx_tasks_tenant          ON tasks(tenant);\n-CREATE INDEX IF NOT EXISTS idx_tasks_idempotency     ON tasks(idempotency_key);\n CREATE INDEX IF NOT EXISTS idx_links_child           ON task_links(child_id);\n CREATE INDEX IF NOT EXISTS idx_links_parent          ON task_links(parent_id);\n CREATE INDEX IF NOT EXISTS idx_comments_task         ON task_comments(task_id, created_at);\n CREATE INDEX IF NOT EXISTS idx_events_task           ON task_events(task_id, created_at);\n-CREATE INDEX IF NOT EXISTS idx_events_run            ON task_events(run_id, id);\n CREATE INDEX IF NOT EXISTS idx_runs_task             ON task_runs(task_id, started_at);\n CREATE INDEX IF NOT EXISTS idx_runs_status           ON task_runs(status);\n CREATE INDEX IF NOT EXISTS idx_notify_task           ON kanban_notify_subs(task_id);","@@ -1083,10 +1078,9 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:\n         _add_column_if_missing(\n             conn, \"tasks\", \"idempotency_key\", \"idempotency_key TEXT\"\n         )\n-        conn.execute(\n-            \"CREATE INDEX IF NOT EXISTS idx_tasks_idempotency \"\n-            \"ON tasks(idempotency_key)\"\n-        )\n+    # ``idx_tasks_idempotency`` is created unconditionally below alongside\n+    # the other additive-column indexes — see the block after the\n+    # legacy-column migration. Creating it here too would be redundant.\n \n     # Refresh after early additive migrations above. Some existing DBs were\n     # partially migrated in older releases and can already contain the later","@@ -1170,24 +1164,38 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:\n         # created from within an agent loop that propagated\n         # ``HERMES_SESSION_ID`` (e.g. ACP). NULL on legacy rows and on any\n         # creation path that doesn't set the env var (CLI, dashboard).\n-        # Index keeps per-session list queries cheap.\n         _add_column_if_missing(\n             conn, \"tasks\", \"session_id\", \"session_id TEXT\"\n         )\n-        conn.execute(\n-            \"CREATE INDEX IF NOT EXISTS idx_tasks_session_id \"\n-            \"ON tasks(session_id)\"\n-        )\n+\n+    # Indexes over additive ``tasks`` columns must be created after the\n+    # columns exist. Keeping them in SCHEMA_SQL breaks legacy boards: SQLite\n+    # parses each statement in ``executescript`` against the live schema, so a\n+    # ``CREATE INDEX`` over a missing column aborts initialization before the\n+    # additive ``ALTER TABLE`` migrations below can run. Re-running them here\n+    # is cheap thanks to ``IF NOT EXISTS`` and stays correct on fresh DBs\n+    # (where the columns already exist from SCHEMA_SQL).\n+    conn.execute(\"CREATE INDEX IF NOT EXISTS idx_tasks_tenant ON tasks(tenant)\")\n+    conn.execute(\n+        \"CREATE INDEX IF NOT EXISTS idx_tasks_idempotency ON tasks(idempotency_key)\"\n+    )\n+    conn.execute(\n+        \"CREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id)\"\n+    )\n \n     # task_events gained a run_id column; back-fill it as NULL for\n     # historical events (they predate runs and can't be attributed).\n     ev_cols = {row[\"name\"] for row in conn.execute(\"PRAGMA table_info(task_events)\")}\n     if \"run_id\" not in ev_cols:\n         _add_column_if_missing(conn, \"task_events\", \"run_id\", \"run_id INTEGER\")\n-        conn.execute(\n-            \"CREATE INDEX IF NOT EXISTS idx_events_run \"\n-            \"ON task_events(run_id, id)\"\n-        )\n+\n+    # Same ordering rule as the additive ``tasks`` indexes above: create the\n+    # index after the additive column migration so legacy ``task_events``\n+    # tables don't fail during SCHEMA_SQL execution before ``run_id`` exists.\n+    conn.execute(\n+        \"CREATE INDEX IF NOT EXISTS idx_events_run \"\n+        \"ON task_events(run_id, id)\"\n+    )\n \n     notify_table_exists = conn.execute(\n         \"SELECT name FROM sqlite_master WHERE type='table' AND name='kanban_notify_subs'\""]},{"file":"tests/hermes_cli/test_kanban_db.py","hunks":["@@ -48,6 +48,87 @@ def test_init_creates_expected_tables(kanban_home):\n     assert {\"tasks\", \"task_links\", \"task_comments\", \"task_events\"} <= names\n \n \n+def test_connect_migrates_legacy_db_before_optional_column_indexes(tmp_path):\n+    \"\"\"Legacy DBs missing additive indexed columns must migrate cleanly.\n+\n+    SCHEMA_SQL runs in ``connect()`` before ``_migrate_add_optional_columns``.\n+    Indexes over additive columns therefore must be created after the\n+    migration adds those columns, or boards predating the column fail to\n+    open before migration can run.\n+\n+    Covers all four indexes that sit on additive columns:\n+    - ``tasks.session_id``       -> ``idx_tasks_session_id``    (#28447)\n+    - ``tasks.tenant``           -> ``idx_tasks_tenant``        (#16081)\n+    - ``tasks.idempotency_key``  -> ``idx_tasks_idempotency``   (#17805)\n+    - ``task_events.run_id``     -> ``idx_events_run``          (#17805)\n+    \"\"\"\n+    db_path = tmp_path / \"legacy-kanban.db\"\n+    conn = sqlite3.connect(str(db_path))\n+    # Pre-#16081 ``tasks`` shape: missing tenant, idempotency_key, session_id.\n+    conn.execute(\"\"\"\n+        CREATE TABLE tasks (\n+            id TEXT PRIMARY KEY,\n+            title TEXT NOT NULL,\n+            body TEXT,\n+            assignee TEXT,\n+            status TEXT NOT NULL,\n+            priority INTEGER NOT NULL DEFAULT 0,\n+            created_by TEXT,\n+            created_at INTEGER NOT NULL,\n+            started_at INTEGER,\n+            completed_at INTEGER,\n+            workspace_kind TEXT NOT NULL DEFAULT 'scratch',\n+            workspace_path TEXT,\n+            claim_lock TEXT,\n+            claim_expires INTEGER\n+        )\n+    \"\"\")\n+    # Pre-#17805 ``task_events`` shape: missing run_id. Required because\n+    # ``_migrate_add_optional_columns`` unconditionally runs PRAGMA on\n+    # ``task_events`` for run_id back-fill.\n+    conn.execute(\"\"\"\n+        CREATE TABLE task_events (\n+            id INTEGER PRIMARY KEY AUTOINCREMENT,\n+            task_id TEXT NOT NULL,\n+            kind TEXT NOT NULL,\n+            payload TEXT,\n+            created_at INTEGER NOT NULL\n+        )\n+    \"\"\")\n+    conn.execute(\n+        \"INSERT INTO tasks (id, title, status, created_at) \"\n+        \"VALUES ('legacy', 'old board task', 'ready', 1)\"\n+    )\n+    conn.commit()\n+    conn.close()\n+\n+    with kb.connect(db_path) as migrated:\n+        task_columns = {\n+            row[\"name\"] for row in migrated.execute(\"PRAGMA table_info(tasks)\")\n+        }\n+        event_columns = {\n+            row[\"name\"]\n+            for row in migrated.execute(\"PRAGMA table_info(task_events)\")\n+        }\n+        indexes = {\n+            row[\"name\"]\n+            for row in migrated.execute(\n+                \"SELECT name FROM sqlite_master WHERE type = 'index'\"\n+            )\n+        }\n+\n+    # Additive columns added by migration:\n+    assert \"session_id\" in task_columns\n+    assert \"tenant\" in task_columns\n+    assert \"idempotency_key\" in task_columns\n+    assert \"run_id\" in event_columns\n+    # And their indexes — the regression scope of this test:\n+    assert \"idx_tasks_session_id\" in indexes\n+    assert \"idx_tasks_tenant\" in indexes\n+    assert \"idx_tasks_idempotency\" in indexes\n+    assert \"idx_events_run\" in indexes\n+\n+\n # ---------------------------------------------------------------------------\n # Task creation + status inference\n # ---------------------------------------------------------------------------","@@ -462,7 +543,7 @@ def test_detect_crashed_workers_isolated_failure_normal_retry(\n             )\n \n \n-def test_max_runtime_uses_current_run_start_after_retry(kanban_home):\n+def test_max_runtime_uses_current_run_start_after_retry(kanban_home, monkeypatch):\n     \"\"\"A retry should get a fresh max-runtime window.\n \n     ``tasks.started_at`` intentionally records the first time the task ever","@@ -470,6 +551,8 @@ def test_max_runtime_uses_current_run_start_after_retry(kanban_home):\n     ``task_runs.started_at`` row; otherwise every retry of an old task is\n     immediately timed out again.\n     \"\"\"\n+    monkeypatch.setattr(kb, \"_pid_alive\", lambda _pid: False)\n+\n     with kb.connect() as conn:\n         host = kb._claimer_id().split(\":\", 1)[0]\n         t = kb.create_task(\n"]}]},{"kind":"pr","number":28994,"title":"fix(kanban): worker-initiated block must not be auto-promoted (#28712)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/kanban_db.py","tests/hermes_cli/test_kanban_blocked_sticky.py"],"fix_hunks":[{"file":"hermes_cli/kanban_db.py","hunks":["@@ -2002,11 +2002,58 @@ def _synthesize_ended_run(\n # Dependency resolution (todo -> ready)\n # ---------------------------------------------------------------------------\n \n+def _has_sticky_block(conn: sqlite3.Connection, task_id: str) -> bool:\n+    \"\"\"Return True when ``task_id`` is sticky-blocked by an explicit\n+    worker/operator ``kanban_block`` call (#28712).\n+\n+    A ``blocked`` status can come from two very different sources:\n+\n+    * **Worker- or operator-initiated** — a worker called\n+      ``kanban_block(reason=\"review-required: ...\")`` (or somebody ran\n+      ``hermes kanban block <id>``).  This is a deliberate handoff that\n+      should stay blocked until an operator unblocks it.  The block tool\n+      emits a ``\"blocked\"`` event row in ``task_events``.\n+\n+    * **Circuit-breaker** — ``_record_task_failure`` tripped after\n+      repeated crashes / spawn failures / timeouts.  This emits\n+      ``\"gave_up\"``, *not* ``\"blocked\"``, and is meant to recover\n+      automatically once the underlying conditions change (e.g. parents\n+      finish, transient infra error clears).\n+\n+    The cheapest signal that distinguishes the two is the most recent\n+    ``\"blocked\"`` / ``\"unblocked\"`` event for the task.  If the most\n+    recent one is ``\"blocked\"`` (or there is a ``\"blocked\"`` event and\n+    no ``\"unblocked\"`` event has fired since), the task is sticky and\n+    ``recompute_ready`` must *not* auto-promote it.\n+\n+    Returns ``False`` when there is no such event at all (e.g. the task\n+    was set to ``status='blocked'`` by the circuit breaker or by direct\n+    DB manipulation) — preserves the pre-#28712 auto-recover semantics\n+    for that path.\n+    \"\"\"\n+    row = conn.execute(\n+        \"SELECT kind FROM task_events \"\n+        \"WHERE task_id = ? AND kind IN ('blocked', 'unblocked') \"\n+        \"ORDER BY id DESC LIMIT 1\",\n+        (task_id,),\n+    ).fetchone()\n+    return bool(row) and row[\"kind\"] == \"blocked\"\n+\n+\n def recompute_ready(conn: sqlite3.Connection) -> int:\n     \"\"\"Promote ``todo`` tasks to ``ready`` when all parents are ``done`` or ``archived``.\n \n     Returns the number of tasks promoted.  Safe to call inside or outside\n     an existing transaction; it opens its own IMMEDIATE txn.\n+\n+    ``blocked`` tasks are also considered for promotion (so a task\n+    blocked purely by a parent dependency unblocks itself when the\n+    parent completes), *except* when the most recent block event was a\n+    worker-initiated ``kanban_block`` — those stay blocked until an\n+    explicit ``kanban_unblock`` (#28712).  Without that guard, a\n+    ``review-required`` handoff would auto-respawn, the fresh worker\n+    would find nothing to do, exit cleanly, get recorded as a protocol\n+    violation, and the cycle would repeat indefinitely.\n     \"\"\"\n     promoted = 0\n     with write_txn(conn):","@@ -2016,6 +2063,12 @@ def recompute_ready(conn: sqlite3.Connection) -> int:\n         for row in todo_rows:\n             task_id = row[\"id\"]\n             cur_status = row[\"status\"]\n+            if cur_status == \"blocked\" and _has_sticky_block(conn, task_id):\n+                # Worker / operator asked for human review — do not\n+                # silently auto-recover.  ``unblock_task`` is the only\n+                # legitimate exit (it emits ``\"unblocked\"`` which flips\n+                # this predicate back).\n+                continue\n             parents = conn.execute(\n                 \"SELECT t.status FROM tasks t \"\n                 \"JOIN task_links l ON l.parent_id = t.id \"","@@ -2024,7 +2077,8 @@ def recompute_ready(conn: sqlite3.Connection) -> int:\n             ).fetchall()\n             if all(p[\"status\"] in (\"done\", \"archived\") for p in parents):\n                 # Blocked tasks also get their failure counters reset —\n-                # this is effectively an auto-unblock.\n+                # this is effectively an auto-unblock (circuit-breaker\n+                # recovery; worker-initiated blocks are skipped above).\n                 if cur_status == \"blocked\":\n                     conn.execute(\n                         \"UPDATE tasks SET status = 'ready', \""]},{"file":"tests/hermes_cli/test_kanban_blocked_sticky.py","hunks":["@@ -0,0 +1,268 @@\n+\"\"\"Regression tests for #28712 — kanban dispatcher must not auto-promote\n+worker-initiated ``kanban_block`` (sticky blocks), but must keep\n+auto-recovering circuit-breaker blocks.\n+\n+The bug: when a worker called ``kanban_block(reason=\"review-required:\n+...\")`` to hand off to a human, the dispatcher's ``recompute_ready``\n+would promote the task back to ``ready`` on the next tick.  The fresh\n+worker found nothing to do (work already applied), exited cleanly, and\n+got recorded as a ``protocol_violation`` → ``gave_up`` → promote → loop\n+until manual intervention.\n+\n+These tests pin down:\n+\n+* Worker / operator-initiated blocks are sticky and survive\n+  ``recompute_ready``.\n+* Circuit-breaker blocks (``gave_up`` event, status flipped via\n+  ``_record_task_failure``) still auto-recover — the original intent\n+  of #40c1decb3 is preserved.\n+* An explicit ``kanban_unblock`` clears the sticky state.\n+* The full block → promote → crash → ``gave_up`` loop is broken after\n+  this fix: subsequent ticks leave the task blocked.\n+\n+The tangentially related schema-init ordering bug originally reported\n+in #28712 (``init_db`` crashing on legacy DBs that pre-dated the\n+``session_id`` migration) is covered separately by\n+``test_kanban_db.py::test_connect_migrates_legacy_db_before_optional_column_indexes``,\n+landed via #28754 / #28781 ahead of this fix.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import time\n+from pathlib import Path\n+\n+import pytest\n+\n+from hermes_cli import kanban_db as kb\n+\n+\n[redacted:email]\n+def kanban_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:\n+    \"\"\"Isolated HERMES_HOME with an empty kanban DB.\"\"\"\n+    home = tmp_path / \".hermes\"\n+    home.mkdir()\n+    monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+    monkeypatch.setattr(Path, \"home\", lambda: tmp_path)\n+    kb.init_db()\n+    return home\n+\n+\n+# ---------------------------------------------------------------------------\n+# Worker-initiated kanban_block must be sticky\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_worker_block_is_not_auto_promoted_by_recompute_ready(kanban_home: Path) -> None:\n+    \"\"\"A standalone task that a worker explicitly blocks for review\n+    must stay blocked across an arbitrary number of dispatcher ticks.\n+    Before #28712's fix, ``recompute_ready`` would silently flip it\n+    back to ``ready`` on the very next tick.\"\"\"\n+    with kb.connect() as conn:\n+        tid = kb.create_task(conn, title=\"needs human review\")\n+        kb.claim_task(conn, tid)\n+        assert kb.block_task(\n+            conn, tid,\n+            reason=\"review-required: please verify ACL change\",\n+            expected_run_id=kb.get_task(conn, tid).current_run_id,\n+        )\n+        assert kb.get_task(conn, tid).status == \"blocked\"\n+\n+        # Hammer the promotion code — exactly the dispatcher loop's\n+        # behaviour, just compressed in time.\n+        for _ in range(5):\n+            promoted = kb.recompute_ready(conn)\n+            assert promoted == 0, \"worker-blocked task must not auto-promote\"\n+            assert kb.get_task(conn, tid).status == \"blocked\"\n+\n+\n+def test_worker_block_on_child_with_done_parents_is_still_sticky(kanban_home: Path) -> None:\n+    \"\"\"The parent-completion path is the one ``recompute_ready`` was\n+    designed for, so it's the most dangerous false-positive: even when\n+    every parent is done, a worker-initiated block on the child must\n+    stay blocked.\"\"\"\n+    with kb.connect() as conn:\n+        parent = kb.create_task(conn, title=\"parent\")\n+        child = kb.create_task(conn, title=\"child\", parents=[parent])\n+        kb.complete_task(conn, parent, result=\"parent ok\")\n+\n+        kb.claim_task(conn, child)\n+        kb.block_task(\n+            conn, child,\n+            reason=\"review-required: child needs sign-off\",\n+            expected_run_id=kb.get_task(conn, child).current_run_id,\n+        )\n+        assert kb.get_task(conn, child).status == \"blocked\"\n+\n+        promoted = kb.recompute_ready(conn)\n+        assert promoted == 0\n+        assert kb.get_task(conn, child).status == \"blocked\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Circuit-breaker blocks still auto-recover (preserve #40c1decb3 intent)\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_circuit_breaker_block_still_auto_promotes(kanban_home: Path) -> None:\n+    \"\"\"A child that was put into ``blocked`` *without* a worker-issued\n+    ``kanban_block`` (e.g. circuit-breaker after repeated spawn\n+    failures, manual DB triage) must still get auto-promoted when its\n+    parents complete — preserves the pre-#28712 recovery semantics.\"\"\"\n+    with kb.connect() as conn:\n+        parent = kb.create_task(conn, title=\"parent\")\n+        child = kb.create_task(conn, title=\"child\", parents=[parent])\n+        kb.complete_task(conn, parent, result=\"ok\")\n+\n+        # Simulate a circuit-breaker / direct triage that flips status\n+        # without emitting a ``blocked`` event — exactly what\n+        # ``_record_task_failure`` does after a ``gave_up``.\n+        conn.execute(\n+            \"UPDATE tasks SET status='blocked', consecutive_failures=5, \"\n+            \"last_failure_error='persistent error' WHERE id=?\",\n+            (child,),\n+        )\n+        conn.commit()\n+\n+        promoted = kb.recompute_ready(conn)\n+        assert promoted == 1\n+        task = kb.get_task(conn, child)\n+        assert task.status == \"ready\"\n+        assert task.consecutive_failures == 0\n+        assert task.last_failure_error is None\n+\n+\n+def test_gave_up_event_alone_does_not_make_block_sticky(kanban_home: Path) -> None:\n+    \"\"\"The circuit-breaker emits ``gave_up`` (not ``blocked``).  Make\n+    sure ``_has_sticky_block`` doesn't accidentally treat ``gave_up``\n+    as sticky — otherwise we'd regress the safety net for genuinely\n+    transient crashes.\"\"\"\n+    with kb.connect() as conn:\n+        parent = kb.create_task(conn, title=\"parent\")\n+        child = kb.create_task(conn, title=\"child\", parents=[parent])\n+        kb.complete_task(conn, parent, result=\"ok\")\n+\n+        # Status + event match what _record_task_failure writes when\n+        # the breaker trips.\n+        conn.execute(\n+            \"UPDATE tasks SET status='blocked' WHERE id=?\", (child,),\n+        )\n+        conn.execute(\n+            \"INSERT INTO task_events (task_id, kind, payload, created_at) \"\n+            \"VALUES (?, 'gave_up', NULL, ?)\",\n+            (child, int(time.time())),\n+        )\n+        conn.commit()\n+\n+        promoted = kb.recompute_ready(conn)\n+        assert promoted == 1\n+        assert kb.get_task(conn, child).status == \"ready\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# unblock_task clears the sticky state\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_unblock_clears_sticky_state_and_lets_block_recover(kanban_home: Path) -> None:\n+    \"\"\"``hermes kanban unblock`` (or the ``kanban_unblock`` tool) is\n+    the only legitimate way out of a worker-initiated block.  After\n+    unblock, a *subsequent* circuit-breaker block on the same task\n+    must again be eligible for auto-recovery.\"\"\"\n+    with kb.connect() as conn:\n+        tid = kb.create_task(conn, title=\"t\")\n+        kb.claim_task(conn, tid)\n+        kb.block_task(\n+            conn, tid,\n+            reason=\"review-required: ...\",\n+            expected_run_id=kb.get_task(conn, tid).current_run_id,\n+        )\n+        assert kb.unblock_task(conn, tid)\n+        # After unblock the task is no longer blocked at all.\n+        assert kb.get_task(conn, tid).status == \"ready\"\n+\n+        # Now simulate a *later* circuit-breaker block (no new\n+        # ``blocked`` event, just status flip).  The most recent\n+        # block/unblock event is ``unblocked`` → guard does not fire\n+        # → recompute can recover.\n+        conn.execute(\n+            \"UPDATE tasks SET status='blocked' WHERE id=?\", (tid,),\n+        )\n+        conn.commit()\n+\n+        promoted = kb.recompute_ready(conn)\n+        assert promoted == 1\n+        assert kb.get_task(conn, tid).status == \"ready\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Full bug-shaped loop: block → promote → crash → gave_up → next tick\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_protocol_violation_loop_is_broken(kanban_home: Path) -> None:\n+    \"\"\"Reproduces the exact #28712 loop and asserts the dispatcher\n+    leaves the task blocked instead of cycling.\n+\n+    Loop shape from the issue:\n+\n+    1. Worker calls ``kanban_block`` → status='blocked',\n+       ``task_runs.outcome='blocked'``, ``blocked`` event.\n+    2. (Bug) Dispatcher promotes back to ``ready``.\n+    3. Fresh worker exits cleanly without terminal tool call →\n+       ``protocol_violation`` event.\n+    4. ``_record_task_failure(failure_limit=1)`` → ``gave_up`` event,\n+       status='blocked' again.\n+    5. (Bug) Dispatcher promotes again → infinite loop.\n+\n+    With the fix in place, step 2 never happens — the test simulates\n+    one would-be loop cycle by faking the crash-then-gave_up entries\n+    that *would* have been written and asserts the *next* tick still\n+    leaves the task blocked.\n+    \"\"\"\n+    with kb.connect() as conn:\n+        tid = kb.create_task(conn, title=\"loop reproducer\")\n+        kb.claim_task(conn, tid)\n+        kb.block_task(\n+            conn, tid,\n+            reason=\"review-required: human eyes please\",\n+            expected_run_id=kb.get_task(conn, tid).current_run_id,\n+        )\n+        assert kb.get_task(conn, tid).status == \"blocked\"\n+\n+        # First dispatcher tick — must NOT promote.\n+        assert kb.recompute_ready(conn) == 0\n+        assert kb.get_task(conn, tid).status == \"blocked\"\n+\n+        # Simulate the (hypothetical) protocol_violation + gave_up\n+        # entries that the dispatcher would have written if the bug\n+        # were still present.  Even with those event rows in place,\n+        # the worker-initiated ``blocked`` event is the most recent\n+        # of the ``{blocked, unblocked}`` pair, so the sticky guard\n+        # still fires.\n+        now = int(time.time())\n+        conn.execute(\n+            \"INSERT INTO task_events (task_id, kind, payload, created_at) \"\n+            \"VALUES (?, 'protocol_violation', NULL, ?)\",\n+            (tid, now),\n+        )\n+        conn.execute(\n+            \"INSERT INTO task_events (task_id, kind, payload, created_at) \"\n+            \"VALUES (?, 'gave_up', NULL, ?)\",\n+            (tid, now + 1),\n+        )\n+        conn.commit()\n+\n+        # Subsequent ticks must still leave it blocked.\n+        for _ in range(3):\n+            promoted = kb.recompute_ready(conn)\n+            assert promoted == 0\n+            assert kb.get_task(conn, tid).status == \"blocked\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Schema-init recovery on legacy DBs is covered by\n+# tests/hermes_cli/test_kanban_db.py::test_connect_migrates_legacy_db_before_optional_column_indexes\n+# (landed via #28754 / #28781).  The original PR shipped a duplicate test\n+# here; dropped during salvage to avoid two assertions of the same contract.\n+# ---------------------------------------------------------------------------\n"]}]},{"kind":"pr","number":29328,"title":"fix(kanban): keep gave_up tasks blocked","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"8e5a6854c3bf081c46df2600775f14bbbde9cc2d","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":34698,"title":"fix(kanban): store sticky-block flag on tasks row to fix cross-connection WAL visibility (#34689)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"dc235e93c","number":0},{"kind":"pr","value":"#34689","number":34689}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":34735,"title":"fix(kanban): emit blocked event when initial_status='blocked' so sticky-block guard fires","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ad0398eed8","number":0},{"kind":"pr","value":"#39609","number":39609}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":39832,"title":"fix(kanban): keep initial blocked tasks sticky","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34735","number":34735}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":44595,"title":"[verified] fix: keep protocol gave_up blocks sticky","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":46274,"title":"fix(kanban): preserve initial blocked approval gates","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":48437,"title":"fix(kanban): emit blocked event for initial_status=\"blocked\" tasks (fixes #47777)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#47777","number":47777},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#46565","number":46565},{"kind":"pr","value":"#34735","number":34735}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":69531,"title":"fix(kanban): enforce auditable authorization for human gates","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"a767d362d7ab","number":0},{"kind":"pr","value":"#27526","number":27526},{"kind":"pr","value":"#28414","number":28414},{"kind":"pr","value":"#28414","number":28414},{"kind":"pr","value":"#32581","number":32581},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#34756","number":34756},{"kind":"pr","value":"#35832","number":35832},{"kind":"pr","value":"#39085","number":39085},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#39832","number":39832},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#41497","number":41497},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#43479","number":43479},{"kind":"pr","value":"#46274","number":46274},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#46565","number":46565},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#48253","number":48253},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#48437","number":48437},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#60100","number":60100},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#61890","number":61890},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#64830","number":64830},{"kind":"pr","value":"#66993","number":66993},{"kind":"pr","value":"#69531","number":69531},{"kind":"pr","value":"#39609","number":39609},{"kind":"pr","value":"#69679","number":69679},{"kind":"pr","value":"#70733","number":70733},{"kind":"pr","value":"#71147","number":71147},{"kind":"pr","value":"#71861","number":71861},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#71977","number":71977},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#72030","number":72030},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#74839","number":74839},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#76718","number":76718},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#76765","number":76765},{"kind":"pr","value":"#39085","number":39085},{"kind":"pr","value":"#41497","number":41497},{"kind":"pr","value":"#46274","number":46274},{"kind":"pr","value":"#46565","number":46565},{"kind":"pr","value":"#48437","number":48437},{"kind":"pr","value":"#61890","number":61890},{"kind":"pr","value":"#71861","number":71861},{"kind":"pr","value":"#71977","number":71977},{"kind":"pr","value":"#72030","number":72030},{"kind":"pr","value":"#76718","number":76718},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#39832","number":39832},{"kind":"pr","value":"#48253","number":48253},{"kind":"pr","value":"#64830","number":64830},{"kind":"pr","value":"#35832","number":35832},{"kind":"pr","value":"#66993","number":66993},{"kind":"pr","value":"#69679","number":69679},{"kind":"pr","value":"#70733","number":70733},{"kind":"pr","value":"#71147","number":71147},{"kind":"pr","value":"#69531","number":69531},{"kind":"pr","value":"#74839","number":74839},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#39085","number":39085},{"kind":"pr","value":"#41497","number":41497},{"kind":"pr","value":"#46565","number":46565},{"kind":"pr","value":"#64830","number":64830},{"kind":"pr","value":"#71861","number":71861},{"kind":"pr","value":"#71977","number":71977},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#69531","number":69531},{"kind":"pr","value":"#69679","number":69679},{"kind":"pr","value":"#34756","number":34756},{"kind":"pr","value":"#35832","number":35832},{"kind":"pr","value":"#66993","number":66993},{"kind":"pr","value":"#70733","number":70733},{"kind":"pr","value":"#71147","number":71147},{"kind":"pr","value":"#76765","number":76765},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#39609","number":39609},{"kind":"pr","value":"#47777","number":47777},{"kind":"pr","value":"#69531","number":69531}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":88686,"title":"fix(kanban): keep initially blocked tasks sticky","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ad0398eed8","number":0},{"kind":"pr","value":"#39609","number":39609},{"kind":"pr","value":"#34735","number":34735}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":107408,"title":"fix(kanban): hold initial_status=blocked cards when parents finish","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#34735","number":34735},{"kind":"pr","value":"#107398","number":107398},{"kind":"pr","value":"#39609","number":39609}],"category":"superseded_by_pr","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}