diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 204fbdeecf..fb30e2e046 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -695,8 +695,16 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int: def _cmd_link(args: argparse.Namespace) -> int: with kbc.connect_closing() as conn: - kb.link_tasks(conn, args.parent_id, args.child_id) + gated = kb.link_tasks(conn, args.parent_id, args.child_id) print(f"Linked {args.parent_id} -> {args.child_id}") + if gated: + print( + f"Note: {args.child_id} was ready and is now todo — parent " + f"{args.parent_id} is not done yet. The ready -> running claim " + f"re-checks parents, so the child only runs after the parent " + f"completes; use `hermes kanban unlink {args.parent_id} {args.child_id}` " + f"to run it now." + ) return 0 diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index a9d8fe7244..d7503417cd 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -1581,9 +1581,13 @@ def set_reasoning_effort(conn: sqlite3.Connection, task_id: str, effort: Optiona # --- Links --- -def link_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> None: +def link_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> bool: + """Link ``parent_id -> child_id``. Returns True when the link gated a + ``ready`` child back to ``todo`` (the new parent is not yet terminal), so + callers can surface the demotion instead of a silent status flip.""" if parent_id == child_id: raise ValueError("a task cannot depend on itself") + gated = False with write_txn(conn): missing = _missing_task_ids(conn, [parent_id, child_id]) if missing: @@ -1593,13 +1597,26 @@ def link_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> None: _link(conn, parent_id, child_id) # If child was ready but parent is not yet done, demote child to todo. if _task_status(conn, parent_id) != "done": - conn.execute( - "UPDATE tasks SET status = 'todo' WHERE id = ? AND status = 'ready'", (child_id,), + cur = conn.execute( + "UPDATE tasks SET status = 'todo' WHERE id = ? AND status = 'ready'", + (child_id,), ) + gated = cur.rowcount == 1 + if gated: + _append_event( + conn, + child_id, + "dependency_wait", + {"reason": "parent_not_done", "demoted": True, "parent": parent_id}, + ) _append_event( - conn, child_id, "linked", {"parent": parent_id, "child": child_id}, + conn, + child_id, + "linked", + {"parent": parent_id, "child": child_id}, ) _inherit_notify_subs(conn, child_id, (parent_id,)) + return gated def _would_cycle(conn: sqlite3.Connection, parent_id: str, child_id: str) -> bool: diff --git a/tests/hermes_cli/test_kanban_cli.py b/tests/hermes_cli/test_kanban_cli.py index 376c79f2a9..23a90ed07b 100644 --- a/tests/hermes_cli/test_kanban_cli.py +++ b/tests/hermes_cli/test_kanban_cli.py @@ -72,6 +72,26 @@ def test_kanban_show_text_renders_graph_with_open_connection(kanban_home): assert "Cannot operate on a closed database" not in output +def test_link_warns_when_ready_child_gated_by_undone_parent(kanban_home): + """`hermes kanban link` must tell the operator the child was demoted. + + A ready child linked under an unfinished parent drops to todo silently in + the DB layer (the claim path re-checks parents); the CLI is the operator's + only window onto that demotion, so it prints a note naming the gate. + """ + with kbc.connect_closing() as conn: + parent_id = kb.create_task(conn, title="blocked parent") + child_id = kb.create_task(conn, title="support card") + conn.execute("UPDATE tasks SET status = 'ready' WHERE id = ?", (child_id,)) + conn.commit() + + output = kc.run_slash(f"link {parent_id} {child_id}") + + assert f"Linked {parent_id} -> {child_id}" in output + assert "was ready and is now todo" in output + assert f"parent {parent_id} is not done yet" in output + + def test_board_override_is_isolated_per_concurrent_call(kanban_home, monkeypatch): kb.create_board("alpha") kb.create_board("beta") diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 046bdaec58..6d9646d4b7 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -1155,6 +1155,47 @@ def test_sqlite_connect_closes_tracked_conn_on_setup_failure(tmp_path, monkeypat assert after == before +def test_link_tasks_emits_dependency_wait_when_demoting_ready_child(kanban_home): + """Linking an unfinished parent under a ready child must not be silent. + + The demotion to todo is correct (the ready -> running claim re-checks + parents), but it used to leave no event: the board showed the card flip + to todo with no explanation until someone mined claim_rejected events. + """ + with kbc.connect() as conn: + parent = kb.create_task(conn, title="blocked parent") + child = kb.create_task(conn, title="support card") + conn.execute("UPDATE tasks SET status = 'ready' WHERE id = ?", (child,)) + conn.commit() + + gated = kb.link_tasks(conn, parent, child) + + assert gated is True, "link_tasks must report the demotion it caused" + assert kb.get_task(conn, child).status == "todo" + events = kb.list_events(conn, child) + wait = [e for e in events if e.kind == "dependency_wait"] + assert wait, "the demotion must be recorded as a dependency_wait event" + payload = wait[-1].payload + assert payload["reason"] == "parent_not_done" + assert payload["demoted"] is True + assert payload["parent"] == parent + + +def test_link_tasks_no_dependency_wait_when_parent_done(kanban_home): + """A done parent demotes nothing and reports no gate.""" + with kbc.connect() as conn: + parent = kb.create_task(conn, title="done parent") + kb.complete_task(conn, parent) + child = kb.create_task(conn, title="follower") + + gated = kb.link_tasks(conn, parent, child) + + assert gated is False + assert kb.get_task(conn, child).status == "ready" + kinds = [e.kind for e in kb.list_events(conn, child)] + assert "dependency_wait" not in kinds + + def test_unlink_tasks_triggers_recompute_ready(kanban_home): """Regression test for issue #22459. diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index 6a3eb5aa91..333db70108 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -996,8 +996,9 @@ def _handle_link(args: dict, **kw) -> str: child_id = args.get("child_id") _check(parent_id and child_id, "both parent_id and child_id are required") with _board(args.get("board")) as (kb, conn): - kb.link_tasks(conn, parent_id=parent_id, child_id=child_id) - return _ok(parent_id=parent_id, child_id=child_id) + gated = kb.link_tasks(conn, parent_id=parent_id, child_id=child_id) + return _ok(parent_id=parent_id, child_id=child_id, gated=gated, + **({"gated_by": parent_id} if gated else {})) # --- Registration (order preserved: it is the order tools appear in the schema) --- diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index 234abc610a..7beaf9d876 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -401,7 +401,7 @@ Dispatcher-owned workers receive their task lifecycle tools automatically. | `kanban_attach_url` | Attach a file to a task by URL. | `url` | | `kanban_attachments` | List a task's attachments. | — | | `kanban_create` | (Orchestrators) fan out into child tasks with an `assignee`, optional `parents`, `skills`, etc. | `title`, `assignee` | -| `kanban_link` | (Orchestrators) add a `parent_id → child_id` dependency edge after the fact. | `parent_id`, `child_id` | +| `kanban_link` | (Orchestrators) add a `parent_id → child_id` dependency edge after the fact. Returns `gated: true` when the child was `ready` and got demoted back to `todo` because the parent is not done — the child will only run after the parent completes. | `parent_id`, `child_id` | | `kanban_unblock` | (Orchestrators) restore a blocked task to its source phase (`review` or `ready`), or `todo` while a parent remains open. | `task_id` | A typical worker turn looks like: