fix(kanban): dashboard link reports the gate; docs; trim to two invariant tests

The dashboard's POST /links is the fourth writer of link_tasks (CLI, tool,
dashboard, plus the graph builder); return the same ``gated`` flag so every
surface that can create the deadlock can see it. Document the
``dependency_wait`` payload the link path emits and the delegation rule the
reporter derived (never link a support card under the card it unblocks).

Drops the CLI output test (a change-detector on prose); the two DB-level
invariants (event emitted on demotion / none for a done parent) stay.
This commit is contained in:
teknium1
2026-09-14 19:13:06 -07:00
committed by Teknium
parent 35b1609fc3
commit 89ef145254
3 changed files with 7 additions and 23 deletions
+2 -2
View File
@@ -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")
-20
View File
@@ -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")
+5 -1
View File
@@ -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 <parent> <child>` 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. |