Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions tests/hermes_cli/test_kanban_block_kinds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down