Kanban scratch workspace safety and artifact preservation
NousResearch/hermes-agent · cluster 20017 · agent
Data as of
5 open items. Fix merged in #20232. Check it covers your case.
The short answer
- Known?
- Yes. 27 related items grouped here, 6 issues already closed.
- Fix event
- A fix was merged to main in PR #20232. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #20232, #31708, #41352, #63619. That is separate from the fix event. - Still open
- 3 issues and 2 PRs.
Next step: read #20232, then check whether your report is one of the 3 still open.
Read #20232Pick up a Lab taskThese items concern bugs and fixes around the kanban scratch workspace lifecycle: preventing cleanup from deleting real directories outside managed roots, preserving artifacts before workspace teardown, deferring cleanup until child tasks finish, and validating artifact existence and card handoff claims before accepting task completion.
Open issues3
Open PRs2
Fixes on record
-
#20232 feat(kanban): hallucination gate + recovery UX for worker-created-card claims (closes #20017)
files:
hermes_cli/kanban.py,hermes_cli/kanban_db.py,plugins/kanban/dashboard/dist/index.js,plugins/kanban/dashboard/dist/style.css,plugins/kanban/dashboard/plugin_api.py,skills/devops/kanban-orchestrator/SKILL.md,skills/devops/kanban-worker/SKILL.md,tests/hermes_cli/test_kanban_cli.py,tests/hermes_cli/test_kanban_core_functionality.py,tests/plugins/test_kanban_dashboard_plugin.py,tools/kanban_tools.pyShow the diff
-
hermes_cli/kanban.py@@ -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) -
hermes_cli/kanban_db.py@@ -1842,6 +1842,212 @@ def release_stale_claims(conn: sqlite3.Connection) -> int: return reclaimed +def reclaim_task( + conn: sqlite3.Connection, + task_id: str, + *, + reason: Optional[str] = None, +) -> bool: + """Operator-driven reclaim: release the claim and reset to ``ready``. + + Unlike :func:`release_stale_claims` which only acts on tasks whose + ``claim_expires`` has passed, this function reclaims immediately + regardless of TTL. Intended for the dashboard/CLI recovery flow + when an operator wants to abort a running worker without waiting + for the TTL to expire (e.g. after seeing a hallucination warning). + + Returns True if a reclaim happened, False if the task isn't in a + reclaimable state (not running, or doesn't exist). + """ + with write_txn(conn): + row = conn.execute( + "SELECT status, claim_lock, worker_pid FROM tasks WHERE id = ?", + (task_id,), + ).fetchone() + if not row: + return False + if row["status"] != "running" and row["claim_lock"] is None: + # Nothing to reclaim — already ready / blocked / done. + return False + prev_lock = row["claim_lock"] + prev_pid = row["worker_pid"] + conn.execute( + "UPDATE tasks SET status = 'ready', claim_lock = NULL, " + "claim_expires = NULL, worker_pid = NULL " + "WHERE id = ? AND status IN ('running', 'ready', 'blocked')", + (task_id,), + ) + run_id = _end_run( + conn, task_id, + outcome="reclaimed", status="reclaimed", + error=( + f"manual_reclaim: {reason}" if reason + else f"manual_reclaim lock={prev_lock}" + ), + ) + _append_event( + conn, task_id, "reclaimed", + { + "manual": True, + "reason": reason, + "prev_lock": prev_lock, + "prev_pid": prev_pid, + }, + run_id=run_id, + ) + return True + + +def reassign_task( + conn: sqlite3.Connection, + task_id: str, + profile: Optional[str], + *, + reclaim_first: bool = False, + reason: Optional[str] = None, +) -> bool: + """Reassign a task, optionally reclaiming a stuck running worker first. + + This is the recovery path for "this profile's model is broken, try + a different one". If ``reclaim_first`` is True, any active claim is + released (via :func:`reclaim_task`) before the reassign happens; + otherwise the function refuses to reassign a currently-running task + and returns False (caller can retry with ``reclaim_first=True``). + + Returns True if the reassign landed. ``profile`` may be ``None`` to + unassign entirely. + """ + if reclaim_first: + # Safe to call even if nothing to reclaim. + reclaim_task(conn, task_id, reason=reason or "reassign") + # assign_task handles its own txn + the still-running guard. + try: + return assign_task(conn, task_id, profile) + except RuntimeError: + # Task is still running and reclaim_first was False; caller + # needs to decide whether to retry with reclaim. + return False + + +def _verify_created_cards( + conn: sqlite3.Connection, + completing_task_id: str, + claimed_ids: Iterable[str], +) -> tuple[list[str], list[str]]: + """Partition ``claimed_ids`` into (verified, phantom). + + A card is "verified" iff a row exists in ``tasks`` with the given id + AND ``created_by`` matches the completing task's ``assignee`` (or + the completing task itself — workers that create children of their + own task also qualify). + + ``phantom`` returns ids that either don't exist at all or exist but + were not created by the completing worker. The caller decides what + to do with each bucket; this helper never mutates. + """ + claimed = [str(x).strip() for x in (claimed_ids or []) if str(x).strip()] + if not claimed: + return [], [] + # Dedupe while preserving order. + seen: set[str] = set() + ordered: list[str] = [] + for cid in claimed: + if cid not in seen: + seen.add(cid) + ordered.append(cid) + + row = conn.execute( + "SELECT assignee FROM tasks WHERE id = ?", (completing_task_id,), + ).fetchone() + if row is None: + # Completing task not found — nothing resolves. + return [], ordered + completing_assignee = row["assignee"] + + # Batch-fetch existence + created_by in one query. + placeholders = ",".join(["?"] * len(ordered)) + rows = conn.execute( + f"SELECT id, created_by FROM tasks WHERE id IN ({placeholders})", + tuple(ordered), + ).fetchall() + found = {r["id"]: r["created_by"] for r in rows} + + verified: list[str] = [] + phantom: list[str] = [] + for cid in ordered: + created_by = found.get(cid) + if created_by is None: + phantom.append(cid) + continue + # Accept if created_by matches the completing task's assignee + # profile, OR the task itself (workers whose created_by happens + # to match their task id are unusual but harmless to accept). + if completing_assignee and created_by == completing_assignee: + verified.append(cid) + elif created_by == completing_task_id: + verified.append(cid) + else: + phantom.append(cid) + return verified, phantom + + +# Task-id pattern used both by ``kanban_create`` (``t_<12 hex>``) and +# ``_new_task_id`` below. Kept permissive on length for forward compat: +# accept 8+ hex chars after the ``t_`` prefix. +_TASK_ID_PROSE_RE = re.compile(r"\bt_[a-f0-9]{8,}\b") + + +def _scan_prose_for_phantom_ids( + conn: sqlite3.Connection, + text: str, +) -> list[str]: + """Regex-scan free-form text for ``t_<hex>`` references; return the + ones that don't exist in ``tasks``. + + Used as a non-blocking advisory check on completion summaries. An + empty return means "no suspicious references found" — either the + text had no IDs at all, or every ID it mentioned resolves to a real + task. Duplicates are deduped. + """ + if not text: + return [] + matches = _TASK_ID_PROSE_RE.findall(text) + if not matches: + return [] + # Dedupe preserving order. + seen: set[str] = set() + unique: list[str] = [] + for m in matches: + if m not in seen: + seen.add(m) + unique.append(m) + placeholders = ",".join(["?"] * len(unique)) + rows = conn.execute( + f"SELECT id FROM tasks WHERE id IN ({placeholders})", + tuple(unique), + ).fetchall() + existing = {r["id"] for r in rows} + return [m for m in unique if m not in existing] + + +class HallucinatedCardsError(ValueError): + """Raised by ``complete_task`` when ``created_cards`` contains ids + that don't exist or weren't created by the completing worker. + + The phantom list is attached as ``.phantom`` for callers that want + structured access. Kept as ``ValueError`` subclass so existing + tool-error handlers treat it as a recoverable user error. + """ + + def __init__(self, phantom: list[str], completing_task_id: str): + self.phantom = list(phantom) + self.completing_task_id = completing_task_id + super().__init__( + f"completion blocked: claimed created_cards that do not exist " + f"or were not created by this worker: {', '.join(phantom)}" + ) + + def complete_task( conn: sqlite3.Connection, task_id: str, @@ -1849,21 +2055,65 @@ def complete_task( result: Optional[str] = None, summary: Optional[str] = None, metadata: Optional[dict] = None, + created_cards: Optional[Iterable[str]] = None, ) -> bool: """Transition ``running|ready -> done`` and record ``result``. - Accepts a task that's merely ``ready`` too, so a manual CLI + Accepts a task that is merely ``ready`` too, so a manual CLI completion (``hermes kanban complete <id>``) works without requiring a claim/start/complete sequence. ``summary`` and ``metadata`` are stored on the closing run (if any) and surfaced to downstream children via :func:`build_worker_context`. When ``summary`` is omitted we fall back to ``result`` so single-run - callers don't have to pass both. ``metadata`` is a free-form dict + callers do not have to pass both. ``metadata`` is a free-form dict (e.g. ``{"changed_files": [...], "tests_run": [...]}``) — workers are encouraged to use it for structured handoff facts. + + ``created_cards`` is an optional list of task ids the completing + worker claims to have created. Each id is verified against + ``tasks.created_by``. If any id is phantom (does not exist or was + not created by this worker's assignee profile), completion is blocked + with a ``HallucinatedCardsError`` and a + ``completion_blocked_hallucination`` event is emitted so the rejected + attempt is auditable. When all ids verify, they are recorded on the + ``completed`` event payload. + + After a successful completion, ``summary`` and ``result`` are scanned + for prose references like ``t_deadbeefcafe`` that do not resolve. + Any suspected phantom references are recorded as a + ``suspected_hallucinated_references`` event. This pass is advisory + and never blocks. """ now = int(time.time()) + + # Gate: verify created_cards BEFORE the main write txn. A rejected + # completion still needs an auditable event, so we emit it in a + # tiny dedicated txn, then raise. The caller is responsible for + # surfacing HallucinatedCardsError to the worker; this function + # never mutates task state on a phantom-card rejection. + if created_cards: + verified_cards, phantom_cards = _verify_created_cards( + conn, task_id, created_cards + ) + if phantom_cards: + with write_txn(conn): + _append_event( + conn, task_id, "completion_blocked_hallucination", + { + "phantom_cards": phantom_cards, + "verified_cards": verified_cards, + "summary_preview": ( + (summary or result or "").strip().splitlines()[0][:200] + if (summary or result) + else None + ), + }, + ) + raise HallucinatedCardsError(phantom_cards, task_id) + else: + verified_cards = [] + with write_txn(conn): cur = conn.execute( """ @@ -1904,14 +2154,38 @@ def complete_task( # full summary stays on the run row. ev_summary = (summary if summary is not None else result) or "" ev_summary = ev_summary.strip().splitlines()[0][:400] if ev_summary else "" + completed_payload: dict = { + "result_len": len(result) if result else 0, + "summary": ev_summary or None, + } + if verified_cards: + completed_payload["verified_cards"] = verified_cards _append_event( conn, task_id, "completed", - { - "result_len": len(result) if result else 0, - "summary": ev_summary or None, - }, + completed_payload, run_id=run_id, ) + # Prose-scan the summary + result for t_<hex> references that do + # not resolve. Advisory — does not block the completion. Runs in + # its own txn so the completion itself is already durable by the + # time we emit the warning. + scan_text = " ".join(filter(None, [summary, result])) + if scan_text: + phantom_refs = _scan_prose_for_phantom_ids(conn, scan_text) + # Drop any phantom refs that were already flagged as verified + # above (shouldn't happen — verified means they exist — but + # belt-and-suspenders). + phantom_refs = [p for p in phantom_refs if p not in set(verified_cards)] + if phantom_refs: + with write_txn(conn): + _append_event( + conn, task_id, "suspected_hallucinated_references", + { + "phantom_refs": phantom_refs, + "source": "completion_summary", + }, + run_id=run_id, + ) # Recompute ready status for dependents (separate txn so children see done). recompute_ready(conn) return True -
plugins/kanban/dashboard/dist/index.js@@ -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, -
plugins/kanban/dashboard/dist/style.css@@ -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); +} -
plugins/kanban/dashboard/plugin_api.py@@ -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 + + [redacted:email]("/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 + + [redacted:email]("/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) # --------------------------------------------------------------------------- -
skills/devops/kanban-orchestrator/SKILL.md@@ -150,3 +150,13 @@ Tell them what you created in plain prose: **Don't pre-create the whole graph if the shape depends on intermediate findings.** If T3's structure depends on what T1 and T2 find, let T3 exist as a "synthesize findings" task whose own first step is to read parent handoffs and plan the rest. Orchestrators can spawn orchestrators. **Tenant inheritance.** If `HERMES_TENANT` is set in your env, pass `tenant=os.environ.get("HERMES_TENANT")` on every `kanban_create` call so child tasks stay in the same namespace. + +## Recovering stuck workers + +When a worker profile keeps crashing, hallucinating, or getting blocked by its own mistakes (usually: wrong model, missing skill, broken credential), the kanban dashboard flags the task with a ⚠ badge and opens a **Recovery** section in the drawer. Three primary actions: + +1. **Reclaim** (or `hermes kanban reclaim <task_id>`) — abort the running worker immediately and reset the task to `ready`. The existing claim TTL is ~15 min; this is the fast path out. +2. **Reassign** (or `hermes kanban reassign <task_id> <new-profile> --reclaim`) — switch the task to a different profile and let the dispatcher pick it up with a fresh worker. +3. **Change profile model** — the dashboard prints a copy-paste hint for `hermes -p <profile> model` since profile config lives on disk; edit it in a terminal, then Reclaim to retry with the new model. + +Hallucination warnings appear on tasks where a worker's `kanban_complete(created_cards=[...])` claim included card ids that don't exist or weren't created by the worker's profile (the gate blocks the completion), or where the free-form summary references `t_<hex>` ids that don't resolve (advisory prose scan, non-blocking). Both produce audit events that persist even after recovery actions — the trail stays for debugging. -
skills/devops/kanban-worker/SKILL.md@@ -75,6 +75,32 @@ kanban_complete( Shape `metadata` so downstream parsers (reviewers, aggregators, schedulers) can use it without re-reading your prose. +## Claiming cards you actually created + +If your run produced new kanban tasks (via `kanban_create`), pass the ids in `created_cards` on `kanban_complete`. The kernel verifies each id exists and was created by your profile; any phantom id blocks the completion with an error listing what went wrong, and the rejected attempt is permanently recorded on the task's event log. **Only list ids you captured from a successful `kanban_create` return value — never invent ids from prose, never paste ids from earlier runs, never claim cards another worker created.** + +```python +# GOOD — capture return values, then claim them. +c1 = kanban_create(title="remediate SQL injection", assignee="security-worker") +c2 = kanban_create(title="fix CSRF middleware", assignee="web-worker") + +kanban_complete( + summary="Review done; spawned remediations for both findings.", + metadata={"pr_number": 123, "approved": False}, + created_cards=[c1["task_id"], c2["task_id"]], +) +``` + +```python +# BAD — claiming ids you don't have captured return values for. +kanban_complete( + summary="Created remediation cards t_a1b2c3d4, t_deadbeef", # hallucinated + created_cards=["t_a1b2c3d4", "t_deadbeef"], # → gate rejects +) +``` + +If a `kanban_create` call fails (exception, tool_error), the card was NOT created — do not include a phantom id for it. Retry the create, or omit the id and mention the failure in your summary. The prose-scan pass also catches `t_<hex>` references in your free-form summary that don't resolve; these don't block the completion but show up as advisory warnings on the task in the dashboard. + ## Block reasons that get answered fast Bad: `"stuck"` — the human has no context. -
tests/hermes_cli/test_kanban_cli.py@@ -208,3 +208,81 @@ def test_kanban_not_gateway_only(): cmd = next(c for c in COMMAND_REGISTRY if c.name == "kanban") assert not cmd.cli_only assert not cmd.gateway_only + + +# --------------------------------------------------------------------------- +# reclaim + reassign CLI smoke tests +# --------------------------------------------------------------------------- + +def test_run_slash_reclaim_running_task(kanban_home): + import re + import time + import secrets + from hermes_cli import kanban_db as kb + + out1 = kc.run_slash("create 'stuck worker task' --assignee broken-model") + m = re.search(r"(t_[a-f0-9]+)", out1) + assert m + tid = m.group(1) + + # Simulate a running claim outside TTL. + conn = kb.connect() + try: + lock = secrets.token_hex(4) + conn.execute( + "UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, " + "worker_pid=? WHERE id=?", + (lock, int(time.time()) + 3600, 4242, tid), + ) + conn.execute( + "INSERT INTO task_runs (task_id, status, claim_lock, claim_expires, " + "worker_pid, started_at) VALUES (?, 'running', ?, ?, ?, ?)", + (tid, lock, int(time.time()) + 3600, 4242, int(time.time())), + ) + rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + conn.execute("UPDATE tasks SET current_run_id=? WHERE id=?", (rid, tid)) + conn.commit() + finally: + conn.close() + + out = kc.run_slash(f"reclaim {tid} --reason 'test'") + assert "Reclaimed" in out, out + # Status back to ready. + out2 = kc.run_slash(f"show {tid}") + assert "ready" in out2.lower() + + +def test_run_slash_reassign_with_reclaim_flag(kanban_home): + import re + import time + import secrets + from hermes_cli import kanban_db as kb + + out1 = kc.run_slash("create 'switch model' --assignee orig") + m = re.search(r"(t_[a-f0-9]+)", out1) + tid = m.group(1) + + # Simulate a running claim. + conn = kb.connect() + try: + lock = secrets.token_hex(4) + conn.execute( + "UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, " + "worker_pid=? WHERE id=?", + (lock, int(time.time()) + 3600, 4242, tid), + ) + conn.execute( + "INSERT INTO task_runs (task_id, status, claim_lock, claim_expires, " + "worker_pid, started_at) VALUES (?, 'running', ?, ?, ?, ?)", + (tid, lock, int(time.time()) + 3600, 4242, int(time.time())), + ) + rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + conn.execute("UPDATE tasks SET current_run_id=? WHERE id=?", (rid, tid)) + conn.commit() + finally: + conn.close() + + out = kc.run_slash(f"reassign {tid} newbie --reclaim --reason 'switch'") + assert "Reassigned" in out, out + out2 = kc.run_slash(f"show {tid}") + assert "newbie" in out2 -
tests/hermes_cli/test_kanban_core_functionality.py@@ -2786,3 +2786,269 @@ def test_gateway_dispatcher_watcher_env_truthy_uses_config(monkeypatch): timeout=3.0, ) ) + + +# --------------------------------------------------------------------------- +# Hallucination gate (created_cards verify + prose scan) +# --------------------------------------------------------------------------- + +def test_complete_with_created_cards_all_verified_records_manifest(kanban_home): + """A completion with created_cards that all exist + belong to this + worker records them on the ``completed`` event payload.""" + conn = kb.connect() + try: + parent = kb.create_task(conn, title="parent", assignee="alice") + c1 = kb.create_task(conn, title="c1", assignee="x", created_by="alice") + c2 = kb.create_task(conn, title="c2", assignee="y", created_by="alice") + ok = kb.complete_task( + conn, parent, + summary="done, created c1+c2", + created_cards=[c1, c2], + ) + assert ok is True + evs = list(conn.execute( + "SELECT kind, payload FROM task_events WHERE task_id=? ORDER BY id", + (parent,), + )) + completed = [e for e in evs if e["kind"] == "completed"] + assert len(completed) == 1 + import json as _json + payload = _json.loads(completed[0]["payload"]) + assert payload.get("verified_cards") == [c1, c2] + finally: + conn.close() + + +def test_complete_with_phantom_created_cards_raises_and_audits(kanban_home): + """A completion claiming a card id that doesn't exist raises + HallucinatedCardsError, leaves the task in its prior state, and + records a ``completion_blocked_hallucination`` event for auditing.""" + conn = kb.connect() + try: + parent = kb.create_task(conn, title="parent", assignee="alice") + real = kb.create_task(conn, title="real", assignee="x", created_by="alice") + phantom_id = "t_deadbeefcafe" + + with pytest.raises(kb.HallucinatedCardsError) as excinfo: + kb.complete_task( + conn, parent, + summary="claimed phantom", + created_cards=[real, phantom_id], + ) + assert excinfo.value.phantom == [phantom_id] + + # Task still in prior state (ready, not done). + row = conn.execute( + "SELECT status FROM tasks WHERE id=?", (parent,), + ).fetchone() + assert row["status"] == "ready" + + # Audit event landed. + kinds = [ + r["kind"] for r in conn.execute( + "SELECT kind FROM task_events WHERE task_id=? ORDER BY id", + (parent,), + ) + ] + assert "completion_blocked_hallucination" in kinds + assert "completed" not in kinds + finally: + conn.close() + + +def test_complete_with_cross_worker_card_is_rejected(kanban_home): + """A card that exists but was created by a different worker profile + is treated as phantom (hallucinated attribution).""" + conn = kb.connect() + try: + parent = kb.create_task(conn, title="parent", assignee="alice") + other = kb.create_task(conn, title="other", assignee="x", created_by="bob") + + with pytest.raises(kb.HallucinatedCardsError) as excinfo: + kb.complete_task( + conn, parent, + summary="claiming someone else's card", + created_cards=[other], + ) + assert excinfo.value.phantom == [other] + finally: + conn.close() + + +def test_complete_prose_scan_flags_nonexistent_ids(kanban_home): + """Successful completion whose summary references a ``t_<hex>`` id + that doesn't resolve emits a ``suspected_hallucinated_references`` + event. Does not block the completion.""" + conn = kb.connect() + try: + parent = kb.create_task(conn, title="parent", assignee="x") + ok = kb.complete_task( + conn, parent, + summary="also saw t_abcd1234ffff failing in CI", + ) + assert ok is True + kinds_and_payloads = list(conn.execute( + "SELECT kind, payload FROM task_events WHERE task_id=? ORDER BY id", + (parent,), + )) + kinds = [r["kind"] for r in kinds_and_payloads] + assert "suspected_hallucinated_references" in kinds + import json as _json + susp = [ + _json.loads(r["payload"]) + for r in kinds_and_payloads + if r["kind"] == "suspected_hallucinated_references" + ][0] + assert "t_abcd1234ffff" in susp["phantom_refs"] + finally: + conn.close() + + +def test_complete_prose_scan_ignores_existing_ids(kanban_home): + """Summaries referencing real task ids don't emit a warning.""" + conn = kb.connect() + try: + other = kb.create_task(conn, title="other", assignee="x") + parent = kb.create_task(conn, title="parent", assignee="x") + ok = kb.complete_task( + conn, parent, + summary=f"depended on {other}, now done", + ) + assert ok is True + kinds = [ + r["kind"] for r in conn.execute( + "SELECT kind FROM task_events WHERE task_id=? ORDER BY id", + (parent,), + ) + ] + assert "suspected_hallucinated_references" not in kinds + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Recovery helpers (reclaim + reassign) +# --------------------------------------------------------------------------- + +def test_reclaim_task_resets_running_to_ready(kanban_home): + """Manual reclaim releases the claim, resets status, and emits a + ``reclaimed`` event even when claim_expires has not passed.""" + import time + import secrets + conn = kb.connect() + try: + t = kb.create_task(conn, title="stuck", assignee="broken") + # Simulate a live claim (not expired). + lock = secrets.token_hex(8) + future = int(time.time()) + 3600 + conn.execute( + "UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, " + "worker_pid=? WHERE id=?", + (lock, future, 12345, t), + ) + conn.execute( + "INSERT INTO task_runs (task_id, status, claim_lock, claim_expires, " + "worker_pid, started_at) VALUES (?, 'running', ?, ?, ?, ?)", + (t, lock, future, 12345, int(time.time())), + ) + run_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + conn.execute("UPDATE tasks SET current_run_id=? WHERE id=?", (run_id, t)) + conn.commit() + + # release_stale_claims should NOT reclaim (not expired). + assert kb.release_stale_claims(conn) == 0 + + # reclaim_task should work immediately. + assert kb.reclaim_task(conn, t, reason="test reason") is True + + row = conn.execute( + "SELECT status, claim_lock, worker_pid FROM tasks WHERE id=?", + (t,), + ).fetchone() + assert row["status"] == "ready" + assert row["claim_lock"] is None + assert row["worker_pid"] is None + + import json as _json + reclaim_evs = [ + _json.loads(r["payload"]) + for r in conn.execute( + "SELECT payload FROM task_events WHERE task_id=? AND kind='reclaimed'", + (t,), + ) + ] + assert len(reclaim_evs) == 1 + assert reclaim_evs[0].get("manual") is True + assert reclaim_evs[0].get("reason") == "test reason" + finally: + conn.close() + + +def test_reclaim_task_returns_false_for_already_ready(kanban_home): + """Reclaiming a task that's not running returns False (no-op).""" + conn = kb.connect() + try: + t = kb.create_task(conn, title="ready task", assignee="x") + assert kb.reclaim_task(conn, t) is False + finally: + conn.close() + + +def test_reassign_task_refuses_running_without_reclaim_first(kanban_home): + """Without ``reclaim_first=True``, reassigning a running task is a + no-op returning False (matches assign_task's RuntimeError via + internal catch).""" + conn = kb.connect() + try: + t = kb.create_task(conn, title="running", assignee="orig") + conn.execute( + "UPDATE tasks SET status='running', claim_lock=? WHERE id=?", + ("live", t), + ) + conn.commit() + assert kb.reassign_task(conn, t, "new") is False + # Assignee unchanged. + row = conn.execute( + "SELECT assignee FROM tasks WHERE id=?", (t,), + ).fetchone() + assert row["assignee"] == "orig" + finally: + conn.close() + + +def test_reassign_task_with_reclaim_first_switches_profile(kanban_home): + """With ``reclaim_first=True``, a running task is reclaimed and + reassigned in one operation.""" + import time + import secrets + conn = kb.connect() + try: + t = kb.create_task(conn, title="switch me", assignee="orig") + lock = secrets.token_hex(8) + future = int(time.time()) + 3600 + conn.execute( + "UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, " + "worker_pid=? WHERE id=?", + (lock, future, 99999, t), + ) + conn.execute( + "INSERT INTO task_runs (task_id, status, claim_lock, claim_expires, " + "worker_pid, started_at) VALUES (?, 'running', ?, ?, ?, ?)", + (t, lock, future, 99999, int(time.time())), + ) + run_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + conn.execute("UPDATE tasks SET current_run_id=? WHERE id=?", (run_id, t)) + conn.commit() + + assert kb.reassign_task( + conn, t, "new-profile", + reclaim_first=True, reason="switch model", + ) is True + + row = conn.execute( + "SELECT assignee, status FROM tasks WHERE id=?", (t,), + ).fetchone() + assert row["assignee"] == "new-profile" + assert row["status"] == "ready" + finally: + conn.close() -
tests/plugins/test_kanban_dashboard_plugin.py@@ -1117,3 +1117,221 @@ def test_home_channels_empty_when_no_homes_configured(client, monkeypatch): r = client.get("/api/plugins/kanban/home-channels") assert r.status_code == 200 assert r.json()["home_channels"] == [] + + +# --------------------------------------------------------------------------- +# Recovery endpoints (reclaim + reassign) and warnings field +# --------------------------------------------------------------------------- + +def test_board_surfaces_warnings_field_for_hallucinated_completions(client): + """Tasks with a pending completion_blocked_hallucination event surface + a ``warnings`` object on the /board payload so the UI can badge + them without fetching per-task events.""" + conn = kb.connect() + try: + parent = kb.create_task(conn, title="parent", assignee="alice") + real = kb.create_task(conn, title="real", assignee="x", created_by="alice") + + import pytest as _pytest + with _pytest.raises(kb.HallucinatedCardsError): + kb.complete_task( + conn, parent, + summary="claimed phantom", + created_cards=[real, "t_deadbeefcafe"], + ) + finally: + conn.close() + + r = client.get("/api/plugins/kanban/board") + assert r.status_code == 200 + data = r.json() + tasks = [t for col in data["columns"] for t in col["tasks"]] + parent_dict = next(t for t in tasks if t["title"] == "parent") + assert parent_dict.get("warnings") is not None + w = parent_dict["warnings"] + assert w["count"] >= 1 + assert "completion_blocked_hallucination" in w["kinds"] + + +def test_board_warnings_cleared_after_clean_completion(client): + """A completed or edited event after a hallucination event clears + the warning badge — we don't mark tasks permanently.""" + conn = kb.connect() + try: + parent = kb.create_task(conn, title="parent", assignee="alice") + real = kb.create_task(conn, title="real", assignee="x", created_by="alice") + + import pytest as _pytest + with _pytest.raises(kb.HallucinatedCardsError): + kb.complete_task( + conn, parent, + summary="first attempt phantom", + created_cards=[real, "t_phantom11"], + ) + + # Second attempt drops the bad id — succeeds. + ok = kb.complete_task( + conn, parent, + summary="retry without phantom", + created_cards=[real], + ) + assert ok is True + finally: + conn.close() + + r = client.get("/api/plugins/kanban/board", params={"include_archived": True}) + assert r.status_code == 200 + data = r.json() + tasks = [t for col in data["columns"] for t in col["tasks"]] + parent_dict = next(t for t in tasks if t["title"] == "parent") + # The clean completion wiped the warning. + assert parent_dict.get("warnings") is None + + +def test_reclaim_endpoint_releases_running_claim(client): + """POST /tasks/<id>/reclaim drops the claim, returns ok, and emits + a manual reclaimed event.""" + import secrets + conn = kb.connect() + try: + t = kb.create_task(conn, title="running", assignee="x") + lock = secrets.token_hex(8) + future = int(time.time()) + 3600 + conn.execute( + "UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, " + "worker_pid=? WHERE id=?", + (lock, future, 99999, t), + ) + conn.execute( + "INSERT INTO task_runs (task_id, status, claim_lock, claim_expires, " + "worker_pid, started_at) VALUES (?, 'running', ?, ?, ?, ?)", + (t, lock, future, 99999, int(time.time())), + ) + run_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + conn.execute("UPDATE tasks SET current_run_id=? WHERE id=?", (run_id, t)) + conn.commit() + finally: + conn.close() + + r = client.post( + f"/api/plugins/kanban/tasks/{t}/reclaim", + json={"reason": "browser recovery"}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["ok"] is True + assert body["task_id"] == t + + # Confirm the task is back to ready. + conn2 = kb.connect() + try: + row = conn2.execute( + "SELECT status, claim_lock FROM tasks WHERE id=?", (t,), + ).fetchone() + assert row["status"] == "ready" + assert row["claim_lock"] is None + finally: + conn2.close() + + +def test_reclaim_endpoint_409_for_non_running_task(client): + """Reclaiming a task that's already ready returns 409.""" + conn = kb.connect() + try: + t = kb.create_task(conn, title="ready", assignee="x") + finally: + conn.close() + + r = client.post( + f"/api/plugins/kanban/tasks/{t}/reclaim", + json={}, + ) + assert r.status_code == 409 + + +def test_reassign_endpoint_switches_profile(client): + """POST /tasks/<id>/reassign changes the assignee field.""" + conn = kb.connect() + try: + t = kb.create_task(conn, title="task", assignee="orig") + finally: + conn.close() + + r = client.post( + f"/api/plugins/kanban/tasks/{t}/reassign", + json={"profile": "newbie", "reclaim_first": False}, + ) + assert r.status_code == 200, r.text + assert r.json()["assignee"] == "newbie" + + conn2 = kb.connect() + try: + row = conn2.execute( + "SELECT assignee FROM tasks WHERE id=?", (t,), + ).fetchone() + assert row["assignee"] == "newbie" + finally: + conn2.close() + + +def test_reassign_endpoint_409_on_running_without_reclaim(client): + """Reassigning a running task without reclaim_first returns 409.""" + import secrets + conn = kb.connect() + try: + t = kb.create_task(conn, title="running", assignee="orig") + conn.execute( + "UPDATE tasks SET status='running', claim_lock=? WHERE id=?", + (secrets.token_hex(4), t), + ) + conn.commit() + finally: + conn.close() + + r = client.post( + f"/api/plugins/kanban/tasks/{t}/reassign", + json={"profile": "new", "reclaim_first": False}, + ) + assert r.status_code == 409 + + +def test_reassign_endpoint_with_reclaim_first_succeeds_on_running(client): + """With reclaim_first=true, a running task is reclaimed+reassigned in + one call.""" + import secrets + conn = kb.connect() + try: + t = kb.create_task(conn, title="running", assignee="orig") + lock = secrets.token_hex(4) + conn.execute( + "UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, " + "worker_pid=? WHERE id=?", + (lock, int(time.time()) + 3600, 1234, t), + ) + conn.execute( + "INSERT INTO task_runs (task_id, status, claim_lock, claim_expires, " + "worker_pid, started_at) VALUES (?, 'running', ?, ?, ?, ?)", + (t, lock, int(time.time()) + 3600, 1234, int(time.time())), + ) + rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + conn.execute("UPDATE tasks SET current_run_id=? WHERE id=?", (rid, t)) + conn.commit() + finally: + conn.close() + + r = client.post( + f"/api/plugins/kanban/tasks/{t}/reassign", + json={"profile": "new", "reclaim_first": True, "reason": "switch"}, + ) + assert r.status_code == 200, r.text + assert r.json()["assignee"] == "new" + + conn2 = kb.connect() + try: + row = conn2.execute( + "SELECT status, assignee FROM tasks WHERE id=?", (t,), + ).fetchone() + assert row["status"] == "ready" + assert row["assignee"] == "new" + finally: + conn2.close() -
tools/kanban_tools.py@@ -210,6 +210,20 @@ def _handle_complete(args: dict, **kw) -> str: summary = args.get("summary") metadata = args.get("metadata") result = args.get("result") + created_cards = args.get("created_cards") + if created_cards is not None: + if isinstance(created_cards, str): + # Accept a single id as a string for convenience. + created_cards = [created_cards] + if not isinstance(created_cards, (list, tuple)): + return tool_error( + f"created_cards must be a list of task ids, got " + f"{type(created_cards).__name__}" + ) + # Normalise: strings only, stripped, non-empty. + created_cards = [ + str(c).strip() for c in created_cards if str(c).strip() + ] if not (summary or result): return tool_error( "provide at least one of: summary (preferred), result" @@ -221,10 +235,23 @@ def _handle_complete(args: dict, **kw) -> str: try: kb, conn = _connect() try: - ok = kb.complete_task( - conn, tid, - result=result, summary=summary, metadata=metadata, - ) + try: + ok = kb.complete_task( + conn, tid, + result=result, summary=summary, metadata=metadata, + created_cards=created_cards, + ) + except kb.HallucinatedCardsError as hall_err: + # Structured rejection — surface the phantom ids so the + # worker can retry with a corrected list or drop the + # field. Audit event already landed in the DB. + return tool_error( + f"kanban_complete blocked: the following created_cards " + f"do not exist or were not created by this worker: " + f"{', '.join(hall_err.phantom)}. " + f"Either omit them, use only ids returned from successful " + f"kanban_create calls, or remove the created_cards field." + ) if not ok: return tool_error( f"could not complete {tid} (unknown id or already terminal)" @@ -452,7 +479,11 @@ def _handle_link(args: dict, **kw) -> str: "human-readable 1-3 sentence description of what you did; put " "machine-readable facts in ``metadata`` (changed_files, " "tests_run, decisions, findings, etc). At least one of " - "``summary`` or ``result`` is required." + "``summary`` or ``result`` is required. If you created new " + "tasks via ``kanban_create`` during this run, list their ids " + "in ``created_cards`` — the kernel verifies them so phantom " + "references are caught before they leak into downstream " + "automation." ), "parameters": { "type": "object", @@ -487,6 +518,22 @@ def _handle_link(args: dict, **kw) -> str: "callers that still set --result on the CLI." ), }, + "created_cards": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Optional structured manifest of task ids you " + "created via ``kanban_create`` during this run. " + "The kernel verifies each id exists and was " + "created by this worker's profile; any phantom " + "id blocks the completion with an error listing " + "what went wrong (auditable in the task's events). " + "Only list ids you got back from a successful " + "``kanban_create`` call — do not invent or " + "remember ids from prose. Omit the field if you " + "did not create any cards." + ), + }, }, "required": [], },
-
-
#31708 fix(kanban): containment + prevention for scratch-cleanup data loss (#28818)
files:
hermes_cli/kanban_db.py,scripts/release.py,tests/hermes_cli/test_kanban_db.py -
#41352 fix(kanban): defer scratch workspace cleanup until linked children finish (#33774)
files:
hermes_cli/kanban_db.py,tests/hermes_cli/test_kanban_db.pyShow the diff
-
hermes_cli/kanban_db.py@@ -3838,6 +3838,27 @@ def _cleanup_workspace(conn: sqlite3.Connection, task_id: str) -> None: kind: Optional[str] = row["workspace_kind"] path: Optional[str] = row["workspace_path"] if kind != "scratch" or not path: + # This task's own workspace isn't a removable scratch dir, but its + # completion may still unblock a deferred parent scratch cleanup + # (e.g. a 'dir' child whose scratch parent was waiting on it). #33774 + _try_cleanup_parent_workspaces(conn, task_id) + return + # Check if this task has children that still need the workspace. + # If any child is not yet done/archived, defer cleanup so the + # child can read handoff artifacts from the scratch dir (#33774). + _active_children = conn.execute( + "SELECT 1 FROM task_links l " + "JOIN tasks t ON t.id = l.child_id " + "WHERE l.parent_id = ? AND t.status NOT IN ('done', 'archived', 'failed', 'cancelled') " + "LIMIT 1", + (task_id,), + ).fetchone() + if _active_children: + _log.debug( + "Deferring scratch workspace cleanup for task %s: " + "active children still need workspace at %s", + task_id, path, + ) return import shutil wp = Path(path) @@ -3860,10 +3881,54 @@ def _cleanup_workspace(conn: sqlite3.Connection, task_id: str) -> None: # Also kill the tmux session for the worker that owned this task, # if the tmux session is now dead (worker process exited). _cleanup_worker_tmux(conn, task_id) + # After cleaning up this task's workspace, check if any parent + # tasks now have all children done — their deferred cleanup can + # proceed (#33774). + _try_cleanup_parent_workspaces(conn, task_id) except Exception: pass # best-effort — never block completion +def _try_cleanup_parent_workspaces(conn: sqlite3.Connection, task_id: str) -> None: + """Clean up parent scratch workspaces now that *task_id* completed. + + When a parent task's cleanup was deferred because it had active children, + this function is called after each child completes. If all children of a + parent are now done/archived/failed/cancelled, the parent's scratch + workspace is removed (#33774). + """ + try: + parents = conn.execute( + "SELECT parent_id FROM task_links WHERE child_id = ?", + (task_id,), + ).fetchall() + for (parent_id,) in parents: + row = conn.execute( + "SELECT workspace_kind, workspace_path FROM tasks WHERE id = ?", + (parent_id,), + ).fetchone() + if not row or row["workspace_kind"] != "scratch" or not row["workspace_path"]: + continue + # Check if ALL children of this parent are terminal + active = conn.execute( + "SELECT 1 FROM task_links l " + "JOIN tasks t ON t.id = l.child_id " + "WHERE l.parent_id = ? AND t.status NOT IN ('done', 'archived', 'failed', 'cancelled') " + "LIMIT 1", + (parent_id,), + ).fetchone() + if active: + continue # still has active children + # All children done — safe to clean up parent workspace + import shutil + wp = Path(row["workspace_path"]) + if wp.is_dir() and _is_managed_scratch_path(wp): + shutil.rmtree(wp, ignore_errors=True) + _log.debug("Deferred cleanup: removed parent %s scratch workspace: %s", parent_id, wp) + except Exception: + pass # best-effort + + def _cleanup_worker_tmux(conn: sqlite3.Connection, task_id: str) -> None: """Kill the tmux session associated with a task's assignee, if dead.""" try: -
tests/hermes_cli/test_kanban_db.py@@ -2006,6 +2006,91 @@ def test_cleanup_workspace_honors_workspaces_root_env_override(tmp_path, monkeyp assert not scratch_dir.exists(), "Override-root scratch dir should be cleaned up" +# --------------------------------------------------------------------------- +# Deferred scratch cleanup for parent/child handoff (#33774) +# --------------------------------------------------------------------------- + +def test_cleanup_workspace_deferred_while_child_active(kanban_home): + """A scratch parent's workspace survives completion while a child is still active. + + The dependency chain (parents=[A]) must guarantee child B can read A's + handoff artifacts. The old cleanup deleted A's scratch dir immediately on + A's completion, before B ever ran. + """ + with kb.connect() as conn: + parent = kb.create_task(conn, title="parent") + child = kb.create_task(conn, title="child") + kb.link_tasks(conn, parent, child) # child depends on parent + p_task = kb.get_task(conn, parent) + parent_ws = kb.resolve_workspace(p_task) + kb.set_workspace_path(conn, parent, parent_ws) + assert parent_ws.is_dir() + # Parent completes; child is still 'todo' -> cleanup must be deferred. + kb.complete_task(conn, parent, result="handoff written") + + assert parent_ws.exists(), ( + "Parent scratch workspace must survive while a linked child is active" + ) + + +def test_cleanup_workspace_swept_after_last_child_completes(kanban_home): + """Once all children are terminal, the deferred parent scratch dir is removed.""" + with kb.connect() as conn: + parent = kb.create_task(conn, title="parent") + child = kb.create_task(conn, title="child") + kb.link_tasks(conn, parent, child) + p_task = kb.get_task(conn, parent) + parent_ws = kb.resolve_workspace(p_task) + kb.set_workspace_path(conn, parent, parent_ws) + # Give the child its own scratch dir too. + c_task = kb.get_task(conn, child) + child_ws = kb.resolve_workspace(c_task) + kb.set_workspace_path(conn, child, child_ws) + + kb.complete_task(conn, parent, result="ok") + assert parent_ws.exists(), "deferred while child active" + + # Child completes -> recompute promotes nothing new; the child's + # cleanup sweep should now reap the parent's deferred workspace. + kb.complete_task(conn, child, result="done") + + assert not parent_ws.exists(), ( + "Parent scratch workspace should be swept once all children are terminal" + ) + assert not child_ws.exists(), "Child scratch workspace should be cleaned up too" + + +def test_dir_child_completion_unblocks_deferred_scratch_parent(kanban_home, tmp_path): + """A non-scratch ('dir') child completing must still sweep its scratch parent. + + Regression for the gap where ``_cleanup_workspace`` returned early for a + non-scratch task and never ran the parent sweep — leaking the parent's + deferred scratch dir forever. + """ + child_dir = tmp_path / "persistent-child" + child_dir.mkdir() + with kb.connect() as conn: + parent = kb.create_task(conn, title="scratch parent") + child = kb.create_task( + conn, title="dir child", workspace_kind="dir", + workspace_path=str(child_dir), + ) + kb.link_tasks(conn, parent, child) + p_task = kb.get_task(conn, parent) + parent_ws = kb.resolve_workspace(p_task) + kb.set_workspace_path(conn, parent, parent_ws) + + kb.complete_task(conn, parent, result="handoff") + assert parent_ws.exists(), "deferred while dir child active" + + kb.complete_task(conn, child, result="built") + + assert not parent_ws.exists(), ( + "A 'dir' child completing must trigger the parent scratch sweep" + ) + assert child_dir.exists(), "Non-scratch 'dir' child workspace is never deleted" + + def test_is_managed_scratch_path_accepts_per_board_workspaces(kanban_home, tmp_path): """Per-board scratch dirs under ``<kanban_home>/kanban/boards/<slug>/workspaces`` are managed.""" board_scratch = kanban_home / "kanban" / "boards" / "my-board" / "workspaces" / "task-1"
-
-
#63619 fix(kanban): preserve completion artifacts before scratch cleanup
files:
gateway/platforms/base.py,hermes_cli/kanban_db.py,plugins/kanban/dashboard/plugin_api.py,scripts/release.py,tests/gateway/test_platform_base.py,tests/hermes_cli/test_kanban_db.py,tests/tools/test_kanban_tools.py,tools/kanban_tools.py,website/docs/user-guide/features/kanban.md,website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.mdShow the diff
-
gateway/platforms/base.py@@ -1072,10 +1072,33 @@ def _profile_cache_roots() -> List[Path]: return roots +def _kanban_attachment_roots() -> List[Path]: + """Return durable Kanban attachment roots without importing kanban_db.""" + override = os.environ.get("HERMES_KANBAN_ATTACHMENTS_ROOT", "").strip() + if override: + return [Path(override).expanduser()] + home_override = os.environ.get("HERMES_KANBAN_HOME", "").strip() + root = Path(home_override).expanduser() if home_override else _HERMES_ROOT + roots = [root / "kanban" / "attachments"] + boards_root = root / "kanban" / "boards" + try: + board_dirs = [ + path for path in boards_root.iterdir() + if path.is_dir() and not path.is_symlink() + and re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", path.name) + and (path / "kanban.db").is_file() + ] + except OSError: + return roots + roots.extend(path / "attachments" for path in board_dirs) + return roots + + def _media_delivery_allowed_roots() -> List[Path]: """Return roots from which model-emitted local media may be delivered.""" roots = [Path(root) for root in MEDIA_DELIVERY_SAFE_ROOTS] roots.extend(_profile_cache_roots()) + roots.extend(_kanban_attachment_roots()) extra_roots = os.environ.get(MEDIA_DELIVERY_ALLOW_DIRS_ENV, "") for chunk in extra_roots.split(os.pathsep): for raw_root in chunk.split(","): -
hermes_cli/kanban_db.py@@ -135,6 +135,7 @@ VALID_WORKSPACE_KINDS = {"scratch", "worktree", "dir"} KNOWN_TOOLSET_NAMES = frozenset(name.casefold() for name in get_toolset_names()) _IS_WINDOWS = sys.platform == "win32" +KANBAN_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024 def _fire_kanban_lifecycle_hook(event: str, task_id: str, **fields: Any) -> None: @@ -3975,6 +3976,10 @@ def __init__(self, phantom: list[str], completing_task_id: str): ) +class ArtifactPreservationError(RuntimeError): + """Raised when a declared scratch deliverable cannot be preserved.""" + + def complete_task( conn: sqlite3.Connection, task_id: str, @@ -4042,6 +4047,9 @@ def complete_task( else: verified_cards = [] + metadata = _merge_completion_prose_artifacts( + conn, task_id, metadata, summary=summary, result=result, + ) with write_txn(conn): if expected_run_id is None: cur = conn.execute( @@ -4080,6 +4088,18 @@ def complete_task( ) if cur.rowcount != 1: return False + if isinstance(metadata, dict): + _persist_scratch_completion_artifacts(conn, task_id, metadata) + for stored_path in metadata.pop("_staged_artifacts", []): + path = Path(stored_path) + _insert_completion_attachment( + conn, + task_id, + filename=path.name, + stored_path=str(path), + size=path.stat().st_size, + created_at=now, + ) run_id = _end_run( conn, task_id, outcome="completed", status="done", @@ -4174,40 +4194,215 @@ def complete_task( # Workspace / tmux cleanup # --------------------------------------------------------------------------- -def _is_managed_scratch_path(p: Path) -> bool: - """Return True iff *p* is a strict descendant of a kanban-managed scratch root. - A managed root is exclusively a ``workspaces/`` directory — never the - broader kanban home, a board root, or sibling subtrees like ``logs/`` or - ``boards/<slug>/`` itself. Allowed roots: +def _merge_completion_prose_artifacts( + conn: sqlite3.Connection, + task_id: str, + metadata: Optional[dict], + *, + summary: Optional[str], + result: Optional[str], +) -> Optional[dict]: + """Promote existing scratch files named in legacy completion prose. + + ``artifacts=[...]`` is preferred. Older workers only wrote an absolute + deliverable path in ``summary``/``result``; discover it while scratch still + exists so cleanup cannot erase the file the user was promised. + """ + row = conn.execute( + "SELECT workspace_kind, workspace_path FROM tasks WHERE id = ?", + (task_id,), + ).fetchone() + if not row or row["workspace_kind"] != "scratch" or not row["workspace_path"]: + return metadata + workspace = Path(row["workspace_path"]).expanduser() + if not _is_managed_scratch_path(workspace): + return metadata + text = "\n".join(part for part in (summary, result) if part) + if not text: + return metadata + prefix = re.escape(str(workspace)) + discovered: list[str] = [] + for match in re.finditer(prefix + r"(?:[/\\][^\s`\"'<>]+)", text): + raw = match.group(0).rstrip(".,;:!?)]}") + candidate = Path(raw) + if candidate.is_file(): + discovered.append(str(candidate)) + if not discovered: + return metadata + updated = dict(metadata) if isinstance(metadata, dict) else {} + existing = updated.get("artifacts") + merged = list(existing) if isinstance(existing, (list, tuple)) else [] + seen = {str(path) for path in merged} + for path in discovered: + if path not in seen: + merged.append(path) + seen.add(path) + updated["artifacts"] = merged + return updated + + +def _persist_scratch_completion_artifacts( + conn: sqlite3.Connection, + task_id: str, + metadata: dict, +) -> None: + """Copy scratch-workspace completion artifacts before cleanup removes them.""" + raw_artifacts = metadata.get("artifacts") + if not isinstance(raw_artifacts, (list, tuple)): + return - * ``HERMES_KANBAN_WORKSPACES_ROOT`` when set (worker-side override - injected by the dispatcher). - * ``<kanban_home>/kanban/workspaces`` — legacy default-board scratch root. - * ``<kanban_home>/kanban/boards/<slug>/workspaces`` for each board slug - that currently exists on disk. + row = conn.execute( + "SELECT workspace_kind, workspace_path FROM tasks WHERE id = ?", + (task_id,), + ).fetchone() + if not row or row["workspace_kind"] != "scratch" or not row["workspace_path"]: + return - The check requires strict descendancy: a path equal to one of these - roots is NOT managed (deleting the workspaces root would wipe every - task's scratch dir at once), and a path that resolves to ``<kanban_home> - /kanban`` itself, ``<kanban_home>/kanban/logs``, or - ``<kanban_home>/kanban/boards/<slug>`` is rejected because those - subtrees hold Hermes' own DB, metadata, and logs, not task workspaces. + workspace = Path(row["workspace_path"]).expanduser() + is_managed, board = _managed_scratch_path_info(workspace) + if not is_managed: + return - Used by :func:`_cleanup_workspace` to refuse to ``shutil.rmtree`` paths - outside Hermes-managed storage. A board ``default_workdir`` pointing at a - real source tree can otherwise pair with ``workspace_kind='scratch'`` and - cause task completion to delete user data (#28818). - """ + try: + workspace_root = workspace.resolve() + except OSError: + return + + attachment_dir = task_attachments_dir(task_id, board=board) + persisted: list[str] = [] + used_destinations: set[Path] = set() + changed = False + + def _discard_copies() -> None: + for copied in used_destinations: + try: + copied.unlink(missing_ok=True) + except OSError: + pass + try: + attachment_dir.rmdir() + except OSError: + pass + + for item in raw_artifacts: + artifact = str(item).strip() if isinstance(item, str) else "" + if not artifact: + continue + src = Path(artifact).expanduser() + try: + resolved_src = src.resolve() + except OSError: + persisted.append(artifact) + continue + + if not resolved_src.is_relative_to(workspace_root): + persisted.append(artifact) + continue + + if not src.is_file(): + _discard_copies() + raise ArtifactPreservationError( + f"declared scratch artifact is unavailable or not a regular file: {artifact}" + ) + + size = resolved_src.stat().st_size + if size > KANBAN_ATTACHMENT_MAX_BYTES: + _discard_copies() + raise ArtifactPreservationError( + f"declared scratch artifact exceeds the " + f"{KANBAN_ATTACHMENT_MAX_BYTES}-byte limit: {artifact}" + ) + + dest: Optional[Path] = None + try: + attachment_dir.mkdir(parents=True, exist_ok=True) + dest = _unique_attachment_path(attachment_dir, resolved_src.name, used_destinations) + with resolved_src.open("rb") as source_file, dest.open("xb") as destination_file: + copied = 0 + while chunk := source_file.read(1024 * 1024): + copied += len(chunk) + if copied > KANBAN_ATTACHMENT_MAX_BYTES: + raise ArtifactPreservationError( + f"declared scratch artifact grew beyond the size limit: {artifact}" + ) + destination_file.write(chunk) + except Exception as exc: + if dest is not None: + try: + dest.unlink(missing_ok=True) + except OSError: + pass + _discard_copies() + if isinstance(exc, ArtifactPreservationError): + raise + raise ArtifactPreservationError( + f"could not preserve declared scratch artifact {artifact}: {exc}" + ) from exc + + used_destinations.add(dest) + persisted.append(str(dest.resolve())) + changed = True + + if changed: + metadata["artifacts"] = persisted + metadata["_staged_artifacts"] = [ + path for path in persisted if path.startswith(str(attachment_dir.resolve())) + ] + + +def _insert_completion_attachment( + conn: sqlite3.Connection, + task_id: str, + *, + filename: str, + stored_path: str, + size: int, + created_at: int, +) -> None: + """Record a worker-produced artifact in the existing attachment table.""" + conn.execute( + "INSERT INTO task_attachments " + "(task_id, filename, stored_path, content_type, size, uploaded_by, created_at) " + "VALUES (?, ?, ?, NULL, ?, 'kanban_complete', ?)", + (task_id, filename, stored_path, size, created_at), + ) + _append_event( + conn, + task_id, + "attached", + {"filename": filename, "size": size, "by": "kanban_complete"}, + ) + + +def _unique_attachment_path(directory: Path, filename: str, used: set[Path]) -> Path: + """Return a non-conflicting path under ``directory`` for ``filename``.""" + safe_name = Path(filename).name or "artifact" + candidate = directory / safe_name + if candidate not in used and not candidate.exists(): + return candidate + + stem = Path(safe_name).stem or "artifact" + suffix = Path(safe_name).suffix + idx = 1 + while True: + candidate = directory / f"{stem}_{idx}{suffix}" + if candidate not in used and not candidate.exists(): + return candidate + idx += 1 + + +def _managed_scratch_path_info(p: Path) -> tuple[bool, Optional[str]]: + """Return whether *p* is managed scratch storage and the matching board.""" try: p_abs = p.resolve(strict=False) except OSError: - return False - roots: list[Path] = [] + return False, None + roots: list[tuple[Path, Optional[str]]] = [] override = os.environ.get("HERMES_KANBAN_WORKSPACES_ROOT", "").strip() if override: try: - roots.append(Path(override).expanduser().resolve(strict=False)) + roots.append((Path(override).expanduser().resolve(strict=False), None)) except OSError: pass try: @@ -4216,7 +4411,7 @@ def _is_managed_scratch_path(p: Path) -> bool: home = None if home is not None: try: - roots.append((home / "kanban" / "workspaces").resolve(strict=False)) + roots.append(((home / "kanban" / "workspaces").resolve(strict=False), DEFAULT_BOARD)) except OSError: pass try: @@ -4235,18 +4430,47 @@ def _is_managed_scratch_path(p: Path) -> bool: except OSError: continue try: - roots.append((entry / "workspaces").resolve(strict=False)) + roots.append(((entry / "workspaces").resolve(strict=False), entry.name)) except OSError: continue - for root in roots: + for root, board in roots: if p_abs == root: continue try: if p_abs.is_relative_to(root): - return True + return True, board except ValueError: continue - return False + return False, None + + +def _is_managed_scratch_path(p: Path) -> bool: + """Return True iff *p* is a strict descendant of a kanban-managed scratch root. + + A managed root is exclusively a ``workspaces/`` directory — never the + broader kanban home, a board root, or sibling subtrees like ``logs/`` or + ``boards/<slug>/`` itself. Allowed roots: + + * ``HERMES_KANBAN_WORKSPACES_ROOT`` when set (worker-side override + injected by the dispatcher). + * ``<kanban_home>/kanban/workspaces`` — legacy default-board scratch root. + * ``<kanban_home>/kanban/boards/<slug>/workspaces`` for each board slug + that currently exists on disk. + + The check requires strict descendancy: a path equal to one of these + roots is NOT managed (deleting the workspaces root would wipe every + task's scratch dir at once), and a path that resolves to ``<kanban_home> + /kanban`` itself, ``<kanban_home>/kanban/logs``, or + ``<kanban_home>/kanban/boards/<slug>`` is rejected because those + subtrees hold Hermes' own DB, metadata, and logs, not task workspaces. + + Used by :func:`_cleanup_workspace` to refuse to ``shutil.rmtree`` paths + outside Hermes-managed storage. A board ``default_workdir`` pointing at a + real source tree can otherwise pair with ``workspace_kind='scratch'`` and + cause task completion to delete user data (#28818). + """ + is_managed, _board = _managed_scratch_path_info(p) + return is_managed def _cleanup_workspace(conn: sqlite3.Connection, task_id: str) -> None: -
plugins/kanban/dashboard/plugin_api.py@@ -646,7 +646,7 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)): # Cap a single upload so a runaway request can't fill the disk. 25 MB # comfortably covers PDFs, images, and source docs — the kanban use case. -_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024 +_MAX_ATTACHMENT_BYTES = kanban_db.KANBAN_ATTACHMENT_MAX_BYTES def _safe_attachment_name(raw: str) -> str: -
scripts/release.py@@ -239,6 +239,7 @@ "[redacted:email]": "w31rdm4ch1nZ", "[redacted:email]": "x7peeps", "[redacted:email]": "ahmadashfq", + "[redacted:email]": "yinkev", # scratch artifact preservation salvage "[redacted:email]": "rratmansky", "[redacted:email]": "lkz-de", "[redacted:email]": "salesondemandio", -
tests/gateway/test_platform_base.py@@ -786,6 +786,28 @@ def test_allows_operator_configured_extra_root(self, tmp_path, monkeypatch): assert BasePlatformAdapter.validate_media_delivery_path(str(media_file)) == str(media_file.resolve()) + def test_allows_stale_kanban_attachment_but_not_neighboring_workspace( + self, tmp_path, monkeypatch, + ): + """Strict mode trusts durable attachments without trusting scratch.""" + self._patch_roots(monkeypatch) + monkeypatch.setenv("HERMES_KANBAN_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "0") + board_root = tmp_path / "hermes" / "kanban" / "boards" / "research" + board_root.mkdir(parents=True) + (board_root / "kanban.db").touch() + attachment = board_root / "attachments" / "t_12345678" / "report.pdf" + scratch = board_root / "workspaces" / "t_12345678" / "notes.txt" + attachment.parent.mkdir(parents=True) + scratch.parent.mkdir(parents=True) + attachment.write_bytes(b"%PDF") + scratch.write_text("private", encoding="utf-8") + + assert BasePlatformAdapter.validate_media_delivery_path(str(attachment)) == str( + attachment.resolve() + ) + assert BasePlatformAdapter.validate_media_delivery_path(str(scratch)) is None + def test_recency_trust_allows_freshly_produced_file(self, tmp_path, monkeypatch): """A PDF the agent just wrote to /tmp should be deliverable. -
tests/hermes_cli/test_kanban_db.py@@ -2363,6 +2363,175 @@ def test_cleanup_workspace_removes_managed_scratch_dir(kanban_home): assert not ws.exists(), "Hermes-managed scratch dir should be cleaned up" +def test_complete_task_persists_scratch_artifacts_before_cleanup(kanban_home): + """Completion artifacts from scratch workspaces survive workspace cleanup.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="render chart") + task = kb.get_task(conn, t) + ws = kb.resolve_workspace(task) + kb.set_workspace_path(conn, t, ws) + artifact = ws / "chart.png" + artifact.write_bytes(b"png-bytes") + + assert kb.complete_task( + conn, + t, + result="ok", + metadata={"artifacts": [str(artifact)]}, + ) + + completed = [e for e in kb.list_events(conn, t) if e.kind == "completed"][-1] + persisted = Path(completed.payload["artifacts"][0]) + run = kb.latest_run(conn, t) + + assert not ws.exists(), "scratch workspace should still be cleaned up" + assert persisted.exists(), "artifact copy should survive scratch cleanup" + assert persisted.parent == kb.task_attachments_dir(t) + assert persisted.name == "chart.png" + assert persisted.read_bytes() == b"png-bytes" + assert str(persisted) != str(artifact) + assert run is not None + assert run.metadata["artifacts"] == [str(persisted)] + with kb.connect() as conn: + attachments = kb.list_attachments(conn, t) + assert [(a.filename, a.stored_path) for a in attachments] == [ + ("chart.png", str(persisted.resolve())) + ] + + +def test_complete_task_rejects_missing_declared_scratch_artifact(kanban_home): + """A declared scratch deliverable must not disappear behind a false Done.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="missing report") + task = kb.get_task(conn, t) + ws = kb.resolve_workspace(task) + kb.set_workspace_path(conn, t, ws) + missing = ws / "report.md" + + with pytest.raises(kb.ArtifactPreservationError, match="unavailable"): + kb.complete_task( + conn, + t, + result="report complete", + metadata={"artifacts": [str(missing)]}, + ) + + assert kb.get_task(conn, t).status == "ready" + assert kb.list_attachments(conn, t) == [] + assert ws.exists(), "failed completion must keep scratch available for retry" + + +def test_complete_task_preserves_legacy_artifact_path_from_summary(kanban_home): + """Summary-only workers keep the file they tell the user was delivered.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="legacy report") + task = kb.get_task(conn, t) + ws = kb.resolve_workspace(task) + kb.set_workspace_path(conn, t, ws) + report = ws / "report.md" + report.write_text("legacy deliverable", encoding="utf-8") + + assert kb.complete_task( + conn, + t, + summary=f"Task complete — delivered {report}", + ) + run = kb.latest_run(conn, t) + + persisted = Path(run.metadata["artifacts"][0]) + assert not ws.exists() + assert persisted.read_text(encoding="utf-8") == "legacy deliverable" + assert persisted.parent == kb.task_attachments_dir(t) + + +def test_complete_task_leaves_non_scratch_artifact_paths_unchanged( + kanban_home, + tmp_path, +): + """Only artifacts inside the managed scratch workspace are copied.""" + external = tmp_path / "report.md" + external.write_text("keep me here", encoding="utf-8") + + with kb.connect() as conn: + t = kb.create_task(conn, title="external report") + task = kb.get_task(conn, t) + ws = kb.resolve_workspace(task) + kb.set_workspace_path(conn, t, ws) + + assert kb.complete_task( + conn, + t, + result="ok", + metadata={"artifacts": [str(external)]}, + ) + + completed = [e for e in kb.list_events(conn, t) if e.kind == "completed"][-1] + run = kb.latest_run(conn, t) + + assert not ws.exists(), "scratch workspace should still be cleaned up" + assert external.exists() + assert completed.payload["artifacts"] == [str(external)] + assert run is not None + assert run.metadata["artifacts"] == [str(external)] + + +def test_complete_task_persists_duplicate_scratch_artifact_names(kanban_home): + """Scratch artifact persistence does not overwrite duplicate basenames.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="render reports") + task = kb.get_task(conn, t) + ws = kb.resolve_workspace(task) + kb.set_workspace_path(conn, t, ws) + first = ws / "a" / "report.txt" + second = ws / "b" / "report.txt" + first.parent.mkdir(parents=True) + second.parent.mkdir(parents=True) + first.write_text("first", encoding="utf-8") + second.write_text("second", encoding="utf-8") + + assert kb.complete_task( + conn, + t, + result="ok", + metadata={"artifacts": [str(first), str(second)]}, + ) + + completed = [e for e in kb.list_events(conn, t) if e.kind == "completed"][-1] + persisted = [Path(p) for p in completed.payload["artifacts"]] + + assert not ws.exists(), "scratch workspace should still be cleaned up" + assert [p.name for p in persisted] == ["report.txt", "report_1.txt"] + assert [p.read_text(encoding="utf-8") for p in persisted] == ["first", "second"] + assert all(p.parent == kb.task_attachments_dir(t) for p in persisted) + + +def test_complete_task_persists_board_scratch_artifacts_to_board_attachments(kanban_home): + """Board scratch artifacts are copied under that board's attachment root.""" + kb.create_board("work-proj") + + with kb.connect(board="work-proj") as conn: + t = kb.create_task(conn, title="board chart", board="work-proj") + task = kb.get_task(conn, t) + ws = kb.resolve_workspace(task, board="work-proj") + kb.set_workspace_path(conn, t, ws) + artifact = ws / "chart.png" + artifact.write_bytes(b"board-png") + + assert kb.complete_task( + conn, + t, + result="ok", + metadata={"artifacts": [str(artifact)]}, + ) + + completed = [e for e in kb.list_events(conn, t) if e.kind == "completed"][-1] + persisted = Path(completed.payload["artifacts"][0]) + + assert not ws.exists(), "board scratch workspace should still be cleaned up" + assert persisted.exists() + assert persisted.parent == kb.task_attachments_dir(t, board="work-proj") + + def test_cleanup_workspace_refuses_path_outside_scratch_root(kanban_home, tmp_path): """A scratch task with a user path outside the workspaces root must NOT be deleted (#28818). -
tests/tools/test_kanban_tools.py@@ -484,6 +484,31 @@ def test_complete_rejects_non_list_artifacts(worker_env): assert "artifacts must be a list" in err +def test_complete_missing_scratch_artifact_stays_in_flight(worker_env): + """A false deliverable claim must return retry guidance, not mark Done.""" + from hermes_cli import kanban_db as kb + from tools import kanban_tools as kt + + with kb.connect() as conn: + task = kb.get_task(conn, worker_env) + assert task is not None + workspace = kb.resolve_workspace(task) + kb.set_workspace_path(conn, worker_env, workspace) + + output = kt._handle_complete({ + "summary": "report complete", + "artifacts": [str(workspace / "missing-report.md")], + }) + error = json.loads(output).get("error", "") + + assert "could not preserve" in error + assert "still in-flight" in error + assert "retry kanban_complete" in error + with kb.connect() as conn: + assert kb.get_task(conn, worker_env).status == "running" + assert workspace.exists() + + def test_complete_rejects_no_handoff(worker_env): from tools import kanban_tools as kt out = kt._handle_complete({}) -
tools/kanban_tools.py@@ -630,6 +630,13 @@ def _handle_complete(args: dict, **kw) -> str: created_cards=created_cards, expected_run_id=_worker_run_id(tid), ) + except kb.ArtifactPreservationError as artifact_err: + return tool_error( + f"kanban_complete could not preserve the declared artifacts: " + f"{artifact_err}. Your task is still in-flight and its " + f"scratch workspace was kept. Fix the artifact path or " + f"storage error, then retry kanban_complete with the same handoff." + ) except kb.HallucinatedCardsError as hall_err: # Structured rejection — surface the phantom ids so the # worker can retry with a corrected list or drop the @@ -1277,8 +1284,10 @@ def _board_schema_prop() -> dict[str, str]: "lands with the completion notification. Skip " "intermediate scratch files and references that " "are not the deliverable. The path must exist " - "on disk when the notifier runs; missing files " - "are silently skipped." + "on disk at completion. Files inside a managed scratch " + "workspace are copied to durable task attachments before " + "cleanup; a missing declared scratch artifact keeps the " + "task in-flight so you can fix the path and retry." ), }, "board": _board_schema_prop(), -
website/docs/user-guide/features/kanban.md@@ -63,7 +63,7 @@ They coexist: a kanban worker may call `delegate_task` internally during its run - **Link** — `task_links` row recording a parent → child dependency. The dispatcher promotes `todo → ready` when all parents are `done`. - **Comment** — the inter-agent protocol. Agents and humans append comments; when a worker is (re-)spawned it reads the full comment thread as part of its context. - **Workspace** — the directory a worker operates in. Three kinds: - - `scratch` (default) — fresh tmp dir under `~/.hermes/kanban/workspaces/<id>/` (or `~/.hermes/kanban/boards/<slug>/workspaces/<id>/` on non-default boards). **Deleted when the task completes** — scratch is ephemeral by design, so the dir is wiped the moment the worker (or `hermes kanban complete <id>`) marks the task done. If you want to keep the worker's output, use `worktree:` or `dir:<path>` instead. The first time a scratch workspace is created on an install, the dispatcher logs a warning and emits a `tip_scratch_workspace` event on the task (visible via `hermes kanban show <id>`). + - `scratch` (default) — fresh tmp dir under `~/.hermes/kanban/workspaces/<id>/` (or `~/.hermes/kanban/boards/<slug>/workspaces/<id>/` on non-default boards). **Deleted when the task completes** — scratch is ephemeral by design. Files explicitly declared through `kanban_complete(artifacts=[...])` are copied into durable per-task attachment storage before cleanup; existing deliverable paths in legacy completion summaries receive the same treatment. Other scratch files are removed. A missing declared scratch artifact keeps the task in-flight so the worker can correct the path and retry. Use `worktree:` or `dir:<path>` when the whole workspace should remain available. The first time a scratch workspace is created on an install, the dispatcher logs a warning and emits a `tip_scratch_workspace` event on the task (visible via `hermes kanban show <id>`). - `dir:<path>` — an existing shared directory (Obsidian vault, mail ops dir, per-account folder). **Must be an absolute path.** Relative paths like `dir:../tenants/foo/` are rejected at dispatch because they'd resolve against whatever CWD the dispatcher happens to be in, which is ambiguous and a confused-deputy escape vector. The path is otherwise trusted — it's your box, your filesystem, the worker runs with your uid. This is the trusted-local-user threat model; kanban is single-host by design. **Preserved on completion.** - `worktree` — a git worktree under `.worktrees/<id>/` for coding tasks. Use `worktree:<path>` to pin the exact target path. Worker-side `git worktree add` creates it, using `--branch` when provided. **Preserved on completion.** - **Dispatcher** — a long-lived loop that, every N seconds (default 60): reclaims stale claims, reclaims crashed workers (PID gone but TTL not yet expired), promotes ready tasks, atomically claims, spawns assigned profiles. Runs **inside the gateway** by default (`kanban.dispatch_in_gateway: true`). One dispatcher sweeps all boards per tick; workers are spawned with `HERMES_KANBAN_BOARD` pinned so they can't see other boards. After `kanban.failure_limit` consecutive spawn failures on the same task (default: 2) the dispatcher auto-blocks it with the last error as the reason — prevents thrashing on tasks whose profile doesn't exist, workspace can't mount, etc. -
website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md@@ -59,7 +59,7 @@ Hermes Kanban 是一个持久化任务看板,在所有 Hermes 配置文件之 - **Link(链接)** —— `task_links` 行,记录父 → 子依赖关系。当所有父任务变为 `done` 时,调度器将 `todo → ready`。 - **Comment(评论)** —— agent 间协议。Agent 和人类追加评论;当 worker 被(重新)启动时,它将完整的评论线程作为上下文的一部分读取。 - **Workspace(工作区)** —— worker 操作的目录。三种类型: - - `scratch`(默认)—— 在 `~/.hermes/kanban/workspaces/<id>/` 下(非默认看板为 `~/.hermes/kanban/boards/<slug>/workspaces/<id>/`)创建的临时目录。**任务完成时删除** —— scratch 是临时性的,worker(或 `hermes kanban complete <id>`)将任务标记为完成的那一刻,目录即被清除。如果想保留 worker 的输出,请使用 `worktree:` 或 `dir:<path>`。在某次安装中首次创建 scratch 工作区时,调度器会记录警告并在任务上发出 `tip_scratch_workspace` 事件(可通过 `hermes kanban show <id>` 查看)。 + - `scratch`(默认)—— 在 `~/.hermes/kanban/workspaces/<id>/` 下(非默认看板为 `~/.hermes/kanban/boards/<slug>/workspaces/<id>/`)创建的临时目录。**任务完成时删除** —— scratch 按设计是临时性的。通过 `kanban_complete(artifacts=[...])` 明确声明的文件会在清理前复制到持久的任务附件存储;旧版完成摘要中已存在的交付文件路径也会得到同样处理。其他 scratch 文件仍会被删除。如果声明的 scratch 交付文件不存在,任务会保持进行中,worker 可修正路径后重试。需要保留整个工作区时,请使用 `worktree:` 或 `dir:<path>`。在某次安装中首次创建 scratch 工作区时,调度器会记录警告并在任务上发出 `tip_scratch_workspace` 事件(可通过 `hermes kanban show <id>` 查看)。 - `dir:<path>` —— 现有的共享目录(Obsidian vault、邮件运维目录、每账号文件夹)。**必须是绝对路径。** 像 `dir:../tenants/foo/` 这样的相对路径在调度时会被拒绝,因为它们会相对于调度器碰巧所在的 CWD 解析,这是模糊的,也是混淆代理(confused-deputy)逃逸向量。路径本身是受信任的 —— 这是你的机器、你的文件系统,worker 以你的 uid 运行。这是受信任本地用户的威胁模型;kanban 设计为单主机。**完成时保留。** - `worktree` —— 用于编码任务的 git worktree,位于 `.worktrees/<id>/` 下。使用 `worktree:<path>` 固定确切的目标路径。Worker 端的 `git worktree add` 创建它,提供 `--branch` 时使用该分支。**完成时保留。** - **Dispatcher(调度器)** —— 一个长期运行的循环,每 N 秒(默认 60 秒)执行一次:回收过期的认领、回收崩溃的 worker(PID 消失但 TTL 尚未过期)、推进就绪任务、原子性认领、启动已分配的配置文件。默认**在 gateway 内部运行**(`kanban.dispatch_in_gateway: true`)。每次 tick 一个调度器扫描所有看板;worker 启动时固定了 `HERMES_KANBAN_BOARD`,因此无法看到其他看板。在同一任务上连续启动失败 `kanban.failure_limit` 次(默认:2)后,调度器会以最后一个错误为原因自动阻塞该任务 —— 防止因配置文件不存在、工作区无法挂载等原因导致的反复抖动。
-
-
#63627 fix(image-gen): classify unsupported Codex image accounts
files:
plugins/image_gen/openai-codex/__init__.py,tests/plugins/image_gen/test_openai_codex_provider.py
11 PRs closed without merging
From the close comments: superseded by another PR 5 · merged via another change 2 · done elsewhere 1 · duplicate 1 · partial fix 1 · other 1
-
#20022 fix(kanban): validate created card handoffs · partial fix →
#20448,#20232 -
#25328 fix(kanban): verify created artifacts before marking task done (#25288) · other →
#21925,#25356 -
#28819 fix(kanban): refuse to rmtree workspace_path outside managed scratch root (#28818) · done elsewhere →
7590910 -
#29242 fix(kanban): refuse to clean up paths outside scratch root (#28818) · superseded by another PR →
80ad160,23115b5,ce529d6,#31708,#28819 -
#30718 fix(kanban): guard _cleanup_workspace against paths outside workspaces_root · superseded by another PR →
80ad160,23115b5,ce529d6,#31708,#28819,#28818
Show 6 more
-
#33916 fix: defer scratch workspace cleanup when task has active children (#33774) · merged via another change →
9405cd0,#41352 -
#38454 fix(kanban): preserve scratch workspace on completion, clean up on archive · superseded by another PR →
336f702,8e4f415,a2dd0e0,#41352,#33774,#28818,#33916,#38454 -
#39777 fix(kanban): preserve scratch completion artifacts · merged via another change →
#63619 -
#39793 fix(kanban): preserve scratch artifacts before workspace cleanup (#39747) · duplicate →
#39777 -
#53723 fix(kanban): validate artifact file existence before accepting completion · superseded by another PR →
#63619 -
#62036 fix(kanban): preserve declared scratch artifacts · superseded by another PR →
#63619,#39777
Closed issues · 6
- Closed#20017kanban workers fabricate card IDs in complete() — no verification that claimed cards exist
- Closed#28818[Bug]: Kanban scratch workspace cleanup can delete real source directories
- Closed#30151Hermes Kanban “Scratch Workspace” Cleanup Silently Deleted My Entire Projects Directory
- Closed#33774[Bug]: Kanban parent-child handoff: scratch workspace GC destroys artifacts before child can read them
- Closed#36046[Bug]: kanban artificat issue [not created or deleted]
- Closed#39747kanban_complete artifacts silently lost on scratch workspaces — file deleted before gateway notifier delivers
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.07: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab