mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-12 03:42:08 +00:00
feat(kanban): hallucination gate + recovery UX for worker-created-card claims (#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 #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).
This commit is contained in:
parent
7de3c86c5a
commit
de9238d37e
11 changed files with 1791 additions and 17 deletions
|
|
@ -308,6 +308,35 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
|||
p_assign.add_argument("task_id")
|
||||
p_assign.add_argument("profile", help="Profile name (or 'none' to unassign)")
|
||||
|
||||
# --- reclaim / reassign (recovery) ---
|
||||
p_reclaim = sub.add_parser(
|
||||
"reclaim",
|
||||
help="Release an active worker claim on a running task",
|
||||
)
|
||||
p_reclaim.add_argument("task_id")
|
||||
p_reclaim.add_argument(
|
||||
"--reason", default=None,
|
||||
help="Human-readable reason (recorded on the reclaimed event)",
|
||||
)
|
||||
|
||||
p_reassign = sub.add_parser(
|
||||
"reassign",
|
||||
help="Reassign a task to a different profile, optionally reclaiming first",
|
||||
)
|
||||
p_reassign.add_argument("task_id")
|
||||
p_reassign.add_argument(
|
||||
"profile",
|
||||
help="New profile name (or 'none' to unassign)",
|
||||
)
|
||||
p_reassign.add_argument(
|
||||
"--reclaim", action="store_true",
|
||||
help="Release any active claim before reassigning (required if task is running)",
|
||||
)
|
||||
p_reassign.add_argument(
|
||||
"--reason", default=None,
|
||||
help="Human-readable reason (recorded on the reclaimed event)",
|
||||
)
|
||||
|
||||
# --- link / unlink ---
|
||||
p_link = sub.add_parser("link", help="Add a parent->child dependency")
|
||||
p_link.add_argument("parent_id")
|
||||
|
|
@ -597,6 +626,8 @@ def kanban_command(args: argparse.Namespace) -> int:
|
|||
"ls": _cmd_list,
|
||||
"show": _cmd_show,
|
||||
"assign": _cmd_assign,
|
||||
"reclaim": _cmd_reclaim,
|
||||
"reassign": _cmd_reassign,
|
||||
"link": _cmd_link,
|
||||
"unlink": _cmd_unlink,
|
||||
"claim": _cmd_claim,
|
||||
|
|
@ -1117,6 +1148,45 @@ def _cmd_assign(args: argparse.Namespace) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def _cmd_reclaim(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
ok = kb.reclaim_task(
|
||||
conn, args.task_id,
|
||||
reason=getattr(args, "reason", None),
|
||||
)
|
||||
if not ok:
|
||||
print(
|
||||
f"cannot reclaim {args.task_id} (not running or unknown id)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
print(f"Reclaimed {args.task_id}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_reassign(args: argparse.Namespace) -> int:
|
||||
profile = None if args.profile.lower() in ("none", "-", "null") else args.profile
|
||||
with kb.connect() as conn:
|
||||
ok = kb.reassign_task(
|
||||
conn, args.task_id, profile,
|
||||
reclaim_first=bool(getattr(args, "reclaim", False)),
|
||||
reason=getattr(args, "reason", None),
|
||||
)
|
||||
if not ok:
|
||||
print(
|
||||
f"cannot reassign {args.task_id} "
|
||||
f"(unknown id, or still running — pass --reclaim to release first)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
print(
|
||||
f"Reassigned {args.task_id} to "
|
||||
f"{profile or '(unassigned)'}"
|
||||
+ (" (claim reclaimed)" if getattr(args, "reclaim", False) else "")
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_link(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
kb.link_tasks(conn, args.parent_id, args.child_id)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue