# NousResearch/hermes-agent · Kanban blocked tasks auto-promoted when they should stay blocked

## Open now

- 1 issue, 2 PRs open on GitHub.
- #47777: 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)
- PR #44595: \[verified\] fix: keep protocol gave_up blocks sticky
- PR #69531: fix(kanban): enforce auditable authorization for human gates

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.

## TL;DR

- 22 member(s): 1 open issue(s), 8 closed issue(s), 2 open PR(s), 2 merged PR(s), 9 PR(s) closed without merging.
- A fix was merged to main in PR #28781. Verify it addresses your case.
- **Recommended:** read PR #28781 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-28994`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.08`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #47777: 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)

## Closed / duplicate issues

- #28712: kanban: dispatcher auto-promotes blocked task → respawn worker → protocol_violation loop
- #28903: \[Bug\]: Kanban auto-unblock is too eager
- #28944: Kanban: blocked tasks auto-promoted by daemon without explicit human unblock
- #29014: Kanban dispatcher repeatedly respawns blocked/manual-gate tasks, causing provider quota drain
- #34689: Kanban _has_sticky_block guard ineffective in cross-connection WAL-mode production (recurrence of #28712 loop)
- #37689: Circuit-breaker (gave_up) blocks are auto-resurrected by recompute_ready → deterministic-failure tasks loop forever
- #39609: \[Bug\]: Tasks created with --initial-status blocked auto-promote to ready ~1s later with no actor — human approval gate bypassed
- #62828: Circuit breaker defeated: recompute_ready re-promotes tasks after gave_up

## Prior & competing PRs

- **#28781: fix(kanban): hoist all additive-column indexes out of SCHEMA_SQL (#28461)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/kanban_db.py`, `tests/hermes_cli/test_kanban_db.py`
  - fix hunks:
  - `hermes_cli/kanban_db.py`

    ```diff
    @@ -865,8 +865,6 @@ class Event:
         session_id           TEXT
     );
     
    -CREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id);
    -
     CREATE TABLE IF NOT EXISTS task_links (
         parent_id  TEXT NOT NULL,
         child_id   TEXT NOT NULL,
    ```

    ```diff
    @@ -937,13 +935,10 @@ class Event:
     
     CREATE INDEX IF NOT EXISTS idx_tasks_assignee_status ON tasks(assignee, status);
     CREATE INDEX IF NOT EXISTS idx_tasks_status          ON tasks(status);
    -CREATE INDEX IF NOT EXISTS idx_tasks_tenant          ON tasks(tenant);
    -CREATE INDEX IF NOT EXISTS idx_tasks_idempotency     ON tasks(idempotency_key);
     CREATE INDEX IF NOT EXISTS idx_links_child           ON task_links(child_id);
     CREATE INDEX IF NOT EXISTS idx_links_parent          ON task_links(parent_id);
     CREATE INDEX IF NOT EXISTS idx_comments_task         ON task_comments(task_id, created_at);
     CREATE INDEX IF NOT EXISTS idx_events_task           ON task_events(task_id, created_at);
    -CREATE INDEX IF NOT EXISTS idx_events_run            ON task_events(run_id, id);
     CREATE INDEX IF NOT EXISTS idx_runs_task             ON task_runs(task_id, started_at);
     CREATE INDEX IF NOT EXISTS idx_runs_status           ON task_runs(status);
     CREATE INDEX IF NOT EXISTS idx_notify_task           ON kanban_notify_subs(task_id);
    ```

    ```diff
    @@ -1083,10 +1078,9 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
             _add_column_if_missing(
                 conn, "tasks", "idempotency_key", "idempotency_key TEXT"
             )
    -        conn.execute(
    -            "CREATE INDEX IF NOT EXISTS idx_tasks_idempotency "
    -            "ON tasks(idempotency_key)"
    -        )
    +    # ``idx_tasks_idempotency`` is created unconditionally below alongside
    +    # the other additive-column indexes — see the block after the
    +    # legacy-column migration. Creating it here too would be redundant.
     
         # Refresh after early additive migrations above. Some existing DBs were
         # partially migrated in older releases and can already contain the later
    ```

    ```diff
    @@ -1170,24 +1164,38 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
             # created from within an agent loop that propagated
             # ``HERMES_SESSION_ID`` (e.g. ACP). NULL on legacy rows and on any
             # creation path that doesn't set the env var (CLI, dashboard).
    -        # Index keeps per-session list queries cheap.
             _add_column_if_missing(
                 conn, "tasks", "session_id", "session_id TEXT"
             )
    -        conn.execute(
    -            "CREATE INDEX IF NOT EXISTS idx_tasks_session_id "
    -            "ON tasks(session_id)"
    -        )
    +
    +    # Indexes over additive ``tasks`` columns must be created after the
    +    # columns exist. Keeping them in SCHEMA_SQL breaks legacy boards: SQLite
    +    # parses each statement in ``executescript`` against the live schema, so a
    +    # ``CREATE INDEX`` over a missing column aborts initialization before the
    +    # additive ``ALTER TABLE`` migrations below can run. Re-running them here
    +    # is cheap thanks to ``IF NOT EXISTS`` and stays correct on fresh DBs
    +    # (where the columns already exist from SCHEMA_SQL).
    +    conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_tenant ON tasks(tenant)")
    +    conn.execute(
    +        "CREATE INDEX IF NOT EXISTS idx_tasks_idempotency ON tasks(idempotency_key)"
    +    )
    +    conn.execute(
    +        "CREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id)"
    +    )
     
         # task_events gained a run_id column; back-fill it as NULL for
         # historical events (they predate runs and can't be attributed).
         ev_cols = {row["name"] for row in conn.execute("PRAGMA table_info(task_events)")}
         if "run_id" not in ev_cols:
             _add_column_if_missing(conn, "task_events", "run_id", "run_id INTEGER")
    -        conn.execute(
    -            "CREATE INDEX IF NOT EXISTS idx_events_run "
    -            "ON task_events(run_id, id)"
    -        )
    +
    +    # Same ordering rule as the additive ``tasks`` indexes above: create the
    +    # index after the additive column migration so legacy ``task_events``
    +    # tables don't fail during SCHEMA_SQL execution before ``run_id`` exists.
    +    conn.execute(
    +        "CREATE INDEX IF NOT EXISTS idx_events_run "
    +        "ON task_events(run_id, id)"
    +    )
     
         notify_table_exists = conn.execute(
             "SELECT name FROM sqlite_master WHERE type='table' AND name='kanban_notify_subs'"
    ```
  - `tests/hermes_cli/test_kanban_db.py`

    ```diff
    @@ -48,6 +48,87 @@ def test_init_creates_expected_tables(kanban_home):
         assert {"tasks", "task_links", "task_comments", "task_events"} <= names
     
     
    +def test_connect_migrates_legacy_db_before_optional_column_indexes(tmp_path):
    +    """Legacy DBs missing additive indexed columns must migrate cleanly.
    +
    +    SCHEMA_SQL runs in ``connect()`` before ``_migrate_add_optional_columns``.
    +    Indexes over additive columns therefore must be created after the
    +    migration adds those columns, or boards predating the column fail to
    +    open before migration can run.
    +
    +    Covers all four indexes that sit on additive columns:
    +    - ``tasks.session_id``       -> ``idx_tasks_session_id``    (#28447)
    +    - ``tasks.tenant``           -> ``idx_tasks_tenant``        (#16081)
    +    - ``tasks.idempotency_key``  -> ``idx_tasks_idempotency``   (#17805)
    +    - ``task_events.run_id``     -> ``idx_events_run``          (#17805)
    +    """
    +    db_path = tmp_path / "legacy-kanban.db"
    +    conn = sqlite3.connect(str(db_path))
    +    # Pre-#16081 ``tasks`` shape: missing tenant, idempotency_key, session_id.
    +    conn.execute("""
    +        CREATE TABLE tasks (
    +            id TEXT PRIMARY KEY,
    +            title TEXT NOT NULL,
    +            body TEXT,
    +            assignee TEXT,
    +            status TEXT NOT NULL,
    +            priority INTEGER NOT NULL DEFAULT 0,
    +            created_by TEXT,
    +            created_at INTEGER NOT NULL,
    +            started_at INTEGER,
    +            completed_at INTEGER,
    +            workspace_kind TEXT NOT NULL DEFAULT 'scratch',
    +            workspace_path TEXT,
    +            claim_lock TEXT,
    +            claim_expires INTEGER
    +        )
    +    """)
    +    # Pre-#17805 ``task_events`` shape: missing run_id. Required because
    +    # ``_migrate_add_optional_columns`` unconditionally runs PRAGMA on
    +    # ``task_events`` for run_id back-fill.
    +    conn.execute("""
    +        CREATE TABLE task_events (
    +            id INTEGER PRIMARY KEY AUTOINCREMENT,
    +            task_id TEXT NOT NULL,
    +            kind TEXT NOT NULL,
    +            payload TEXT,
    +            created_at INTEGER NOT NULL
    +        )
    +    """)
    +    conn.execute(
    +        "INSERT INTO tasks (id, title, status, created_at) "
    +        "VALUES ('legacy', 'old board task', 'ready', 1)"
    +    )
    +    conn.commit()
    +    conn.close()
    +
    +    with kb.connect(db_path) as migrated:
    +        task_columns = {
    +            row["name"] for row in migrated.execute("PRAGMA table_info(tasks)")
    +        }
    +        event_columns = {
    +            row["name"]
    +            for row in migrated.execute("PRAGMA table_info(task_events)")
    +        }
    +        indexes = {
    +            row["name"]
    +            for row in migrated.execute(
    +                "SELECT name FROM sqlite_master WHERE type = 'index'"
    +            )
    +        }
    +
    +    # Additive columns added by migration:
    +    assert "session_id" in task_columns
    +    assert "tenant" in task_columns
    +    assert "idempotency_key" in task_columns
    +    assert "run_id" in event_columns
    +    # And their indexes — the regression scope of this test:
    +    assert "idx_tasks_session_id" in indexes
    +    assert "idx_tasks_tenant" in indexes
    +    assert "idx_tasks_idempotency" in indexes
    +    assert "idx_events_run" in indexes
    +
    +
     # ---------------------------------------------------------------------------
     # Task creation + status inference
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -462,7 +543,7 @@ def test_detect_crashed_workers_isolated_failure_normal_retry(
                 )
     
     
    -def test_max_runtime_uses_current_run_start_after_retry(kanban_home):
    +def test_max_runtime_uses_current_run_start_after_retry(kanban_home, monkeypatch):
         """A retry should get a fresh max-runtime window.
     
         ``tasks.started_at`` intentionally records the first time the task ever
    ```

    ```diff
    @@ -470,6 +551,8 @@ def test_max_runtime_uses_current_run_start_after_retry(kanban_home):
         ``task_runs.started_at`` row; otherwise every retry of an old task is
         immediately timed out again.
         """
    +    monkeypatch.setattr(kb, "_pid_alive", lambda _pid: False)
    +
         with kb.connect() as conn:
             host = kb._claimer_id().split(":", 1)[0]
             t = kb.create_task(
    ```
- **#28994: fix(kanban): worker-initiated block must not be auto-promoted (#28712)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/kanban_db.py`, `tests/hermes_cli/test_kanban_blocked_sticky.py`
  - fix hunks:
  - `hermes_cli/kanban_db.py`

    ```diff
    @@ -2002,11 +2002,58 @@ def _synthesize_ended_run(
     # Dependency resolution (todo -> ready)
     # ---------------------------------------------------------------------------
     
    +def _has_sticky_block(conn: sqlite3.Connection, task_id: str) -> bool:
    +    """Return True when ``task_id`` is sticky-blocked by an explicit
    +    worker/operator ``kanban_block`` call (#28712).
    +
    +    A ``blocked`` status can come from two very different sources:
    +
    +    * **Worker- or operator-initiated** — a worker called
    +      ``kanban_block(reason="review-required: ...")`` (or somebody ran
    +      ``hermes kanban block <id>``).  This is a deliberate handoff that
    +      should stay blocked until an operator unblocks it.  The block tool
    +      emits a ``"blocked"`` event row in ``task_events``.
    +
    +    * **Circuit-breaker** — ``_record_task_failure`` tripped after
    +      repeated crashes / spawn failures / timeouts.  This emits
    +      ``"gave_up"``, *not* ``"blocked"``, and is meant to recover
    +      automatically once the underlying conditions change (e.g. parents
    +      finish, transient infra error clears).
    +
    +    The cheapest signal that distinguishes the two is the most recent
    +    ``"blocked"`` / ``"unblocked"`` event for the task.  If the most
    +    recent one is ``"blocked"`` (or there is a ``"blocked"`` event and
    +    no ``"unblocked"`` event has fired since), the task is sticky and
    +    ``recompute_ready`` must *not* auto-promote it.
    +
    +    Returns ``False`` when there is no such event at all (e.g. the task
    +    was set to ``status='blocked'`` by the circuit breaker or by direct
    +    DB manipulation) — preserves the pre-#28712 auto-recover semantics
    +    for that path.
    +    """
    +    row = conn.execute(
    +        "SELECT kind FROM task_events "
    +        "WHERE task_id = ? AND kind IN ('blocked', 'unblocked') "
    +        "ORDER BY id DESC LIMIT 1",
    +        (task_id,),
    +    ).fetchone()
    +    return bool(row) and row["kind"] == "blocked"
    +
    +
     def recompute_ready(conn: sqlite3.Connection) -> int:
         """Promote ``todo`` tasks to ``ready`` when all parents are ``done`` or ``archived``.
     
         Returns the number of tasks promoted.  Safe to call inside or outside
         an existing transaction; it opens its own IMMEDIATE txn.
    +
    +    ``blocked`` tasks are also considered for promotion (so a task
    +    blocked purely by a parent dependency unblocks itself when the
    +    parent completes), *except* when the most recent block event was a
    +    worker-initiated ``kanban_block`` — those stay blocked until an
    +    explicit ``kanban_unblock`` (#28712).  Without that guard, a
    +    ``review-required`` handoff would auto-respawn, the fresh worker
    +    would find nothing to do, exit cleanly, get recorded as a protocol
    +    violation, and the cycle would repeat indefinitely.
         """
         promoted = 0
         with write_txn(conn):
    ```

    ```diff
    @@ -2016,6 +2063,12 @@ def recompute_ready(conn: sqlite3.Connection) -> int:
             for row in todo_rows:
                 task_id = row["id"]
                 cur_status = row["status"]
    +            if cur_status == "blocked" and _has_sticky_block(conn, task_id):
    +                # Worker / operator asked for human review — do not
    +                # silently auto-recover.  ``unblock_task`` is the only
    +                # legitimate exit (it emits ``"unblocked"`` which flips
    +                # this predicate back).
    +                continue
                 parents = conn.execute(
                     "SELECT t.status FROM tasks t "
                     "JOIN task_links l ON l.parent_id = t.id "
    ```

    ```diff
    @@ -2024,7 +2077,8 @@ def recompute_ready(conn: sqlite3.Connection) -> int:
                 ).fetchall()
                 if all(p["status"] in ("done", "archived") for p in parents):
                     # Blocked tasks also get their failure counters reset —
    -                # this is effectively an auto-unblock.
    +                # this is effectively an auto-unblock (circuit-breaker
    +                # recovery; worker-initiated blocks are skipped above).
                     if cur_status == "blocked":
                         conn.execute(
                             "UPDATE tasks SET status = 'ready', "
    ```
  - `tests/hermes_cli/test_kanban_blocked_sticky.py`

    ```diff
    @@ -0,0 +1,268 @@
    +"""Regression tests for #28712 — kanban dispatcher must not auto-promote
    +worker-initiated ``kanban_block`` (sticky blocks), but must keep
    +auto-recovering circuit-breaker blocks.
    +
    +The bug: when a worker called ``kanban_block(reason="review-required:
    +...")`` to hand off to a human, the dispatcher's ``recompute_ready``
    +would promote the task back to ``ready`` on the next tick.  The fresh
    +worker found nothing to do (work already applied), exited cleanly, and
    +got recorded as a ``protocol_violation`` → ``gave_up`` → promote → loop
    +until manual intervention.
    +
    +These tests pin down:
    +
    +* Worker / operator-initiated blocks are sticky and survive
    +  ``recompute_ready``.
    +* Circuit-breaker blocks (``gave_up`` event, status flipped via
    +  ``_record_task_failure``) still auto-recover — the original intent
    +  of #40c1decb3 is preserved.
    +* An explicit ``kanban_unblock`` clears the sticky state.
    +* The full block → promote → crash → ``gave_up`` loop is broken after
    +  this fix: subsequent ticks leave the task blocked.
    +
    +The tangentially related schema-init ordering bug originally reported
    +in #28712 (``init_db`` crashing on legacy DBs that pre-dated the
    +``session_id`` migration) is covered separately by
    +``test_kanban_db.py::test_connect_migrates_legacy_db_before_optional_column_indexes``,
    +landed via #28754 / #28781 ahead of this fix.
    +"""
    +
    +from __future__ import annotations
    +
    +import time
    +from pathlib import Path
    +
    +import pytest
    +
    +from hermes_cli import kanban_db as kb
    +
    +
    [redacted:email]
    +def kanban_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    +    """Isolated HERMES_HOME with an empty kanban DB."""
    +    home = tmp_path / ".hermes"
    +    home.mkdir()
    +    monkeypatch.setenv("HERMES_HOME", str(home))
    +    monkeypatch.setattr(Path, "home", lambda: tmp_path)
    +    kb.init_db()
    +    return home
    +
    +
    +# ---------------------------------------------------------------------------
    +# Worker-initiated kanban_block must be sticky
    +# ---------------------------------------------------------------------------
    +
    +
    +def test_worker_block_is_not_auto_promoted_by_recompute_ready(kanban_home: Path) -> None:
    +    """A standalone task that a worker explicitly blocks for review
    +    must stay blocked across an arbitrary number of dispatcher ticks.
    +    Before #28712's fix, ``recompute_ready`` would silently flip it
    +    back to ``ready`` on the very next tick."""
    +    with kb.connect() as conn:
    +        tid = kb.create_task(conn, title="needs human review")
    +        kb.claim_task(conn, tid)
    +        assert kb.block_task(
    +            conn, tid,
    +            reason="review-required: please verify ACL change",
    +            expected_run_id=kb.get_task(conn, tid).current_run_id,
    +        )
    +        assert kb.get_task(conn, tid).status == "blocked"
    +
    +        # Hammer the promotion code — exactly the dispatcher loop's
    +        # behaviour, just compressed in time.
    +        for _ in range(5):
    +            promoted = kb.recompute_ready(conn)
    +            assert promoted == 0, "worker-blocked task must not auto-promote"
    +            assert kb.get_task(conn, tid).status == "blocked"
    +
    +
    +def test_worker_block_on_child_with_done_parents_is_still_sticky(kanban_home: Path) -> None:
    +    """The parent-completion path is the one ``recompute_ready`` was
    +    designed for, so it's the most dangerous false-positive: even when
    +    every parent is done, a worker-initiated block on the child must
    +    stay blocked."""
    +    with kb.connect() as conn:
    +        parent = kb.create_task(conn, title="parent")
    +        child = kb.create_task(conn, title="child", parents=[parent])
    +        kb.complete_task(conn, parent, result="parent ok")
    +
    +        kb.claim_task(conn, child)
    +        kb.block_task(
    +            conn, child,
    +            reason="review-required: child needs sign-off",
    +            expected_run_id=kb.get_task(conn, child).current_run_id,
    +        )
    +        assert kb.get_task(conn, child).status == "blocked"
    +
    +        promoted = kb.recompute_ready(conn)
    +        assert promoted == 0
    +        assert kb.get_task(conn, child).status == "blocked"
    +
    +
    +# ---------------------------------------------------------------------------
    +# Circuit-breaker blocks still auto-recover (preserve #40c1decb3 intent)
    +# ---------------------------------------------------------------------------
    +
    +
    +def test_circuit_breaker_block_still_auto_promotes(kanban_home: Path) -> None:
    +    """A child that was put into ``blocked`` *without* a worker-issued
    +    ``kanban_block`` (e.g. circuit-breaker after repeated spawn
    +    failures, manual DB triage) must still get auto-promoted when its
    +    parents complete — preserves the pre-#28712 recovery semantics."""
    +    with kb.connect() as conn:
    +        parent = kb.create_task(conn, title="parent")
    +        child = kb.create_task(conn, title="child", parents=[parent])
    +        kb.complete_task(conn, parent, result="ok")
    +
    +        # Simulate a circuit-breaker / direct triage that flips status
    +        # without emitting a ``blocked`` event — exactly what
    +        # ``_record_task_failure`` does after a ``gave_up``.
    +        conn.execute(
    +            "UPDATE tasks SET status='blocked', consecutive_failures=5, "
    +            "last_failure_error='persistent error' WHERE id=?",
    +            (child,),
    +        )
    +        conn.commit()
    +
    +        promoted = kb.recompute_ready(conn)
    +        assert promoted == 1
    +        task = kb.get_task(conn, child)
    +        assert task.status == "ready"
    +        assert task.consecutive_failures == 0
    +        assert task.last_failure_error is None
    +
    +
    +def test_gave_up_event_alone_does_not_make_block_sticky(kanban_home: Path) -> None:
    +    """The circuit-breaker emits ``gave_up`` (not ``blocked``).  Make
    +    sure ``_has_sticky_block`` doesn't accidentally treat ``gave_up``
    +    as sticky — otherwise we'd regress the safety net for genuinely
    +    transient crashes."""
    +    with kb.connect() as conn:
    +        parent = kb.create_task(conn, title="parent")
    +        child = kb.create_task(conn, title="child", parents=[parent])
    +        kb.complete_task(conn, parent, result="ok")
    +
    +        # Status + event match what _record_task_failure writes when
    +        # the breaker trips.
    +        conn.execute(
    +            "UPDATE tasks SET status='blocked' WHERE id=?", (child,),
    +        )
    +        conn.execute(
    +            "INSERT INTO task_events (task_id, kind, payload, created_at) "
    +            "VALUES (?, 'gave_up', NULL, ?)",
    +            (child, int(time.time())),
    +        )
    +        conn.commit()
    +
    +        promoted = kb.recompute_ready(conn)
    +        assert promoted == 1
    +        assert kb.get_task(conn, child).status == "ready"
    +
    +
    +# ---------------------------------------------------------------------------
    +# unblock_task clears the sticky state
    +# ---------------------------------------------------------------------------
    +
    +
    +def test_unblock_clears_sticky_state_and_lets_block_recover(kanban_home: Path) -> None:
    +    """``hermes kanban unblock`` (or the ``kanban_unblock`` tool) is
    +    the only legitimate way out of a worker-initiated block.  After
    +    unblock, a *subsequent* circuit-breaker block on the same task
    +    must again be eligible for auto-recovery."""
    +    with kb.connect() as conn:
    +        tid = kb.create_task(conn, title="t")
    +        kb.claim_task(conn, tid)
    +        kb.block_task(
    +            conn, tid,
    +            reason="review-required: ...",
    +            expected_run_id=kb.get_task(conn, tid).current_run_id,
    +        )
    +        assert kb.unblock_task(conn, tid)
    +        # After unblock the task is no longer blocked at all.
    +        assert kb.get_task(conn, tid).status == "ready"
    +
    +        # Now simulate a *later* circuit-breaker block (no new
    +        # ``blocked`` event, just status flip).  The most recent
    +        # block/unblock event is ``unblocked`` → guard does not fire
    +        # → recompute can recover.
    +        conn.execute(
    +            "UPDATE tasks SET status='blocked' WHERE id=?", (tid,),
    +        )
    +        conn.commit()
    +
    +        promoted = kb.recompute_ready(conn)
    +        assert promoted == 1
    +        assert kb.get_task(conn, tid).status == "ready"
    +
    +
    +# ---------------------------------------------------------------------------
    +# Full bug-shaped loop: block → promote → crash → gave_up → next tick
    +# ---------------------------------------------------------------------------
    +
    +
    +def test_protocol_violation_loop_is_broken(kanban_home: Path) -> None:
    +    """Reproduces the exact #28712 loop and asserts the dispatcher
    +    leaves the task blocked instead of cycling.
    +
    +    Loop shape from the issue:
    +
    +    1. Worker calls ``kanban_block`` → status='blocked',
    +       ``task_runs.outcome='blocked'``, ``blocked`` event.
    +    2. (Bug) Dispatcher promotes back to ``ready``.
    +    3. Fresh worker exits cleanly without terminal tool call →
    +       ``protocol_violation`` event.
    +    4. ``_record_task_failure(failure_limit=1)`` → ``gave_up`` event,
    +       status='blocked' again.
    +    5. (Bug) Dispatcher promotes again → infinite loop.
    +
    +    With the fix in place, step 2 never happens — the test simulates
    +    one would-be loop cycle by faking the crash-then-gave_up entries
    +    that *would* have been written and asserts the *next* tick still
    +    leaves the task blocked.
    +    """
    +    with kb.connect() as conn:
    +        tid = kb.create_task(conn, title="loop reproducer")
    +        kb.claim_task(conn, tid)
    +        kb.block_task(
    +            conn, tid,
    +            reason="review-required: human eyes please",
    +            expected_run_id=kb.get_task(conn, tid).current_run_id,
    +        )
    +        assert kb.get_task(conn, tid).status == "blocked"
    +
    +        # First dispatcher tick — must NOT promote.
    +        assert kb.recompute_ready(conn) == 0
    +        assert kb.get_task(conn, tid).status == "blocked"
    +
    +        # Simulate the (hypothetical) protocol_violation + gave_up
    +        # entries that the dispatcher would have written if the bug
    +        # were still present.  Even with those event rows in place,
    +        # the worker-initiated ``blocked`` event is the most recent
    +        # of the ``{blocked, unblocked}`` pair, so the sticky guard
    +        # still fires.
    +        now = int(time.time())
    +        conn.execute(
    +            "INSERT INTO task_events (task_id, kind, payload, created_at) "
    +            "VALUES (?, 'protocol_violation', NULL, ?)",
    +            (tid, now),
    +        )
    +        conn.execute(
    +            "INSERT INTO task_events (task_id, kind, payload, created_at) "
    +            "VALUES (?, 'gave_up', NULL, ?)",
    +            (tid, now + 1),
    +        )
    +        conn.commit()
    +
    +        # Subsequent ticks must still leave it blocked.
    +        for _ in range(3):
    +            promoted = kb.recompute_ready(conn)
    +            assert promoted == 0
    +            assert kb.get_task(conn, tid).status == "blocked"
    +
    +
    +# ---------------------------------------------------------------------------
    +# Schema-init recovery on legacy DBs is covered by
    +# tests/hermes_cli/test_kanban_db.py::test_connect_migrates_legacy_db_before_optional_column_indexes
    +# (landed via #28754 / #28781).  The original PR shipped a duplicate test
    +# here; dropped during salvage to avoid two assertions of the same contract.
    +# ---------------------------------------------------------------------------
    ```
- **#34698: fix(kanban): store sticky-block flag on tasks row to fix cross-connection WAL visibility (#34689)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `dc235e93c`, `#34689`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#28726: fix(kanban): worker-initiated block must not be auto-promoted (#28712)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `d35f8932e`, `#28994`, `#28754`, `#28781`
- **#29328: fix(kanban): keep gave_up tasks blocked**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `8e5a6854c3bf081c46df2600775f14bbbde9cc2d`
- **#34735: fix(kanban): emit blocked event when initial_status='blocked' so sticky-block guard fires**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `ad0398eed8`, `#39609`
- **#39832: fix(kanban): keep initial blocked tasks sticky**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#34735`
- **#44595: \[verified\] fix: keep protocol gave_up blocks sticky**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `hermes_cli/kanban_db.py`, `tests/hermes_cli/test_kanban_blocked_sticky.py`
- **#46274: fix(kanban): preserve initial blocked approval gates**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#48437: fix(kanban): emit blocked event for initial_status="blocked" tasks (fixes #47777)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#34735`, `#47777`, `#34735`, `#46565`, `#34735`
- **#69531: fix(kanban): enforce auditable authorization for human gates**. open
  - maintainer direction: category `partial_fix`; implication `needs_llm`; refs `a767d362d7ab`, `#27526`, `#28414`, `#28414`, `#32581`, `#34735`, `#34756`, `#35832`, `#39085`, `#34735`, `#34735`, `#39832`, `#34735`, `#41497`, `#34735`, `#34735`, `#43479`, `#46274`, `#34735`, `#46565`, `#34735`, `#48253`, `#34735`, `#48437`, `#34735`, `#60100`, `#34735`, `#61890`, `#34735`, `#64830`, `#66993`, `#69531`, `#39609`, `#69679`, `#70733`, `#71147`, `#71861`, `#34735`, `#71977`, `#34735`, `#34735`, `#72030`, `#34735`, `#74839`, `#34735`, `#76718`, `#34735`, `#76765`, `#39085`, `#41497`, `#46274`, `#46565`, `#48437`, `#61890`, `#71861`, `#71977`, `#72030`, `#76718`, `#34735`, `#39832`, `#48253`, `#64830`, `#35832`, `#66993`, `#69679`, `#70733`, `#71147`, `#69531`, `#74839`, `#34735`, `#39085`, `#41497`, `#46565`, `#64830`, `#71861`, `#71977`, `#34735`, `#69531`, `#69679`, `#34756`, `#35832`, `#66993`, `#70733`, `#71147`, `#76765`, `#374151`, `#39609`, `#47777`, `#69531`
  - files: `hermes_cli/dashboard_auth/__init__.py`, `hermes_cli/dashboard_auth/base.py`, `hermes_cli/dashboard_auth/middleware.py`, `hermes_cli/kanban.py`, `hermes_cli/kanban_db.py`, `plugins/kanban/dashboard/plugin_api.py`, `tests/hermes_cli/test_kanban_blocked_sticky.py`, `tests/hermes_cli/test_kanban_cli.py`, `tests/hermes_cli/test_kanban_core_functionality.py`, `tests/hermes_cli/test_kanban_decompose_db.py`, `tests/hermes_cli/test_kanban_promote.py`, `tests/hermes_cli/test_kanban_worktree_isolation.py`, `tests/plugins/test_kanban_dashboard_plugin.py`, `tests/stress/_isolated_kanban.py`, `tests/stress/test_concurrency.py`, `tests/stress/test_concurrency_mixed.py`, `tests/stress/test_concurrency_parent_gate.py`, `tests/stress/test_concurrency_reclaim_race.py`, `tests/tools/test_kanban_tools.py`
- **#88686: fix(kanban): keep initially blocked tasks sticky**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `ad0398eed8`, `#39609`, `#34735`
- **#107408: fix(kanban): hold initial_status=blocked cards when parents finish**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#34735`, `#34735`, `#34735`, `#107398`, `#39609`

## Fix status

- Merged to main in PR #28781 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-20`.
- Subsystem: `cron`
