Skip to content

Commit de9238d

Browse files
authored
feat(kanban): hallucination gate + recovery UX for worker-created-card claims (NousResearch#20232)
Workers completing a kanban task can now claim the ids of cards they created via an optional ``created_cards`` field on ``kanban_complete``. The kernel verifies each id exists and was created by the completing worker's profile; any phantom id blocks the completion with a ``HallucinatedCardsError`` and records a ``completion_blocked_hallucination`` event on the task so the rejected attempt is auditable. Successful completions also get a non-blocking prose-scan pass over their ``summary`` + ``result`` that emits a ``suspected_hallucinated_references`` event for any ``t_<hex>`` reference that doesn't resolve. Closes NousResearch#20017. Recovery UX (kernel + CLI + dashboard) -------------------------------------- A structural gate alone isn't enough — operators also need to see and act on stuck workers, especially when a profile's model is the root cause. This PR ships the full loop: * ``kanban_db.reclaim_task(task_id)`` — operator-driven reclaim that releases an active worker claim immediately (unlike ``release_stale_claims`` which only acts after claim_expires has passed). Emits a ``reclaimed`` event with ``manual: True`` payload. * ``kanban_db.reassign_task(task_id, profile, reclaim_first=...)`` — switch a task to a different profile, optionally reclaiming a stuck running worker in the same call. * ``hermes kanban reclaim <id> [--reason ...]`` and ``hermes kanban reassign <id> <profile> [--reclaim] [--reason ...]`` CLI subcommands wired through to the same helpers. * ``POST /api/plugins/kanban/tasks/{id}/reclaim`` and ``POST /api/plugins/kanban/tasks/{id}/reassign`` endpoints on the dashboard plugin. Dashboard surfacing ------------------- * ⚠ **warning badge** on cards with active hallucination events. * **attention strip** at the top of the board listing all flagged tasks; dismissible per session. * **events callout** in the task drawer — hallucination events render with a red left border, amber icon, and phantom ids as styled chips. * **recovery section** in the task drawer with three actions: Reclaim, Reassign (with profile picker + reclaim-first checkbox), and a copy-to-clipboard hint for ``hermes -p <profile> model`` since profile config lives on disk and can't be edited from the browser. Auto-opens when the task has warnings, collapsed otherwise. Keyed by task id so state doesn't leak between drawers. Active-vs-stale rule: warnings clear when a clean ``completed`` or ``edited`` event supersedes the hallucination, so recovery is never permanently stigmatising — the audit events persist for debugging but the badge goes away once the worker succeeds. Skill updates ------------- * ``skills/devops/kanban-worker/SKILL.md`` documents the ``created_cards`` contract with good/bad examples. * ``skills/devops/kanban-orchestrator/SKILL.md`` gains a "Recovering stuck workers" section with the three actions and when to use each. Tests ----- * Kernel gate: verified-cards manifest, phantom rejection + audit event, cross-worker rejection, prose scan positive + negative. * Recovery helpers: reclaim on running task, reclaim on non-running returns False, reassign refuses running without reclaim_first, reassign with reclaim_first succeeds on running. * API endpoints: warnings field present on /board and /tasks/:id, warnings cleared after clean completion, reclaim 200 + 409 paths, reassign 200 + 409 + reclaim_first paths. * CLI smoke: reclaim + reassign subcommands. Live-verified end-to-end on a dashboard with seeded scenarios: attention strip renders, badges land on the right cards, drawer callout shows phantom chips, Reclaim on a running task flips status to ready + emits manual reclaimed event + refreshes the drawer, Reassign swaps the assignee and triggers board refresh. 359/359 kanban-suite tests pass (test_kanban_{db,cli,boards,core_functionality} + dashboard + tools).
1 parent 7de3c86 commit de9238d

11 files changed

Lines changed: 1791 additions & 17 deletions

File tree

hermes_cli/kanban.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,35 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
308308
p_assign.add_argument("task_id")
309309
p_assign.add_argument("profile", help="Profile name (or 'none' to unassign)")
310310

311+
# --- reclaim / reassign (recovery) ---
312+
p_reclaim = sub.add_parser(
313+
"reclaim",
314+
help="Release an active worker claim on a running task",
315+
)
316+
p_reclaim.add_argument("task_id")
317+
p_reclaim.add_argument(
318+
"--reason", default=None,
319+
help="Human-readable reason (recorded on the reclaimed event)",
320+
)
321+
322+
p_reassign = sub.add_parser(
323+
"reassign",
324+
help="Reassign a task to a different profile, optionally reclaiming first",
325+
)
326+
p_reassign.add_argument("task_id")
327+
p_reassign.add_argument(
328+
"profile",
329+
help="New profile name (or 'none' to unassign)",
330+
)
331+
p_reassign.add_argument(
332+
"--reclaim", action="store_true",
333+
help="Release any active claim before reassigning (required if task is running)",
334+
)
335+
p_reassign.add_argument(
336+
"--reason", default=None,
337+
help="Human-readable reason (recorded on the reclaimed event)",
338+
)
339+
311340
# --- link / unlink ---
312341
p_link = sub.add_parser("link", help="Add a parent->child dependency")
313342
p_link.add_argument("parent_id")
@@ -597,6 +626,8 @@ def kanban_command(args: argparse.Namespace) -> int:
597626
"ls": _cmd_list,
598627
"show": _cmd_show,
599628
"assign": _cmd_assign,
629+
"reclaim": _cmd_reclaim,
630+
"reassign": _cmd_reassign,
600631
"link": _cmd_link,
601632
"unlink": _cmd_unlink,
602633
"claim": _cmd_claim,
@@ -1117,6 +1148,45 @@ def _cmd_assign(args: argparse.Namespace) -> int:
11171148
return 0
11181149

11191150

1151+
def _cmd_reclaim(args: argparse.Namespace) -> int:
1152+
with kb.connect() as conn:
1153+
ok = kb.reclaim_task(
1154+
conn, args.task_id,
1155+
reason=getattr(args, "reason", None),
1156+
)
1157+
if not ok:
1158+
print(
1159+
f"cannot reclaim {args.task_id} (not running or unknown id)",
1160+
file=sys.stderr,
1161+
)
1162+
return 1
1163+
print(f"Reclaimed {args.task_id}")
1164+
return 0
1165+
1166+
1167+
def _cmd_reassign(args: argparse.Namespace) -> int:
1168+
profile = None if args.profile.lower() in ("none", "-", "null") else args.profile
1169+
with kb.connect() as conn:
1170+
ok = kb.reassign_task(
1171+
conn, args.task_id, profile,
1172+
reclaim_first=bool(getattr(args, "reclaim", False)),
1173+
reason=getattr(args, "reason", None),
1174+
)
1175+
if not ok:
1176+
print(
1177+
f"cannot reassign {args.task_id} "
1178+
f"(unknown id, or still running — pass --reclaim to release first)",
1179+
file=sys.stderr,
1180+
)
1181+
return 1
1182+
print(
1183+
f"Reassigned {args.task_id} to "
1184+
f"{profile or '(unassigned)'}"
1185+
+ (" (claim reclaimed)" if getattr(args, "reclaim", False) else "")
1186+
)
1187+
return 0
1188+
1189+
11201190
def _cmd_link(args: argparse.Namespace) -> int:
11211191
with kb.connect() as conn:
11221192
kb.link_tasks(conn, args.parent_id, args.child_id)

0 commit comments

Comments
 (0)