NousResearch/hermes-agent

Kanban blocked tasks auto-promoted when they should stay blocked

Cluster 28712 · cron. Data as of

3 open items. Fix merged in #28781. Check it covers your case.

The short answer

1open issue
2open PRs
22items grouped
Known?
Yes. 22 related items grouped here, 8 issues already closed.
Fix event
A fix was merged to main in PR #28781. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #28994. That is separate from the fix event.
Still open
1 issue and 2 PRs.

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.

Open issues1

  • Issue#47777Kanban: kanban_create(initial_status="blocked") does not stick — dispatcher auto-promotes parked tasks to ready and can spawn an unwanted worker (data-integrity risk)

Open PRs2

  • PR#44595[verified] fix: keep protocol gave_up blocks sticky
  • PR#69531fix(kanban): enforce auditable authorization for human gates

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #28781 fix(kanban): hoist all additive-column indexes out of SCHEMA_SQL (#28461)

    merged to main

    files: hermes_cli/kanban_db.py, tests/hermes_cli/test_kanban_db.py

    Show the diff
    • hermes_cli/kanban_db.py
      @@ -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,
      @@ -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);
      @@ -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
      @@ -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
      @@ -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
       # ---------------------------------------------------------------------------
      @@ -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
      @@ -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

    files: hermes_cli/kanban_db.py, tests/hermes_cli/test_kanban_blocked_sticky.py

    Show the diff
    • hermes_cli/kanban_db.py
      @@ -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):
      @@ -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 "
      @@ -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
      @@ -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.
      +# ---------------------------------------------------------------------------

9 PRs closed without merging

From the close comments: done elsewhere 2 · superseded by another PR 2 · closed without comment 1 · duplicate 1 · merged via another change 1 · other 2

  • #28726 fix(kanban): worker-initiated block must not be auto-promoted (#28712) · merged via another change → d35f893, #28994, #28754, #28781
  • #29328 fix(kanban): keep gave_up tasks blocked · other → 8e5a685
  • #34698 fix(kanban): store sticky-block flag on tasks row to fix cross-connection WAL visibility (#34689) · other → dc235e9, #34689
  • #34735 fix(kanban): emit blocked event when initial_status='blocked' so sticky-block guard fires · done elsewhere → ad0398e, #39609
  • #39832 fix(kanban): keep initial blocked tasks sticky · superseded by another PR → #34735
Show 4 more
  • #46274 fix(kanban): preserve initial blocked approval gates · closed without comment
  • #48437 fix(kanban): emit blocked event for initial_status="blocked" tasks (fixes #47777) · duplicate → #34735, #47777, #46565
  • #88686 fix(kanban): keep initially blocked tasks sticky · done elsewhere → ad0398e, #39609, #34735
  • #107408 fix(kanban): hold initial_status=blocked cards when parents finish · superseded by another PR → #34735, #107398, #39609
Closed issues · 8
  • Closed#28712kanban: dispatcher auto-promotes blocked task → respawn worker → protocol_violation loop
  • Closed#28903[Bug]: Kanban auto-unblock is too eager
  • Closed#28944Kanban: blocked tasks auto-promoted by daemon without explicit human unblock
  • Closed#29014Kanban dispatcher repeatedly respawns blocked/manual-gate tasks, causing provider quota drain
  • Closed#34689Kanban _has_sticky_block guard ineffective in cross-connection WAL-mode production (recurrence of #28712 loop)
  • Closed#37689Circuit-breaker (gave_up) blocks are auto-resurrected by recompute_ready → deterministic-failure tasks loop forever
  • Closed#39609[Bug]: Tasks created with --initial-status blocked auto-promote to ready ~1s later with no actor — human approval gate bypassed
  • Closed#62828Circuit breaker defeated: recompute_ready re-promotes tasks after gave_up
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
cron
Merged PRs
#28781, #28994
Closed issues
8
Attempts
9 PRs closed without merging
Fix link checked
20 May 2026

For your agent

The same facts, typed.