Files
hermes-agent/tests/hermes_cli/test_kanban_block_kinds.py
teknium1 4f1dcc2dad test: purge low-value tests, lane py11 (303 removed)
Change-detectors, tautologies, source-reading tests, redundant duplicates,
mock-echo tests and dead/unrunnable tests. Per-test rationale in the lane
ledger (category + reason for every removal).
2026-09-23 03:15:26 -07:00

201 lines
8.7 KiB
Python

"""Tests for typed block reasons + the unblock-loop breaker.
Covers the built-in fix for the kanban "blocked loop" — a worker blocks a
task, a cron unblocks it, the worker re-blocks for the same reason, repeat
forever. The fix gives ``block_task`` a typed ``kind`` and a persistent
``block_recurrences`` counter:
* ``dependency`` blocks route to ``todo`` (parent-gated, auto-resumed) and
never enter the human ``blocked`` bucket a cron would keep unblocking —
unless no parent is open, in which case the wait can never be satisfied
and the block is recorded as ``needs_input`` (sticky, loop-counted).
* ``needs_input`` / ``capability`` / un-typed blocks land in ``blocked``;
each same-cause re-block after an unblock increments ``block_recurrences``,
and at ``BLOCK_RECURRENCE_LIMIT`` the task routes to ``triage`` for a human.
* ``unblock_task`` deliberately does NOT reset ``block_recurrences`` (the
amnesia that let the loop run unbounded).
* A successful ``complete_task`` resets the loop memory.
"""
from __future__ import annotations
import argparse
from pathlib import Path
import pytest
from hermes_cli import kanban as kanban_cli
from hermes_cli import kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_dispatch as kbd
@pytest.fixture
def kanban_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
return home
def _running_task(conn, title="t", parents=()):
"""Create a task (linked under ``parents`` first) and drive it to ``running`` so block_task can act."""
tid = kb.create_task(conn, title=title, assignee="worker")
for parent in parents:
kb.link_tasks(conn, parent_id=parent, child_id=tid)
with kb.write_txn(conn):
conn.execute("UPDATE tasks SET status='ready' WHERE id=?", (tid,))
claimed = kb.claim_task(conn, tid, claimer="worker")
assert claimed is not None
return tid
def _make_running_again(conn, tid):
with kb.write_txn(conn):
conn.execute("UPDATE tasks SET status='ready' WHERE id=?", (tid,))
assert kb.claim_task(conn, tid, claimer="worker") is not None
# ---------------------------------------------------------------------------
# Loop breaker
# ---------------------------------------------------------------------------
def test_block_loop_detected_event_emitted(kanban_home: Path) -> None:
with kbc.connect_closing() as conn:
tid = _running_task(conn)
kb.block_task(conn, tid, reason="x", kind="capability")
kb.unblock_task(conn, tid)
_make_running_again(conn, tid)
kb.block_task(conn, tid, reason="x", kind="capability")
events = [e for e in kb.list_events(conn, tid)
if e.kind == "block_loop_detected"]
assert events, "expected a block_loop_detected event"
payload = events[-1].payload or {}
assert payload.get("recurrences") == 2
assert payload.get("kind") == "capability"
# ---------------------------------------------------------------------------
# Dependency routing
# ---------------------------------------------------------------------------
def test_dependency_then_parent_done_promotes(kanban_home: Path) -> None:
"""A dependency-parked child becomes ready once its parent completes."""
with kbc.connect_closing() as conn:
parent = kb.create_task(conn, title="parent", assignee="worker")
child = _running_task(conn, title="child")
kb.link_tasks(
conn,
parent_id=parent,
child_id=child,
expected_child_run_id=kb.get_task(conn, child).current_run_id,
)
kb.block_task(conn, child, reason="wait", kind="dependency")
assert kb.get_task(conn, child).status == "todo"
# Finish the parent, then let recompute_ready run.
with kb.write_txn(conn):
conn.execute("UPDATE tasks SET status='ready' WHERE id=?", (parent,))
kb.claim_task(conn, parent, claimer="worker")
kb.complete_task(conn, parent, result="done")
kb.recompute_ready(conn)
assert kb.get_task(conn, child).status == "ready"
def test_dependency_block_with_terminal_parents_parks_then_escalates(
kanban_home: Path, capsys: pytest.CaptureFixture[str],
) -> None:
"""A ``dependency`` block whose parents are all terminal can never be
satisfied by ``recompute_ready``: it must park in ``blocked`` as
``needs_input`` (no ``dependency_wait``, no re-promotion), say so on the
CLI, and count toward the loop breaker so a re-block after an unblock
reaches ``triage``."""
with kbc.connect_closing() as conn:
parent = kb.create_task(conn, title="already-done-parent", assignee="worker")
with kb.write_txn(conn):
conn.execute("UPDATE tasks SET status='done' WHERE id=?", (parent,))
# The edge exists before the run: link_tasks refuses to gate a running child retroactively.
child = _running_task(conn, title="child-of-done", parents=(parent,))
# `hermes kanban block <child> --kind dependency waiting on upstream`
args = argparse.Namespace(task_id=child, ids=None, reason=["waiting", "on", "upstream"], kind="dependency")
assert kanban_cli._cmd_block(args) == 0
assert "needs_input" in capsys.readouterr().out
parked = kb.get_task(conn, child)
assert (parked.status, parked.block_kind, parked.block_recurrences) == ("blocked", "needs_input", 1)
events = kb.list_events(conn, child)
assert not [e for e in events if e.kind == "dependency_wait"]
blocked = [e for e in events if e.kind == "blocked"][-1].payload
assert (blocked["requested_kind"], blocked["rekind_reason"]) == ("dependency", "no_open_parent")
assert kb.recompute_ready(conn) == 0
assert kb.get_task(conn, child).status == "blocked"
# A cron/human unblocks; the worker re-declares the same impossible wait.
assert kb.unblock_task(conn, child)
assert kb.claim_task(conn, child, claimer="worker") is not None
assert kb.block_task(conn, child, reason="still waiting", kind="dependency")
assert kb.get_task(conn, child).status == "triage"
loop = [e for e in kb.list_events(conn, child) if e.kind == "block_loop_detected"][-1].payload
assert loop["recurrences"] == kb.BLOCK_RECURRENCE_LIMIT
def test_dependency_block_with_open_parent_stays_parked_across_dispatch_tick(
kanban_home: Path, all_assignees_spawnable,
) -> None:
"""Control: a genuine wait on an incomplete parent parks in ``todo``
without counting a recurrence, survives a dispatch tick unspawned, and
resumes once the parent finishes."""
spawns: list[str] = []
def fake_spawn(task, workspace, board=None):
spawns.append(task.id)
return 4242
with kbc.connect() as conn:
parent = kb.create_task(conn, title="open-parent", assignee="alice")
# Linked while todo (a running child cannot be gated retroactively); the parent then stays
# open while the child runs — the reopened-parent shape, forced the same way the loop below does.
child = kb.create_task(conn, title="waiter", assignee="worker")
kb.link_tasks(conn, parent_id=parent, child_id=child)
with kb.write_txn(conn):
conn.execute("UPDATE tasks SET status='running' WHERE id=?", (child,))
for _ in range(kb.BLOCK_RECURRENCE_LIMIT + 1):
assert kb.block_task(conn, child, reason="wait", kind="dependency")
parked = kb.get_task(conn, child)
assert (parked.status, parked.block_kind, parked.block_recurrences) == ("todo", "dependency", 0)
with kb.write_txn(conn):
conn.execute("UPDATE tasks SET status='running' WHERE id=?", (child,))
assert kb.block_task(conn, child, reason="wait", kind="dependency")
res = kbd.dispatch_once(conn, spawn_fn=fake_spawn)
assert kb.get_task(conn, child).status == "todo"
assert child not in spawns
with kb.write_txn(conn):
conn.execute("UPDATE tasks SET status='ready' WHERE id=?", (parent,))
kb.claim_task(conn, parent, claimer="alice")
kb.complete_task(conn, parent, result="done")
res2 = kbd.dispatch_once(conn, spawn_fn=fake_spawn)
assert child in [row[0] for row in res2.spawned]
# ---------------------------------------------------------------------------
# Completion resets loop memory
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Validation + back-compat
# ---------------------------------------------------------------------------