diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index ef6b3c4521..91b0369af1 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -765,8 +765,8 @@ class LinkBody(BaseModel): @router.post("/links") def add_link(payload: LinkBody, board: Optional[str] = Query(None)): with _board_conn(board) as (board, conn), _value_error_400(): - kanban_db.link_tasks(conn, payload.parent_id, payload.child_id) - return {"ok": True} + gated = kanban_db.link_tasks(conn, payload.parent_id, payload.child_id) + return {"ok": True, "gated": gated} @router.delete("/links") diff --git a/tests/hermes_cli/test_kanban_cli.py b/tests/hermes_cli/test_kanban_cli.py index 23a90ed07b..376c79f2a9 100644 --- a/tests/hermes_cli/test_kanban_cli.py +++ b/tests/hermes_cli/test_kanban_cli.py @@ -72,26 +72,6 @@ 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/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index 7beaf9d876..cc8a2b9028 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -99,6 +99,10 @@ They look similar; they are not the same primitive. **One-sentence distinction:** `delegate_task` is a function call; Kanban is a work queue where every handoff is a row any profile (or human) can see and edit. +:::caution Don't link a support card to the card it is meant to unblock +A worker that is blocked on `t_parent` and creates a support card for the missing piece must **not** `kanban_link(t_parent, t_support)`: the link makes the support card a *child* of the blocked parent, so it is gated behind the parent it exists to unblock and neither card ever runs. Reference the parent id in the support card's body instead. `link`/`kanban_link` now report `gated: true` and record a `dependency_wait` event when they demote a `ready` child, so the deadlock is visible on the board; `hermes kanban unlink ` releases it. +::: + **Use `delegate_task` when** the parent agent needs a short reasoning answer before continuing, no humans involved, result goes back into the parent's context. **Use Kanban when** work crosses agent boundaries, needs to survive restarts, might need human input, might be picked up by a different role, or needs to be discoverable after the fact. @@ -1275,7 +1279,7 @@ Every transition appends a row to `task_events`. Each row carries an optional `r | `claimed` | `{lock, expires, run_id}` | Dispatcher atomically claimed a `ready` task for spawn. | | `completed` | `{result_len, summary?}` | Worker wrote `--result` / `--summary` and task hit `done`. `summary` is the first-line handoff (400-char cap); full version lives on the run row. If `complete_task` is called on a never-claimed task with handoff fields, a zero-duration run is synthesized so `run_id` still points at something. | | `blocked` | `{reason, kind, recurrences}` | Worker or human flipped the task to `blocked`. `kind` is the typed block reason (`needs_input`, `capability`, `transient`, or `null` for a generic block); `recurrences` is the unblock-loop counter. Synthesizes a zero-duration run when called on a never-claimed task with `--reason`. | -| `dependency_wait` | `{reason, kind}` | Worker blocked with `kind=dependency` — the task is only waiting on another task, so it routes to `todo` (parent-gated, auto-promoted) instead of `blocked`. No human needed. | +| `dependency_wait` | `{reason, kind}` or `{reason: parent_not_done, demoted: true, parent}` | Worker blocked with `kind=dependency` — the task is only waiting on another task, so it routes to `todo` (parent-gated, auto-promoted) instead of `blocked`. No human needed. Also emitted when `link`/`kanban_link` puts a `ready` child under a parent that is not `done`: the child drops back to `todo` and this event records why (the `ready → running` claim re-checks parents, so nothing can run it until the parent completes or the link is removed with `hermes kanban unlink`). | | `block_loop_detected` | `{reason, kind, recurrences, limit}` | A task was unblocked and re-blocked for the same reason `BLOCK_RECURRENCE_LIMIT` times (default 2). Instead of landing in `blocked` again — where a cron would keep unblocking it — it routes to `triage` for orchestration attention, breaking the unblock↔re-block loop. | | `unblocked` | — | `blocked → ready` (or `todo` if parents are still open), either manually or via `/unblock`. Resets the dispatcher's `consecutive_failures` but deliberately preserves `block_recurrences` so the loop breaker keeps its memory. `run_id` is `NULL`. | | `archived` | — | Hidden from the default board. If the task was still running, carries the `run_id` of the run that was reclaimed as a side effect. |