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
- 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.
Next step: read #28781, then check whether your report is one of the 1 still open.
Read #28781Pick up a Lab taskA 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
Fixes on record
-
#28781 fix(kanban): hoist all additive-column indexes out of SCHEMA_SQL (#28461)
files:
hermes_cli/kanban_db.py,tests/hermes_cli/test_kanban_db.pyShow 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)
files:
hermes_cli/kanban_db.py,tests/hermes_cli/test_kanban_blocked_sticky.pyShow 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.00means 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