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:
Teknium
2026-05-05 08:06:55 -07:00
committed by GitHub
parent 7de3c86c5a
commit de9238d37e
11 changed files with 1791 additions and 17 deletions

View File

@@ -60,6 +60,35 @@
blocked: "Mark this task as blocked? The worker's claim is released.",
};
// Event kinds that indicate a hallucinated/phantom task-id reference
// in a completion. ``completion_blocked_hallucination`` is emitted when
// the kernel's ``created_cards`` gate rejects a completion; the task is
// left in its prior state and the worker can retry. ``suspected_
// hallucinated_references`` is the advisory prose-scan result — the
// completion succeeded but the summary text references task ids that
// do not resolve.
const HALLUCINATION_EVENT_KINDS = [
"completion_blocked_hallucination",
"suspected_hallucinated_references",
];
const HALLUCINATION_EVENT_LABELS = {
completion_blocked_hallucination: "Completion blocked — phantom card ids",
suspected_hallucinated_references: "Prose referenced phantom card ids",
};
function isHallucinationEvent(kind) {
return HALLUCINATION_EVENT_KINDS.indexOf(kind) !== -1;
}
function phantomIdsFromEvent(ev) {
// Payload shapes:
// completion_blocked_hallucination: {phantom_cards, verified_cards, summary_preview}
// suspected_hallucinated_references: {phantom_refs, source}
if (!ev || !ev.payload) return [];
const p = ev.payload;
return p.phantom_cards || p.phantom_refs || [];
}
function withCompletionSummary(patch, count) {
if (!patch || patch.status !== "done") return patch;
const label = count && count > 1 ? `${count} selected task(s)` : "this task";
@@ -646,6 +675,10 @@
return createNewBoard(payload).then(function () { setShowNewBoard(false); });
},
}) : null,
h(AttentionStrip, {
boardData,
onOpen: setSelectedTaskId,
}),
h(BoardToolbar, {
board: boardData,
tenantFilter, setTenantFilter,
@@ -684,12 +717,303 @@
onRefresh: loadBoard,
renderMarkdown: renderMd,
allTasks: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
assignees: (boardData && boardData.assignees) || [],
eventTick: taskEventTick[selectedTaskId] || 0,
}) : null,
),
);
}
// -------------------------------------------------------------------------
// Attention strip — surfaces tasks with active hallucination warnings.
// Renders a collapsed bar just below the board switcher; clicking expands
// a list of affected tasks with an "Open" button each. Dismissible per
// session via state flag; tasks re-appear on page reload if they still
// have warnings.
// -------------------------------------------------------------------------
function collectWarningTasks(boardData) {
if (!boardData || !boardData.columns) return [];
const out = [];
for (const col of boardData.columns) {
for (const t of col.tasks || []) {
if (t.warnings && t.warnings.count > 0) out.push(t);
}
}
// Sort: most recent warning first.
out.sort(function (a, b) {
return (b.warnings.latest_at || 0) - (a.warnings.latest_at || 0);
});
return out;
}
function AttentionStrip(props) {
const [expanded, setExpanded] = useState(false);
const [dismissed, setDismissed] = useState(false);
const warnTasks = useMemo(
function () { return collectWarningTasks(props.boardData); },
[props.boardData]
);
if (dismissed || warnTasks.length === 0) return null;
return h("div", { className: "hermes-kanban-attention" },
h("div", { className: "hermes-kanban-attention-bar" },
h("span", { className: "hermes-kanban-attention-icon" }, "⚠"),
h("span", { className: "hermes-kanban-attention-text" },
warnTasks.length === 1
? "1 task with hallucination warnings"
: `${warnTasks.length} tasks with hallucination warnings`,
),
h("button", {
className: "hermes-kanban-attention-toggle",
onClick: function () { setExpanded(function (x) { return !x; }); },
type: "button",
}, expanded ? "Hide" : "Show"),
h("button", {
className: "hermes-kanban-attention-dismiss",
onClick: function () { setDismissed(true); },
title: "Hide until next page reload",
type: "button",
}, "✕"),
),
expanded
? h("div", { className: "hermes-kanban-attention-list" },
warnTasks.map(function (t) {
return h("div", { key: t.id, className: "hermes-kanban-attention-row" },
h("span", { className: "hermes-kanban-attention-row-id" }, t.id),
h("span", { className: "hermes-kanban-attention-row-title" },
t.title || "(untitled)"),
h("span", { className: "hermes-kanban-attention-row-meta" },
t.assignee ? "@" + t.assignee : "unassigned",
" · ",
`${t.warnings.count} event${t.warnings.count === 1 ? "" : "s"}`,
),
h("button", {
className: "hermes-kanban-attention-row-btn",
onClick: function () { props.onOpen(t.id); },
type: "button",
}, "Open"),
);
}),
)
: null,
);
}
// -------------------------------------------------------------------------
// Recovery popover — operator actions for a task flagged with
// hallucination warnings. Three primary actions:
// 1. Reclaim — release a running worker's claim; task back to ready.
// 2. Reassign — switch the task to a different profile (with optional
// reclaim-first toggle for currently-running tasks).
// 3. Edit profile — copy the CLI hint for `hermes -p <name> model`
// (the dashboard can't edit profile config from the
// browser; it lives on the filesystem).
// Rendered from inside TaskDetail via a toggle button.
// -------------------------------------------------------------------------
function RecoveryPopover(props) {
const t = props.task;
const board = props.boardSlug;
const assignees = props.assignees || [];
const [reason, setReason] = useState("");
const [newProfile, setNewProfile] = useState(t.assignee || "");
const [reclaimFirst, setReclaimFirst] = useState(t.status === "running");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState(null);
const [copied, setCopied] = useState(false);
const act = function (kind) {
if (busy) return;
setBusy(true);
setMsg(null);
const urlBase = `${API}/tasks/${encodeURIComponent(t.id)}`;
const url = kind === "reclaim"
? withBoard(`${urlBase}/reclaim`, board)
: withBoard(`${urlBase}/reassign`, board);
const body = kind === "reclaim"
? { reason: reason || null }
: {
profile: newProfile || null,
reclaim_first: !!reclaimFirst,
reason: reason || null,
};
SDK.fetchJSON(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(function () {
setMsg({ ok: true, text:
kind === "reclaim"
? `Reclaimed ${t.id}. Task back to ready.`
: `Reassigned ${t.id} to ${newProfile || "(unassigned)"}.`
});
if (props.onActionComplete) props.onActionComplete(kind);
}).catch(function (err) {
setMsg({ ok: false, text: `Failed: ${err.message || err}` });
}).then(function () {
setBusy(false);
});
};
const profileCmd = `hermes -p ${t.assignee || "<profile>"} model`;
const copyCmd = function () {
try {
navigator.clipboard.writeText(profileCmd).then(function () {
setCopied(true);
setTimeout(function () { setCopied(false); }, 2000);
});
} catch (_) {
window.prompt("Copy this command:", profileCmd);
}
};
return h("div", { className: "hermes-kanban-recovery" },
h("div", { className: "hermes-kanban-recovery-title" },
"Recovery actions"),
h("div", { className: "hermes-kanban-recovery-hint" },
"Use these when a worker is stuck (crash loop, repeated hallucination, ",
"broken model). Events in this task's history are preserved as audit trail."),
// Reason input (shared across actions)
h("div", { className: "hermes-kanban-recovery-section" },
h("label", { className: "hermes-kanban-recovery-label" },
"Reason (optional, logged on event)"),
h("input", {
type: "text",
className: "hermes-kanban-recovery-input",
value: reason,
onChange: function (e) { setReason(e.target.value); },
placeholder: "e.g. model hallucinating, switching to larger",
}),
),
// Action 1: Reclaim
h("div", { className: "hermes-kanban-recovery-section" },
h("div", { className: "hermes-kanban-recovery-action-row" },
h("div", { className: "hermes-kanban-recovery-action-label" },
"1. Reclaim"),
h("div", { className: "hermes-kanban-recovery-action-desc" },
t.status === "running"
? "Abort the running worker and reset to ready."
: "Task is not running — nothing to reclaim."),
h("button", {
className: "hermes-kanban-recovery-btn",
disabled: busy || t.status !== "running",
onClick: function () { act("reclaim"); },
type: "button",
}, "Reclaim"),
),
),
// Action 2: Reassign
h("div", { className: "hermes-kanban-recovery-section" },
h("div", { className: "hermes-kanban-recovery-action-row" },
h("div", { className: "hermes-kanban-recovery-action-label" },
"2. Reassign"),
h("div", { className: "hermes-kanban-recovery-action-desc" },
"Switch to a different worker profile and retry."),
),
h("div", { className: "hermes-kanban-recovery-reassign-row" },
h("select", {
className: "hermes-kanban-recovery-select",
value: newProfile,
onChange: function (e) { setNewProfile(e.target.value); },
},
h("option", { value: "" }, "(unassigned)"),
assignees.map(function (a) {
return h("option", { key: a, value: a }, a);
}),
),
h("label", { className: "hermes-kanban-recovery-checkbox" },
h("input", {
type: "checkbox",
checked: reclaimFirst,
onChange: function (e) { setReclaimFirst(e.target.checked); },
}),
" Reclaim first",
),
h("button", {
className: "hermes-kanban-recovery-btn",
disabled: busy,
onClick: function () { act("reassign"); },
type: "button",
}, "Reassign"),
),
),
// Action 3: Edit profile model (CLI hint)
h("div", { className: "hermes-kanban-recovery-section" },
h("div", { className: "hermes-kanban-recovery-action-row" },
h("div", { className: "hermes-kanban-recovery-action-label" },
"3. Change profile model"),
h("div", { className: "hermes-kanban-recovery-action-desc" },
"Profile config lives on disk — change it from a terminal, ",
"then use Reclaim above to retry with the new model."),
),
h("div", { className: "hermes-kanban-recovery-cmd-row" },
h("code", { className: "hermes-kanban-recovery-cmd" }, profileCmd),
h("button", {
className: "hermes-kanban-recovery-btn",
onClick: copyCmd,
type: "button",
}, copied ? "Copied" : "Copy"),
),
),
msg
? h("div", {
className: cn(
"hermes-kanban-recovery-msg",
msg.ok ? "hermes-kanban-recovery-msg--ok" : "hermes-kanban-recovery-msg--err",
),
}, msg.text)
: null,
);
}
// Thin wrapper that toggles the RecoveryPopover visibility inside a
// task drawer. Auto-opens when the task has active hallucination
// warnings; operators can still collapse it. Always available via a
// header button for tasks without warnings, so reclaim/reassign is
// accessible for other stuck-worker scenarios too.
function RecoverySection(props) {
const [open, setOpen] = useState(!!props.hasWarnings);
// Re-open automatically if warnings appear while the drawer is open.
useEffect(function () {
if (props.hasWarnings) setOpen(true);
}, [props.hasWarnings]);
return h("div", { className: "hermes-kanban-section" },
h("div", { className: "hermes-kanban-section-head-row" },
h("span", { className: "hermes-kanban-section-head" },
props.hasWarnings
? h("span", { className: "hermes-kanban-section-head-warning" },
"⚠ Recovery")
: "Recovery",
),
h("button", {
className: "hermes-kanban-section-toggle",
onClick: function () { setOpen(function (x) { return !x; }); },
type: "button",
}, open ? "Hide" : "Show"),
),
open
? h(RecoveryPopover, {
// Keyed by task id so React tears the popover down and
// remounts it when the drawer swaps to a different task —
// otherwise reason / newProfile / success toast from the
// previous task leak into the new one.
key: props.task.id,
task: props.task,
boardSlug: props.boardSlug,
assignees: props.assignees,
onActionComplete: function () {
if (props.onRefresh) props.onRefresh();
},
})
: null,
);
}
// -------------------------------------------------------------------------
// Board switcher (multi-project)
// -------------------------------------------------------------------------
@@ -1219,6 +1543,14 @@
title: "Select for bulk actions",
}),
h("span", { className: "hermes-kanban-card-id" }, t.id),
t.warnings && t.warnings.count > 0
? h("span", {
className: "hermes-kanban-warning-badge",
title: `${t.warnings.count} hallucination ` +
`event(s) since last clean completion. ` +
`Click to open for details.`,
}, "⚠")
: null,
t.priority > 0
? h(Badge, { className: "hermes-kanban-priority" }, `P${t.priority}`)
: null,
@@ -1541,6 +1873,7 @@
data, editing, setEditing,
renderMarkdown: props.renderMarkdown,
allTasks: props.allTasks,
assignees: props.assignees || [],
boardSlug: boardSlug,
onPatch: doPatch,
onAddParent: addLink,
@@ -1550,6 +1883,7 @@
homeChannels: homeChannels,
homeBusy: homeBusy,
onToggleHomeSub: toggleHomeSubscription,
onRefresh: props.onRefresh,
}) : null,
data ? h("div", { className: "hermes-kanban-drawer-comment-row" },
h(Input, {
@@ -1611,6 +1945,13 @@
t.created_by ? h(MetaRow, { label: "Created by", value: t.created_by }) : null,
),
h(StatusActions, { task: t, onPatch: props.onPatch }),
h(RecoverySection, {
task: t,
boardSlug: props.boardSlug,
assignees: props.assignees,
hasWarnings: t.warnings && t.warnings.count > 0,
onRefresh: props.onRefresh,
}),
h(HomeSubsSection, {
homeChannels: props.homeChannels || [],
homeBusy: props.homeBusy || {},
@@ -1651,11 +1992,41 @@
h("div", { className: "hermes-kanban-section" },
h("div", { className: "hermes-kanban-section-head" }, `Events (${events.length})`),
events.slice().reverse().slice(0, 20).map(function (e) {
return h("div", { key: e.id, className: "hermes-kanban-event" },
h("span", { className: "hermes-kanban-event-kind" }, e.kind),
h("span", { className: "hermes-kanban-event-ago" },
timeAgo ? timeAgo(e.created_at) : ""),
e.payload
const isHall = isHallucinationEvent(e.kind);
const phantoms = isHall ? phantomIdsFromEvent(e) : [];
return h("div", {
key: e.id,
className: cn(
"hermes-kanban-event",
isHall ? "hermes-kanban-event--hallucination" : "",
),
},
isHall
? h("div", { className: "hermes-kanban-event-header" },
h("span", { className: "hermes-kanban-event-warning-icon" }, "⚠"),
h("span", { className: "hermes-kanban-event-warning-label" },
HALLUCINATION_EVENT_LABELS[e.kind] || e.kind),
h("span", { className: "hermes-kanban-event-ago" },
timeAgo ? timeAgo(e.created_at) : ""),
)
: h("div", { className: "hermes-kanban-event-header-plain" },
h("span", { className: "hermes-kanban-event-kind" }, e.kind),
h("span", { className: "hermes-kanban-event-ago" },
timeAgo ? timeAgo(e.created_at) : ""),
),
isHall && phantoms.length > 0
? h("div", { className: "hermes-kanban-event-phantom-row" },
h("span", { className: "hermes-kanban-event-phantom-label" },
"Phantom ids:"),
phantoms.map(function (pid) {
return h("code", {
key: pid,
className: "hermes-kanban-event-phantom-chip",
}, pid);
}),
)
: null,
e.payload && !isHall
? h("code", { className: "hermes-kanban-event-payload" },
JSON.stringify(e.payload))
: null,

View File

@@ -847,3 +847,256 @@
gap: 0.5rem;
margin-top: 1rem;
}
/* ---------------------------------------------------------------------- */
/* Hallucination warnings: per-card badge, events callout, attention */
/* strip, recovery popover. Orange/red palette but muted so the board */
/* doesn't scream on every render. */
/* ---------------------------------------------------------------------- */
.hermes-kanban-warning-badge {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: #ff9e3b;
margin-left: 0.25rem;
cursor: help;
}
/* Attention strip — collapsed state is a thin bar. */
.hermes-kanban-attention {
border: 1px solid rgba(255, 158, 59, 0.35);
background: rgba(255, 158, 59, 0.06);
border-radius: 0.5rem;
overflow: hidden;
}
.hermes-kanban-attention-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
font-size: 0.8125rem;
}
.hermes-kanban-attention-icon { color: #ff9e3b; font-size: 1rem; }
.hermes-kanban-attention-text { flex: 1; }
.hermes-kanban-attention-toggle,
.hermes-kanban-attention-dismiss,
.hermes-kanban-attention-row-btn {
background: transparent;
border: 1px solid rgba(120, 120, 140, 0.3);
border-radius: 0.3rem;
padding: 0.15rem 0.55rem;
font-size: 0.75rem;
color: inherit;
cursor: pointer;
}
.hermes-kanban-attention-toggle:hover,
.hermes-kanban-attention-dismiss:hover,
.hermes-kanban-attention-row-btn:hover {
background: rgba(255, 158, 59, 0.12);
}
.hermes-kanban-attention-list {
border-top: 1px solid rgba(255, 158, 59, 0.2);
padding: 0.25rem 0;
}
.hermes-kanban-attention-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.75rem;
font-size: 0.8125rem;
}
.hermes-kanban-attention-row:hover {
background: rgba(255, 158, 59, 0.08);
}
.hermes-kanban-attention-row-id {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.75rem;
color: var(--color-muted-foreground, #888);
min-width: 7rem;
}
.hermes-kanban-attention-row-title {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hermes-kanban-attention-row-meta {
font-size: 0.75rem;
color: var(--color-muted-foreground, #888);
}
/* Events tab — callout style for hallucination events. */
.hermes-kanban-event--hallucination {
border-left: 3px solid #ff6b6b;
background: rgba(255, 107, 107, 0.08);
padding: 0.5rem 0.65rem;
border-radius: 0.35rem;
margin: 0.25rem 0;
}
.hermes-kanban-event-header,
.hermes-kanban-event-header-plain {
display: flex;
align-items: center;
gap: 0.5rem;
}
.hermes-kanban-event-warning-icon { color: #ff6b6b; font-size: 1rem; }
.hermes-kanban-event-warning-label {
color: #ff6b6b;
font-weight: 600;
font-size: 0.8125rem;
}
.hermes-kanban-event-phantom-row {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.3rem;
padding-left: 1.35rem;
}
.hermes-kanban-event-phantom-label {
font-size: 0.75rem;
color: var(--color-muted-foreground, #999);
}
.hermes-kanban-event-phantom-chip {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
background: rgba(255, 107, 107, 0.15);
border: 1px solid rgba(255, 107, 107, 0.3);
border-radius: 0.3rem;
}
/* Recovery section header — amber accent when the task has warnings. */
.hermes-kanban-section-head-warning { color: #ff9e3b; }
.hermes-kanban-section-head-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.hermes-kanban-section-toggle {
background: transparent;
border: 1px solid rgba(120, 120, 140, 0.3);
border-radius: 0.3rem;
padding: 0.15rem 0.55rem;
font-size: 0.75rem;
color: inherit;
cursor: pointer;
}
/* Recovery popover body. */
.hermes-kanban-recovery {
border: 1px solid rgba(120, 120, 140, 0.25);
background: rgba(255, 158, 59, 0.04);
border-radius: 0.5rem;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.hermes-kanban-recovery-title {
font-weight: 600;
font-size: 0.8125rem;
}
.hermes-kanban-recovery-hint {
font-size: 0.75rem;
color: var(--color-muted-foreground, #888);
line-height: 1.35;
}
.hermes-kanban-recovery-section {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.hermes-kanban-recovery-label {
font-size: 0.75rem;
color: var(--color-muted-foreground, #888);
}
.hermes-kanban-recovery-input,
.hermes-kanban-recovery-select {
padding: 0.25rem 0.4rem;
font-size: 0.8125rem;
background: rgba(0, 0, 0, 0.15);
border: 1px solid rgba(120, 120, 140, 0.3);
border-radius: 0.3rem;
color: inherit;
outline: none;
}
.hermes-kanban-recovery-action-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.hermes-kanban-recovery-action-label {
font-size: 0.8125rem;
font-weight: 600;
min-width: 8rem;
}
.hermes-kanban-recovery-action-desc {
flex: 1;
font-size: 0.75rem;
color: var(--color-muted-foreground, #888);
}
.hermes-kanban-recovery-btn {
padding: 0.25rem 0.7rem;
font-size: 0.75rem;
background: rgba(255, 158, 59, 0.15);
border: 1px solid rgba(255, 158, 59, 0.4);
border-radius: 0.3rem;
color: inherit;
cursor: pointer;
}
.hermes-kanban-recovery-btn:hover:not(:disabled) {
background: rgba(255, 158, 59, 0.25);
}
.hermes-kanban-recovery-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.hermes-kanban-recovery-reassign-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.hermes-kanban-recovery-checkbox {
font-size: 0.75rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.hermes-kanban-recovery-cmd-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.hermes-kanban-recovery-cmd {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(120, 120, 140, 0.3);
border-radius: 0.3rem;
flex: 1;
min-width: 10rem;
overflow-x: auto;
white-space: nowrap;
}
.hermes-kanban-recovery-msg {
font-size: 0.75rem;
padding: 0.35rem 0.5rem;
border-radius: 0.3rem;
}
.hermes-kanban-recovery-msg--ok {
background: rgba(120, 200, 120, 0.12);
color: #6bc46b;
border: 1px solid rgba(120, 200, 120, 0.3);
}
.hermes-kanban-recovery-msg--err {
background: rgba(255, 107, 107, 0.12);
color: #ff8b8b;
border: 1px solid rgba(255, 107, 107, 0.3);
}

View File

@@ -176,6 +176,74 @@ def _run_dict(r: kanban_db.Run) -> dict[str, Any]:
}
# Hallucination-warning event kinds — see complete_task() in kanban_db.py.
# completion_blocked_hallucination: kernel rejected created_cards with
# phantom ids; task stays in prior state.
# suspected_hallucinated_references: prose scan found t_<hex> in summary
# that doesn't resolve; completion succeeded, advisory only.
_WARNING_EVENT_KINDS = (
"completion_blocked_hallucination",
"suspected_hallucinated_references",
)
def _compute_warnings_for_tasks(
conn: sqlite3.Connection,
task_ids: Optional[list[str]] = None,
) -> dict[str, dict]:
"""Return {task_id: {count, kinds, latest_at}} for tasks with
hallucination warnings that occurred AFTER the most recent clean
completion event (completed / edited). An empty dict means no tasks
on the board have active warnings.
``task_ids`` narrows the query; pass ``None`` to scan the whole DB
(matches board-level rollup). Used by both the /board aggregate and
per-task /tasks/:id endpoints.
"""
params: tuple = ()
if task_ids is not None:
if not task_ids:
return {}
placeholders = ",".join(["?"] * len(task_ids))
sql = (
"SELECT task_id, kind, created_at FROM task_events "
f"WHERE task_id IN ({placeholders}) AND kind IN "
"('completion_blocked_hallucination', "
" 'suspected_hallucinated_references', "
" 'completed', 'edited') "
"ORDER BY task_id, id"
)
params = tuple(task_ids)
else:
sql = (
"SELECT task_id, kind, created_at FROM task_events "
"WHERE kind IN "
"('completion_blocked_hallucination', "
" 'suspected_hallucinated_references', "
" 'completed', 'edited') "
"ORDER BY task_id, id"
)
out: dict[str, dict] = {}
for row in conn.execute(sql, params).fetchall():
tid = row["task_id"]
kind = row["kind"]
created_at = row["created_at"]
if kind in ("completed", "edited"):
# Clean event wipes prior warning counters; only events after
# this timestamp count.
out.pop(tid, None)
continue
bucket = out.setdefault(
tid, {"count": 0, "kinds": {}, "latest_at": 0}
)
bucket["count"] += 1
bucket["kinds"][kind] = bucket["kinds"].get(kind, 0) + 1
if created_at > bucket["latest_at"]:
bucket["latest_at"] = created_at
return out
def _links_for(conn: sqlite3.Connection, task_id: str) -> dict[str, list[str]]:
"""Return {'parents': [...], 'children': [...]} for a task."""
parents = [
@@ -253,6 +321,11 @@ def get_board(
if row["cstatus"] == "done":
p["done"] += 1
# Hallucination-warning rollup for this board (all tasks).
# Delegated to _compute_warnings_for_tasks so the per-task
# /tasks/:id endpoint can reuse the same rule.
warnings_per_task = _compute_warnings_for_tasks(conn, task_ids=None)
latest_event_id = conn.execute(
"SELECT COALESCE(MAX(id), 0) AS m FROM task_events"
).fetchone()["m"]
@@ -266,6 +339,9 @@ def get_board(
d["link_counts"] = link_counts.get(t.id, {"parents": 0, "children": 0})
d["comment_count"] = comment_counts.get(t.id, 0)
d["progress"] = progress.get(t.id) # None when the task has no children
w = warnings_per_task.get(t.id)
if w:
d["warnings"] = w
col = t.status if t.status in columns else "todo"
columns[col].append(d)
@@ -313,8 +389,14 @@ def get_task(task_id: str, board: Optional[str] = Query(None)):
task = kanban_db.get_task(conn, task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
task_d = _task_dict(task)
# Attach warnings metadata so the drawer's Recovery section can
# auto-open when a hallucination is unresolved.
warnings = _compute_warnings_for_tasks(conn, task_ids=[task_id])
if warnings.get(task_id):
task_d["warnings"] = warnings[task_id]
return {
"task": _task_dict(task),
"task": task_d,
"comments": [_comment_dict(c) for c in kanban_db.list_comments(conn, task_id)],
"events": [_event_dict(e) for e in kanban_db.list_events(conn, task_id)],
"links": _links_for(conn, task_id),
@@ -713,6 +795,85 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
conn.close()
# ---------------------------------------------------------------------------
# Recovery actions — reclaim a running claim, reassign to a new profile
# ---------------------------------------------------------------------------
class ReclaimBody(BaseModel):
reason: Optional[str] = None
@router.post("/tasks/{task_id}/reclaim")
def reclaim_task_endpoint(
task_id: str,
payload: ReclaimBody,
board: Optional[str] = Query(None),
):
"""Release an active worker claim on a running task.
Used by the dashboard recovery popover when an operator wants to
abort a stuck worker (e.g. one that keeps hallucinating card ids)
without waiting for the claim TTL. Maps 1:1 to
``hermes kanban reclaim <task_id> --reason ...``.
"""
board = _resolve_board(board)
conn = _conn(board=board)
try:
ok = kanban_db.reclaim_task(conn, task_id, reason=payload.reason)
if not ok:
raise HTTPException(
status_code=409,
detail=(
f"cannot reclaim {task_id}: not in a claimable state "
"(not running, or unknown id)"
),
)
return {"ok": True, "task_id": task_id}
finally:
conn.close()
class ReassignBody(BaseModel):
profile: Optional[str] = None # "" or None = unassign
reclaim_first: bool = False
reason: Optional[str] = None
@router.post("/tasks/{task_id}/reassign")
def reassign_task_endpoint(
task_id: str,
payload: ReassignBody,
board: Optional[str] = Query(None),
):
"""Reassign a task to a different profile, optionally reclaiming first.
Used by the dashboard recovery popover when an operator wants to
retry a task with a different worker profile (e.g. switch to a
smarter model after the assigned profile keeps hallucinating).
Maps 1:1 to ``hermes kanban reassign <task_id> <profile> [--reclaim]``.
"""
board = _resolve_board(board)
conn = _conn(board=board)
try:
ok = kanban_db.reassign_task(
conn, task_id,
payload.profile or None,
reclaim_first=bool(payload.reclaim_first),
reason=payload.reason,
)
if not ok:
raise HTTPException(
status_code=409,
detail=(
f"cannot reassign {task_id}: unknown id, or still "
"running (pass reclaim_first=true to release the claim first)"
),
)
return {"ok": True, "task_id": task_id, "assignee": payload.profile or None}
finally:
conn.close()
# ---------------------------------------------------------------------------
# Plugin config (read dashboard.kanban.* defaults from config.yaml)
# ---------------------------------------------------------------------------