mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-06 10:47:12 +08: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:
381
plugins/kanban/dashboard/dist/index.js
vendored
381
plugins/kanban/dashboard/dist/index.js
vendored
@@ -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,
|
||||
|
||||
253
plugins/kanban/dashboard/dist/style.css
vendored
253
plugins/kanban/dashboard/dist/style.css
vendored
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user