From 94c2c364f526f8195f51e2243e136857b2863170 Mon Sep 17 00:00:00 2001 From: vomitorius Date: Fri, 7 Aug 2026 21:55:14 +0200 Subject: [PATCH] fix(kanban): stop parentless dependency blocks from respawn-churning A `block_task(kind="dependency")` call parks the task in `todo` and lets `recompute_ready` gate it on parent completion. But when the task has no parent links at all, that gate is vacuously satisfied ("all parents are done" over zero parents), so `recompute_ready` promotes it straight back to `ready` and the dispatcher respawns it on the next tick: block -> promote -> respawn -> block -> ... once per dispatcher tick, indefinitely. Neither existing brake applies: the dependency branch returns before the `BLOCK_RECURRENCE_LIMIT` escalation, and a dependency block is not a failure, so `consecutive_failures` never trips the circuit breaker. Observed in the wild on a card whose worker reported a stale base after the default branch moved under its PR. The worker classified the block as `dependency`; the card then respawned every 60s, each spawn burning a full worker session. A dependency wait that no parent completion can ever satisfy is a mis-classified block, so gate the `todo` parking on an actual `task_links` parent row. Without one, fall through to the truly-blocked path: the task lands in the human bucket and gets recurrence counting. The legitimate parent-gated case is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- hermes_cli/kanban_db.py | 22 +++++++++++++++++- tests/hermes_cli/test_kanban_block_kinds.py | 25 +++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index c4bb7caf9482..6a7e45e9dbc9 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -5674,7 +5674,27 @@ def block_task( # wait in ``todo`` and let ``recompute_ready`` gate on parents. Routing # here (rather than ``blocked``) is what keeps a cron from ever seeing # a dependency-wait as something to "unblock". - if kind == "dependency": + # + # ...but only when there IS a parent to wait for. ``recompute_ready`` + # promotes a parentless ``todo`` task straight back to ``ready`` (its + # "all parents are done" predicate is vacuously true over zero + # parents), so the dispatcher respawns it on the very next tick: + # block → promote → respawn → block, every tick, forever. Nothing + # brakes it either — this branch returns before the + # ``BLOCK_RECURRENCE_LIMIT`` escalation below, and a dependency block + # is not a failure so ``consecutive_failures`` never trips the circuit + # breaker. A dependency wait that no parent completion can ever + # satisfy is a mis-classified block (typically a worker reporting a + # stale base, a merge conflict, or an external wait), so fall through + # to the truly-blocked path: it lands in the human bucket and gets + # recurrence counting. + _has_parent_link = bool( + conn.execute( + "SELECT 1 FROM task_links WHERE child_id = ? LIMIT 1", + (task_id,), + ).fetchone() + ) + if kind == "dependency" and _has_parent_link: cur = conn.execute( """ UPDATE tasks diff --git a/tests/hermes_cli/test_kanban_block_kinds.py b/tests/hermes_cli/test_kanban_block_kinds.py index d562d436db67..2591ca4d0765 100644 --- a/tests/hermes_cli/test_kanban_block_kinds.py +++ b/tests/hermes_cli/test_kanban_block_kinds.py @@ -83,6 +83,31 @@ def test_block_loop_detected_event_emitted(kanban_home: Path) -> None: # --------------------------------------------------------------------------- +def test_dependency_block_without_parents_does_not_churn( + kanban_home: Path, +) -> None: + """A ``dependency`` block on a parentless task must not park in ``todo``. + + ``recompute_ready`` promotes a parentless ``todo`` task straight back to + ``ready`` ("all parents done" is vacuously true over zero parents), so the + dispatcher respawns it on the next tick — block → promote → respawn → + block, every tick, forever. The dependency branch also returns before the + ``BLOCK_RECURRENCE_LIMIT`` escalation, and a dependency block is not a + failure, so neither brake applies. A dependency wait that no parent + completion can satisfy is a mis-classified block: route it to the human + bucket instead. + """ + with kb.connect_closing() as conn: + tid = _running_task(conn, title="no-parents") + assert kb.block_task( + conn, tid, reason="stale base, needs rebase", kind="dependency", + ) + assert kb.get_task(conn, tid).status == "blocked" + # And it stays there — no silent auto-recovery into the work pool. + kb.recompute_ready(conn) + assert kb.get_task(conn, tid).status == "blocked" + + def test_dependency_then_parent_done_promotes(kanban_home: Path) -> None: """A dependency-parked child becomes ready once its parent completes.""" with kb.connect_closing() as conn: