"""Regressions for domain-layer descendant invalidation on ancestor reopen. ``kanban_db.invalidate_descendants_for_parent_reopen`` is the single implementation of "a done ancestor was reopened, retract everything that assumed its result" (M3). These tests pin: * done descendants are demoted to ``todo`` with a ``descendant_invalidated`` event AND a comment naming the ancestor (non-silent), * running descendants have their audit trail committed BEFORE their worker is terminated, and the kill routes through ``_terminate_reclaimed_worker`` (the same helper the reclaim paths use), * ``consecutive_failures`` resets to 0 (deliberate operator action — opposite of the review-loop rule pinned in M2), and * the dashboard ``_set_status_direct`` reopen path and the DB function produce identical descendant outcomes. """ from __future__ import annotations from pathlib import Path import pytest from hermes_cli import kanban_db as kb @pytest.fixture def conn(tmp_path: Path): db = kb.connect(tmp_path / "kanban.db") try: yield db finally: db.close() def _done_parent_with_done_child(conn): parent_id = kb.create_task(conn, title="ancestor", assignee="planner") assert kb.complete_task(conn, parent_id) child_id = kb.create_task( conn, title="child", assignee="builder", parents=[parent_id], ) assert kb.complete_task(conn, child_id) return parent_id, child_id def _reopen_parent_directly(conn, parent_id: str) -> None: """Minimal stand-in for a reopen surface: flip done -> todo.""" with kb.write_txn(conn): conn.execute( "UPDATE tasks SET status = 'todo', completed_at = NULL WHERE id = ?", (parent_id,), ) def test_reopen_demotes_done_descendants_with_events_and_comments(conn): parent_id, child_id = _done_parent_with_done_child(conn) grandchild_id = kb.create_task( conn, title="grandchild", assignee="writer", parents=[child_id], ) assert kb.complete_task(conn, grandchild_id) _reopen_parent_directly(conn, parent_id) result = kb.invalidate_descendants_for_parent_reopen( conn, parent_id, author="operator", ) demoted = {entry["id"]: entry for entry in result["invalidated"]} assert set(demoted) == {child_id, grandchild_id} for tid in (child_id, grandchild_id): assert demoted[tid]["prior_status"] == "done" assert demoted[tid]["new_status"] == "todo" task = kb.get_task(conn, tid) assert task is not None and task.status == "todo" assert task.completed_at is None events = kb.list_events(conn, tid) inval = [e for e in events if e.kind == "descendant_invalidated"] assert len(inval) == 1 payload = inval[0].payload assert payload["ancestor"] == parent_id assert payload["prior_status"] == "done" assert payload["new_status"] == "todo" comments = kb.list_comments(conn, tid) assert any( parent_id in c.body and c.author == "operator" for c in comments ), f"no invalidation comment naming {parent_id} on {tid}" assert result["terminations"] == [] def test_running_descendant_event_precedes_termination_via_reclaim_helper( conn, tmp_path, monkeypatch, ): parent_id = kb.create_task(conn, title="ancestor", assignee="planner") assert kb.complete_task(conn, parent_id) child_id = kb.create_task( conn, title="running child", assignee="builder", parents=[parent_id], ) claimed = kb.claim_task(conn, child_id) assert claimed is not None and claimed.status == "running" kb._set_worker_pid(conn, child_id, 424242) kills: list[tuple] = [] def fake_terminate(pid, claim_lock, **kwargs): # The audit trail must already be durable when the kill fires: # standalone calls commit before terminating. side = kb.connect(tmp_path / "kanban.db") try: kinds = [e.kind for e in kb.list_events(side, child_id)] finally: side.close() assert "descendant_invalidated" in kinds kills.append((pid, claim_lock)) return {"terminated": True} monkeypatch.setattr(kb, "_terminate_reclaimed_worker", fake_terminate) _reopen_parent_directly(conn, parent_id) result = kb.invalidate_descendants_for_parent_reopen( conn, parent_id, author="operator", ) assert kills and kills[0][0] == 424242 assert result["terminations"] == kills child = kb.get_task(conn, child_id) assert child is not None assert child.status == "todo" assert child.current_run_id is None run = kb.latest_run(conn, child_id) assert run is not None and run.outcome == "reclaimed" def test_counter_reset_on_invalidated_descendants(conn): parent_id, child_id = _done_parent_with_done_child(conn) with kb.write_txn(conn): conn.execute( "UPDATE tasks SET consecutive_failures = 4 WHERE id = ?", (child_id,), ) _reopen_parent_directly(conn, parent_id) kb.invalidate_descendants_for_parent_reopen(conn, parent_id, author="op") child = kb.get_task(conn, child_id) assert child is not None # Deliberate operator action = fresh start with the breaker; contrast # with reopen_review_task, which PRESERVES the counter (M2 rule). assert child.consecutive_failures == 0 def test_dashboard_and_db_paths_produce_identical_outcomes(tmp_path, monkeypatch): fastapi = pytest.importorskip("fastapi") from fastapi.testclient import TestClient import importlib.util import sys home = tmp_path / ".hermes" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) monkeypatch.setattr(Path, "home", lambda: tmp_path) kb.init_db() repo_root = Path(__file__).resolve().parents[2] plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py" spec = importlib.util.spec_from_file_location( "hermes_dashboard_plugin_kanban_m3_test", plugin_file, ) assert spec is not None and spec.loader is not None mod = importlib.util.module_from_spec(spec) sys.modules[spec.name] = mod spec.loader.exec_module(mod) app = fastapi.FastAPI() app.include_router(mod.router, prefix="/api/plugins/kanban") client = TestClient(app) def build_graph(tag: str): with kb.connect() as c: parent = kb.create_task(c, title=f"{tag}-parent", assignee="planner") assert kb.complete_task(c, parent) child = kb.create_task( c, title=f"{tag}-child", assignee="builder", parents=[parent], ) assert kb.complete_task(c, child) return parent, child dash_parent, dash_child = build_graph("dash") db_parent, db_child = build_graph("db") # Surface 1: dashboard drag (done -> todo) via _set_status_direct. r = client.patch( f"/api/plugins/kanban/tasks/{dash_parent}", json={"status": "todo"}, ) assert r.status_code == 200, r.text # Surface 2: DB function directly (the single domain implementation). with kb.connect() as c: with kb.write_txn(c): c.execute( "UPDATE tasks SET status = 'todo', completed_at = NULL " "WHERE id = ?", (db_parent,), ) kb.invalidate_descendants_for_parent_reopen( c, db_parent, author="dashboard", ) with kb.connect() as c: def snapshot(tid: str): t = kb.get_task(c, tid) assert t is not None kinds = sorted(e.kind for e in kb.list_events(c, tid)) n_comments = len(kb.list_comments(c, tid)) return ( t.status, t.completed_at, t.current_run_id, t.consecutive_failures, kinds, n_comments, ) assert snapshot(dash_child) == snapshot(db_child) status, completed_at, _run, failures, kinds, n_comments = snapshot(db_child) assert status == "todo" assert completed_at is None assert failures == 0 assert "descendant_invalidated" in kinds assert n_comments >= 1