@volter/supercode-orchestrator 0.5.227 → 0.5.229

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +18 -2
  2. package/activation.mjs +1 -1
  3. package/adapters/agent-mailbox.mjs +10 -0
  4. package/adapters/api-server.mjs +6 -0
  5. package/agents.mjs +149 -40
  6. package/apply/doors.mjs +3 -1
  7. package/bin/agent-channel.mjs +7 -2
  8. package/bin/connection-runner.mjs +60 -19
  9. package/bin/hermes-worker.mjs +2 -1
  10. package/bin/orchestrator.mjs +3 -3
  11. package/bin/runtime.mjs +3 -2
  12. package/board/agent-current.mjs +82 -0
  13. package/board/agent-declarations.mjs +10 -3
  14. package/board/args.mjs +137 -6
  15. package/board/call.mjs +16 -1
  16. package/board/caller-session.mjs +3 -1
  17. package/board/capacity.mjs +59 -45
  18. package/board/card-state.mjs +14 -5
  19. package/board/ci.mjs +521 -178
  20. package/board/cli.mjs +614 -209
  21. package/board/client.mjs +64 -21
  22. package/board/close-current.mjs +36 -0
  23. package/board/current-records.mjs +44 -0
  24. package/board/custody.mjs +2 -1
  25. package/board/dispatch.mjs +1973 -846
  26. package/board/doors.mjs +2 -2
  27. package/board/engine.mjs +623 -85
  28. package/board/event-stream.mjs +10 -5
  29. package/board/fence.mjs +2 -0
  30. package/board/files.mjs +93 -62
  31. package/board/generation-owner.mjs +188 -53
  32. package/board/git-document.mjs +3 -1
  33. package/board/github-rest.mjs +29 -7
  34. package/board/incidents.mjs +5 -17
  35. package/board/listing.mjs +14 -8
  36. package/board/local-admission.mjs +59 -0
  37. package/board/lock-holder.mjs +18 -6
  38. package/board/machine-fleet.mjs +66 -13
  39. package/board/owner-door-worker.mjs +3 -3
  40. package/board/owner-door.mjs +17 -7
  41. package/board/owner-health.mjs +12 -15
  42. package/board/owner-interface.mjs +6 -0
  43. package/board/publication-reader.mjs +50 -0
  44. package/board/published.mjs +40 -12
  45. package/board/queries.mjs +24 -4
  46. package/board/record-view.mjs +30 -3
  47. package/board/request-mail.mjs +37 -6
  48. package/board/resting-admission.mjs +318 -0
  49. package/board/resting-codec-qualifications.json +33 -0
  50. package/board/resting-protocol.mjs +71 -0
  51. package/board/resting-state-protocol.json +120 -0
  52. package/board/resting-state.mjs +113 -0
  53. package/board/runtime-door.mjs +2 -2
  54. package/board/session-continuity.mjs +127 -0
  55. package/board/session-work.mjs +25 -13
  56. package/board/standalone-declaration.mjs +24 -0
  57. package/board/storage-contract.mjs +129 -7
  58. package/board/store.mjs +73 -11
  59. package/board/tools.mjs +56 -13
  60. package/board/wake.mjs +18 -5
  61. package/board/workflow.mjs +284 -82
  62. package/board/workspace.mjs +86 -9
  63. package/board/ztrack.mjs +344 -91
  64. package/connection.mjs +72 -17
  65. package/gates.mjs +2 -1
  66. package/hand-off.mjs +4 -3
  67. package/hermes-cli.mjs +5 -3
  68. package/hermes-locks.mjs +5 -2
  69. package/hosted.mjs +2 -1
  70. package/index.d.ts +2 -0
  71. package/index.mjs +1 -1
  72. package/kanban-worker.mjs +63 -13
  73. package/loop.mjs +33 -0
  74. package/mcp/hermes-kanban-tools.json +109 -50
  75. package/mcp/server.mjs +7 -1
  76. package/ontology.d.ts +26 -0
  77. package/orchestration.mjs +15 -4
  78. package/package.json +19 -12
  79. package/session-recovery.mjs +2 -1
  80. package/worker-route.mjs +37 -19
package/board/engine.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { latestEvent, latestEventIdSql } from './queries.mjs';
2
+ import { stopWorkChild } from '@volter/supercode-harness-sdk/work-ledger';
1
3
  import { allocateReadableId, cardReadableId, requireReadableId, validateReadableId } from './readable-id.mjs';
2
4
  // The board's transitions (docs/architecture/orchestrator.md §2.9): what a card may do, written for supercode and
3
5
  // kept to Hermes Kanban's lifecycle so the two read each other's boards. Statuses: triage · todo · scheduled ·
@@ -8,22 +10,28 @@ import { allocateReadableId, cardReadableId, requireReadableId, validateReadable
8
10
  //
9
11
  // Each function takes an open board (`store.mjs`) and runs in one write transaction. Nothing here launches,
10
12
  // observes or sends: the dispatcher (`dispatch.mjs`) does, and records what it did through these.
11
- import { current } from './call.mjs';
13
+ import { current, refuseUnreadManager } from './call.mjs';
14
+ import { randomBytes } from 'node:crypto';
15
+ import { redactValue } from '@volter/teams/redact';
16
+ import { backlogQualification, deadlineUntil } from './resting-state.mjs';
12
17
  import { detachedCard, detachedSession, personCustody, personOf, sessionAddress, sessionOperation } from './custody.mjs';
13
18
  import { executionOf, readingContext } from './session-work.mjs';
14
19
  import { cardState, cardStateSql, holdDescription } from './card-state.mjs';
15
20
  import { spawn, execFile } from 'node:child_process';
16
21
  import { existsSync, rmSync } from 'node:fs';
22
+ import { createRequire } from 'node:module';
17
23
  import { join, resolve } from 'node:path';
18
- import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace, plainWorkspace } from './workspace.mjs';
24
+ import { checkWorkspace, assertWorkspaceOwner, cardWorktrees, declareWorkspace, isLocalWorkspace, plainWorkspace } from './workspace.mjs';
19
25
  import { DatabaseSync } from 'node:sqlite';
20
26
  import { CARD_VISIBILITY_SQL } from './visibility.mjs';
21
- import { view, actorOf, principalOf, cardBranch, CARD_NUMBERS, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
22
- import { arcRepositories, arcSources, assertSourceReceipt, completionPhase, outsideArcRepositories, inspectCompletionEvidence, observeCompletionEvidence, recordedArcRepositories, withCompletionEvidence } from './ci.mjs';
23
- import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, reviewerOf, eventTo, fire, validateEvent, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, transitionsFor, workflowFor } from './workflow.mjs';
27
+ import { view, actorOf, setActor, principalOf, cardBranch, CARD_NUMBERS, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
28
+ import { preparePrEvidence, prDisposition, redeclaredSince, releaseReadback, arcRepositories, arcSources, assertSourceReceipt, completionPhase, outsideArcRepositories, inspectCompletionEvidence, observeCompletionEvidence, recordedArcRepositories, withCompletionEvidence, declaredPullRequests, candidatePullRequests, pendingReviewRequests, latestReviewRequest, openPrReviewFindings, prIdentity } from './ci.mjs';
29
+ import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, reviewerOf, eventTo, fire, validateEvent, loadWorkflow, setStatus, settle, settleExpiringFlags, startNew, statusOf, runEvent, transitionsFor, workflowFor, scopeOf, ownerRule, requestDetail } from './workflow.mjs';
24
30
  import { refuseCredentialDump, withheldIfDump } from './credential-guard.mjs';
25
31
  import { AGENT_KIND_LIST, compactCardAgentEvents } from './published.mjs';
26
32
 
33
+ const { toMdast } = createRequire(import.meta.url)('@volter/ztrack/preset-kit');
34
+
27
35
  export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'paused', 'review', 'done', 'archived'];
28
36
  export const WORKSPACE_KINDS = ['scratch', 'dir', 'worktree'];
29
37
  /** A card blocked this many times in a row for the same kind goes to triage for a person (Hermes's limit). */
@@ -70,7 +78,7 @@ export function getTask(db, id) {
70
78
  const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
71
79
  if (!row) return null;
72
80
  const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
73
- return { ...row, number: numberOf(db, id), stage: ours?.stage ?? row.status, workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), detached: json(ours?.detached, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null,
81
+ return { ...row, number: numberOf(db, id), stage: ours?.stage ?? row.status, workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), backlog: json(ours?.backlog, null), detached: json(ours?.detached, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null,
74
82
  tags: json(ours?.tags, null) ?? (row.tenant ? [row.tenant] : []), requested_by_unverified: json(ours?.requested_by_unverified, null) };
75
83
  }
76
84
 
@@ -100,8 +108,23 @@ export function parseWorkspace(word = 'scratch') {
100
108
  * seconds later. The wait runs in a detached shell, so the board's write lock is never held through it.
101
109
  */
102
110
  function terminate(pid) {
103
- try { process.kill(-pid, 'SIGTERM'); } catch { try { process.kill(pid, 'SIGTERM'); } catch { return; } }
104
- spawn('/bin/sh', ['-c', 'for i in 1 2 3 4 5 6 7 8 9 10; do kill -0 "$0" 2>/dev/null || exit 0; sleep 0.5; done; kill -KILL -"$0" 2>/dev/null || kill -KILL "$0" 2>/dev/null', String(pid)], { detached: true, stdio: 'ignore' }).unref();
111
+ try { stopWorkChild(pid, () => process.kill(-pid, 'SIGTERM'), { signal: 'SIGTERM' }); } catch { try { stopWorkChild(pid, () => process.kill(pid, 'SIGTERM'), { signal: 'SIGTERM' }); } catch { return; } }
112
+ stopWorkChild(pid, () => new Promise((accepted) => {
113
+ // Starting the delayed shell is not acceptance of its eventual signal. It
114
+ // reports only a successful kill; disappearing during the wait is a no-op.
115
+ const escalation = spawn('/bin/sh', ['-c', 'for i in 1 2 3 4 5 6 7 8 9 10; do kill -0 "$0" 2>/dev/null || exit 0; sleep 0.5; done; if kill -KILL -"$0" 2>/dev/null || kill -KILL "$0" 2>/dev/null; then printf "stopped\\n" >&3; else exit 1; fi', String(pid)], { detached: true, stdio: ['ignore', 'ignore', 'ignore', 'pipe'] });
116
+ let result = '';
117
+ escalation.stdio[3].on('data', (chunk) => {
118
+ if (result.length + chunk.length > 8) { accepted(false); return; }
119
+ result += chunk;
120
+ if (result === 'stopped\n') accepted(true);
121
+ });
122
+ escalation.stdio[3].once('error', () => accepted(false));
123
+ escalation.once('error', () => accepted(false));
124
+ escalation.once('close', () => accepted(false));
125
+ escalation.stdio[3].unref();
126
+ escalation.unref();
127
+ }), { signal: 'SIGKILL' });
105
128
  }
106
129
 
107
130
  /** Close the card's open run; answers its id, or null when none was open. */
@@ -155,7 +178,7 @@ export function lastSession(db, taskId, slot = 'worker') {
155
178
  // a session slot's own last session (runs recorded before slots existed are the worker's)
156
179
  for (const row of all(db, 'SELECT metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC', taskId)) {
157
180
  const s = json(row.metadata, {})?.supercode;
158
- if (s?.session_id && (s.slot ?? 'worker') === slot) return detachedSession(db, s) ? null : s;
181
+ if (s?.session_id && !(s.launch_not_issued && s.mode === 'fresh' && s.close_confirmed_at) && (s.slot ?? 'worker') === slot) return detachedSession(db, s) ? null : s;
159
182
  }
160
183
  return null;
161
184
  }
@@ -194,17 +217,27 @@ function projectOf(root, idOrSlug) {
194
217
  * its subtasks, and leave the body. A Tasks section with no task line is the body's own text.
195
218
  */
196
219
  export function bodyTasks(body) {
197
- const lines = String(body ?? '').split('\n');
198
- const at = lines.findIndex((line) => /^#{2,3}\s+Tasks\s*$/i.test(line));
220
+ const text = String(body ?? ''), lines = text.split('\n'), literal = new Set();
221
+ // Section/filed-task syntax belongs to prose. Rent CommonMark's literal ranges so fenced or indented examples
222
+ // and HTML blocks keep their text, including headings and checkbox lines that only look like filing instructions.
223
+ const walk = node => {
224
+ if ((node.type === 'code' || node.type === 'html') && node.position) {
225
+ for (let i = node.position.start.line - 1; i < node.position.end.line; i++) literal.add(i);
226
+ return;
227
+ }
228
+ for (const child of node.children ?? []) walk(child);
229
+ };
230
+ walk(toMdast(text));
231
+ const at = lines.findIndex((line, i) => !literal.has(i) && /^#{2,3}\s+Tasks\s*$/i.test(line));
199
232
  if (at < 0) return { body, tasks: [] };
200
- let end = lines.findIndex((line, i) => i > at && /^#{1,3}\s/.test(line));
233
+ let end = lines.findIndex((line, i) => i > at && !literal.has(i) && /^#{1,3}\s/.test(line));
201
234
  if (end < 0) end = lines.length;
202
235
  const section = lines.slice(at + 1, end);
203
- if (!section.some((line) => /^[-*] \[[ xX]\]/.test(line))) return { body, tasks: [] };
236
+ if (!section.some((line, i) => !literal.has(at + 1 + i) && /^[-*] \[[ xX]\]/.test(line))) return { body, tasks: [] };
204
237
  const tasks = [];
205
- for (const line of section) {
238
+ for (const [i, line] of section.entries()) {
206
239
  if (!line.trim()) continue;
207
- const step = /^[-*] \[ \]\s+(\S.*)$/.exec(line);
240
+ const step = !literal.has(at + 1 + i) && /^[-*] \[ \]\s+(\S.*)$/.exec(line);
208
241
  if (step) tasks.push({ title: step[1].trim(), body: [] });
209
242
  else if (/^\s+\S/.test(line) && tasks.length) tasks.at(-1).body.push(line.replace(/^\s{1,4}/, ''));
210
243
  else throw new Error("A card's Tasks section holds open task lines ('- [ ] <step>', each filed as a subtask, with its own indented lines); record finished work as a comment and move other text out of the section");
@@ -276,7 +309,7 @@ export function createCard(board, spec) {
276
309
  throw new Error('A card reviewer names a runnable profile in this home.');
277
310
  }
278
311
  if (spec.subtaskOf && !arc) throw new Error(`no card ${spec.subtaskOf} to be a subtask of`);
279
- if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; a subtask belongs to an arc`);
312
+ if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; subtasks are one level deep, so file it with --subtask-of ${arc.subtask_of}`);
280
313
  let id = newTaskId();
281
314
  while (one(db, 'SELECT 1 FROM tasks WHERE id = ?', id)) id = newTaskId();
282
315
  // where a card's blocks are relations, a card created blocked names what it waits on: one that names nothing is
@@ -441,22 +474,96 @@ export function extendClaim(db, id, { ttl, reason, extra = {} }) {
441
474
  }
442
475
 
443
476
  /**
444
- * A completed card's workspace goes, as Hermes cleans up on completion: a scratch directory under the board's own
445
- * workspaces, or a worktree (its branch stays); never while a child still waits on it or another open card works in
446
- * it, never a `dir`.
477
+ * A closed card's workspace goes, as Hermes cleans up on completion: the solo worktree the board made for it (its
478
+ * branch stays), and with `scratch` a scratch directory under the board's own workspaces; never while a child still
479
+ * waits on it or another open card works in it, never a `dir`. A close the board queues on its own (retire_workspace)
480
+ * retires only the worktree; a scratch folder goes where a workflow declares cleanup_workspace, or at gc. The worktree
481
+ * goes only through its machine's git, and only when that loses nothing (retireWorktree): its branch merged into origin's
482
+ * default branch (by ancestry, `git cherry`, a squash reading, or within a head the card's or its arc's
483
+ * `reviewed_merged` merged), its tree clean apart from ignored files, and no repository nested inside it. One that is
484
+ * not is kept, and the card says once which path holds what. Answers whether a worktree was removed.
447
485
  */
448
- async function cleanupWorkspace(board, id) {
486
+ export async function cleanupWorkspace(board, id, { scratch = true } = {}) {
449
487
  const task = view(board, (db) => getTask(db, id));
450
- if (!task?.workspace_path || !isLocalWorkspace(task) || !['scratch', 'worktree'].includes(task.workspace_kind)) return;
451
- const waiting = view(board, (db) => one(db, "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') LIMIT 1", id)
488
+ if (!task || !(scratch ? ['scratch', 'worktree'] : ['worktree']).includes(task.workspace_kind)) return;
489
+ // a card reopened before its cleanup ran keeps its workspace
490
+ if (!['done', 'cancelled', 'archived'].includes(task.status)) return;
491
+ const waiting = view(board, (db) => one(db, "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', 'cancelled') LIMIT 1", id)
452
492
  ?? one(db, "SELECT 1 FROM tasks WHERE workspace_path = ? AND id <> ? AND status NOT IN ('done', 'archived', 'cancelled') LIMIT 1", task.workspace_path, id));
453
- if (waiting || !existsSync(task.workspace_path)) return;
454
- // awaited, never a wait that holds the event loop
455
- if (task.workspace_kind === 'worktree') { await new Promise((resolve) => execFile('git', ['-C', task.workspace_path, 'worktree', 'remove', '--force', task.workspace_path], { encoding: 'utf8' }, () => resolve())); return; }
493
+ if (waiting) return;
494
+ if (task.workspace_kind === 'worktree') {
495
+ let removed = false;
496
+ for (const allocation of view(board, db => cardWorktrees(db, task))) {
497
+ const shared = view(board, db => one(db, "SELECT 1 FROM tasks WHERE workspace_path=? AND id<>? AND status NOT IN ('done','archived','cancelled') LIMIT 1", allocation.path, id));
498
+ if (shared) continue;
499
+ removed = await retireCardWorktreeOf(board, { ...task, machine: allocation.machine, workspace_path: allocation.path, branch_name: allocation.branch }) || removed;
500
+ }
501
+ return removed;
502
+ }
503
+ if (!task.workspace_path) return;
504
+ if (!isLocalWorkspace(task) || !existsSync(task.workspace_path)) return;
456
505
  const managed = resolve(board.slug === 'default' ? join(board.root, 'kanban', 'workspaces') : join(board.root, 'kanban', 'boards', board.slug, 'workspaces'));
457
506
  if (resolve(task.workspace_path).startsWith(`${managed}/`)) rmSync(task.workspace_path, { recursive: true, force: true });
458
507
  }
459
508
 
509
+ /** Card `task`'s worktree retired on its machine (cleanupWorkspace), once the card's sessions are closed and no live
510
+ * pane there works in it: `worktree_removed` when it went, else the card's comment naming the path, its branch's
511
+ * unmerged commits by subject and its changed files by count (or why the folder is never removed), written once for each
512
+ * such reading. A session is closed when its record says so or its machine reads its pane and process gone (one held
513
+ * after its run, monitored, or closed by hand). Throws while a session may still work there or the machine could not
514
+ * answer, so the effect is tried again. A recorded merge counts only where it merged (`merge`), never a release-only
515
+ * settlement. Answers whether it removed the worktree. */
516
+ async function retireCardWorktreeOf(board, task) {
517
+ const { panesUnder, sessionGone } = await import('./dispatch.mjs');
518
+ const { retireWorktree, worktreePresent } = await import('./workspace.mjs');
519
+ // a folder already gone is nothing to read panes or sessions for; it is recorded so, once, and gc reads it no more
520
+ const gone = () => tx(board, (db) => {
521
+ const last = latestEvent(db, task.id, ['worktree_kept', 'worktree_removed'], 'kind', "json_extract(payload,'$.path')=?", [task.workspace_path]);
522
+ if (last?.kind !== 'worktree_removed') event(db, task.id, 'worktree_removed', { path: task.workspace_path, gone: true });
523
+ return false;
524
+ });
525
+ const resolved = await worktreePresent(task.machine, task.workspace_path);
526
+ if (!resolved) return gone();
527
+ const recorded = view(board, (db) => {
528
+ if (getTask(db, task.id)?.current_run_id) return { current: true, open: [] };
529
+ const latest = new Map();
530
+ for (const row of db.prepare('SELECT metadata FROM task_runs WHERE task_id = ? ORDER BY id DESC').all(task.id)) {
531
+ const s = json(row.metadata, {})?.supercode;
532
+ if (s && !latest.has(s.slot ?? 'worker')) latest.set(s.slot ?? 'worker', s);
533
+ }
534
+ return { current: false, open: [...latest.values()].filter((s) => (s.pane || s.session_id || s.pid || s.launch_key) && !(s.close_confirmed_at || s.close_transferred_to || s.forgotten || s.detached_at)) };
535
+ });
536
+ if (recorded.current) throw new Error(`${task.id}: worktree ${task.workspace_path} waits: its run is still current`);
537
+ const live = [];
538
+ for (const s of recorded.open) if (!(await sessionGone(s))) live.push(s.address ?? s.pane ?? s.session_id ?? s.pid);
539
+ if (live.length) throw new Error(`${task.id}: worktree ${task.workspace_path} waits: its session ${live.join(', ')} is not closed`);
540
+ const panes = await panesUnder(task.machine, task.workspace_path, [resolved]);
541
+ if (panes.length) throw new Error(`${task.id}: worktree ${task.workspace_path} waits: live pane ${panes.join(', ')} works in it`);
542
+ const merged = view(board, (db) => db.prepare("SELECT payload FROM task_events WHERE task_id IN (?, ?) AND kind = 'reviewed_merged'").all(task.id, task.subtask_of ?? task.id))
543
+ .flatMap((row) => json(row.payload, {})?.merges ?? []).filter((m) => m?.merge && !m.release_only).map((m) => m.reviewed)
544
+ .filter((sha) => typeof sha === 'string' && /^[0-9a-f]{40}$/.test(sha));
545
+ // reopened while its machine was read: it keeps its worktree
546
+ if (!['done', 'cancelled', 'archived'].includes(view(board, (db) => getTask(db, task.id))?.status)) return false;
547
+ const answer = await retireWorktree(task.machine, task.workspace_path, { merged: [...new Set(merged)], branch: task.branch_name ?? null });
548
+ if (answer.gone) return gone();
549
+ const held = { path: answer.path, branch: answer.branch ?? null, base: answer.base ?? null, unmerged: answer.unmerged ?? [], modified: answer.modified ?? 0, untracked: answer.untracked ?? 0, reason: answer.reason ?? null, refused: Boolean(answer.refused) };
550
+ tx(board, (db) => {
551
+ if (answer.removed) { event(db, task.id, 'worktree_removed', { path: held.path, branch: held.branch, base: held.base }); return; }
552
+ const previous = latestEvent(db, task.id, ['worktree_kept', 'worktree_removed'], 'kind,payload', "json_extract(payload,'$.path')=?", [task.workspace_path]);
553
+ const last = json(previous?.payload, null);
554
+ if (previous?.kind === 'worktree_kept' && last && Object.keys(held).every((key) => JSON.stringify(last[key]) === JSON.stringify(held[key]))) return;
555
+ event(db, task.id, 'worktree_kept', held);
556
+ const shown = held.unmerged.slice(0, 20).map((subject) => `\n- ${subject}`).join('') + (held.unmerged.length > 20 ? `\n- … and ${held.unmerged.length - 20} more` : '');
557
+ const body = `Worktree ${held.path} is kept${task.machine ? ` on ${task.machine}` : ''}: ${held.reason}.`
558
+ + (held.branch ? ` Branch ${held.branch}${held.base ? ` against origin/${held.base}` : ''}.` : '')
559
+ + (held.unmerged.length ? ` Unmerged commits:${shown}` : '')
560
+ + (held.modified || held.untracked ? `${held.unmerged.length ? '\n' : ' '}Files: ${held.modified} tracked change(s), ${held.untracked} untracked.` : '');
561
+ event(db, task.id, 'commented', { author: 'board', len: body.length, by: 'board' });
562
+ run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', task.id, 'board', body, now());
563
+ });
564
+ return Boolean(answer.removed);
565
+ }
566
+
460
567
 
461
568
 
462
569
 
@@ -475,40 +582,239 @@ async function cleanupWorkspace(board, id) {
475
582
  * Send card `id` the event `name` (the board's workflow decides what it does: `workflow.mjs`), then settle the cards
476
583
  * that wait on it (Hermes's recompute_ready) and run what the move asked for once it is committed. `roles` are who
477
584
  * sends it: `manager` for a person or the fleet's manager at the CLI, the card's own role for its session,
478
- * `platform` for the dispatcher. Answers `{ from, to, moved }`; throws the workflow's refusal.
585
+ * `platform` for the dispatcher. Answers `{ from, to, moved }` or a retained manager request.
479
586
  */
480
587
  // who is sending, when a caller does not say: the CLI answers with the caller's role on the card (setCaller)
481
588
  /** How the engine learns who a caller is on a card: `fn(db, cardId)` answers the caller's roles there (the call's own). */
589
+ const preparedPrEvidence = new WeakSet();
590
+ const completionEvidenceRefusal = (evidence, fallback) => Object.assign(new Error(evidence.reason ?? fallback),
591
+ { boardDetail: true }, Object.fromEntries(['code', 'retryable', 'deferred', 'deferred_code', 'retry_at', 'refusals']
592
+ .filter(key => Object.hasOwn(evidence, key)).map(key => [key, evidence[key]])));
482
593
  export function setCaller(fn) { current().roles = fn; }
483
594
  const callerRoles = (db, cardId) => current().roles(db, cardId);
595
+ const withhold = value => typeof value === 'string' ? withheldIfDump(value)
596
+ : Array.isArray(value) ? value.map(withhold)
597
+ : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, withhold(item)])) : value;
598
+
599
+ /** Record an incomplete or already satisfied ordinary act without claiming a transition or asking for permission. */
600
+ export async function acceptRequest(board, { id = null, name, payload = {}, reason = null } = {}) {
601
+ const review = ['request_review', 'request-review', 'kanban_request_review'].includes(name);
602
+ const safe = redactValue({ requested: name, payload: withhold(payload), reason: String(reason?.message ?? reason ?? 'Recorded with the detail supplied.') });
603
+ const receipt = tx(board, db => {
604
+ const task = id ? getTask(db, id) : null, from = task ? statusOf(db, task) : null;
605
+ const queued = payload.queued_event ?? payload.queuedEvent;
606
+ if (review && queued != null) {
607
+ const pending = pendingReviewRequests(db, id).find(request => request.id === Number(queued));
608
+ if (pending) return { queued: true, disposition: 'pending', requested: name, id, pr: pending.pr, pr_head: pending.pr_head,
609
+ from, to: from, moved: false, pending: true, already: true, event: pending.id, queue_event: pending.id, reason: safe.reason,
610
+ ...(Array.isArray(reason?.refusals) ? { refusals: reason.refusals } : {}), ...(reason?.retry_at ? { retry_at: reason.retry_at } : {}) };
611
+ }
612
+ if (review) {
613
+ const detail = { ...safe, reason: `Nothing was queued. ${safe.reason}` };
614
+ const recorded = event(db, id == null ? '' : String(id), 'review_not_queued', detail, null);
615
+ return { ok: false, queued: false, disposition: 'not_queued', requested: name, from, to: from, moved: false, event: recorded, reason: detail.reason };
616
+ }
617
+ const recorded = queued ?? event(db, id == null ? '' : String(id), 'request_accepted', safe, null);
618
+ return { accepted: true, disposition: queued != null ? 'pending' : 'accepted', requested: name, from, to: from, moved: false, needs_detail: true, ...(queued != null ? { pending: true } : {}), event: recorded, reason: safe.reason };
619
+ });
620
+ (receipt.queued ? current().queuedRequests ??= [] : review ? current().unqueuedReviews ??= [] : current().acceptedRequests ??= []).push(receipt);
621
+ return receipt;
622
+ }
623
+
624
+ /** Retain the requested act for the configured manager without borrowing that manager's authority. A caller that
625
+ * might be that manager, unread this call (call.mjs refuseUnreadManager), gets the identity error instead. */
626
+ export async function routeRequest(board, { id = null, name, payload = {}, reason = null } = {}) {
627
+ refuseUnreadManager();
628
+ const entry = await loadWorkflow(board.root);
629
+ const manager = [].concat(entry.params?.managers ?? []).find(Boolean) ?? 'manager';
630
+ const requester = actorOf() ?? current().caller ?? principalOf() ?? 'board caller';
631
+ const messageId = `m-${randomBytes(12).toString('hex')}`;
632
+ const safe = redactValue({ requested: name, payload: withhold(payload), reason: String(reason?.message ?? reason ?? 'This act needs the manager.'), requester });
633
+ const verb = String(name ?? '').replace(/^kanban_/, '').replaceAll('-', '_');
634
+ const state = ({ complete: 'done', cancel: 'cancelled', archive: 'cancelled' })[verb] ?? payload.state ?? verb;
635
+ const rule = entry.params?.request_rules?.[state];
636
+ const denial = entry.params?.request_denial;
637
+ const receipt = tx(board, db => {
638
+ const task = id ? getTask(db, id) : null;
639
+ const from = task ? statusOf(db, task) : null;
640
+ const taskId = id == null ? '' : String(id);
641
+ const queued = payload.queued_event ?? payload.queuedEvent;
642
+ if (queued != null) {
643
+ const previous = one(db, "SELECT payload FROM task_events WHERE task_id=? AND kind='request_routed' AND COALESCE(json_extract(payload,'$.payload.queued_event'),json_extract(payload,'$.payload.queuedEvent'))=? ORDER BY id DESC LIMIT 1", taskId, queued);
644
+ const prior = json(previous?.payload, null);
645
+ if (prior) return { routed: true, disposition: 'routed', requested: name, manager: prior.manager, message_id: prior.message_id, from, to: from, moved: false, reason: prior.reason, already: true };
646
+ }
647
+ event(db, taskId, 'request_routed', { ...safe, manager, message_id: messageId }, null);
648
+ if (manager) event(db, taskId, 'board_message', { to: manager, message_id: messageId,
649
+ text: `${taskId || 'Board'}: ${requester} requested ${String(name ?? 'an act').replaceAll('_', '-')}.\n${safe.reason}\nOriginal request: ${JSON.stringify(safe.payload)}\nCarry out the request through its ordinary board door, applying any decision it needs.\n${rule ? `\n${rule}\n${denial ?? ''}\n` : ''}\nAnswer with reply_to ${messageId}.` }, null);
650
+ return { routed: true, disposition: 'routed', requested: name, manager, message_id: messageId, from, to: from, moved: false, reason: safe.reason };
651
+ });
652
+ (current().routedRequests ??= []).push(receipt);
653
+ // board_message is the existing durable mail queue. Its delivery pass retries a failed filing.
654
+ return receipt;
655
+ }
484
656
 
485
657
  /**
486
- * Fire `name` on card `id` as its caller (or `roles`), in one board write with what the move settles around it.
487
- * `instead(db, error)`, when given, is asked in that same write when the event is refused: it answers another event's
488
- * name to fire in its place (read and decided against the same state the refusal was), or null to keep the refusal.
658
+ * Apply `name` as the real caller, supplying ordinary missing defaults. A request that needs another decision is
659
+ * retained for the manager. `instead(db, error)`, when present, selects a workflow alternative in the same write.
489
660
  */
490
661
  export async function act(board, id, name, { roles = null, payload = {}, extra = {}, instead = null } = {}) {
662
+ const original = payload;
663
+ try {
664
+ const sender = roles ?? callerRoles(board.db, id);
665
+ let backlogEvent = null;
666
+ if ((Object.hasOwn(payload, 'reason') || name === 'backlog') && !String(payload.reason ?? '').trim()) payload = { ...payload, reason: 'none given' };
667
+ if (name === 'pause' && payload.until == null) payload = { ...payload, until: '1h' };
668
+ if (name === 'unblock' && !payload.on) {
669
+ // A bare unblock means the dependencies the manager can see now, never a wildcard over later additions.
670
+ const blockers = getTask(board.db, id)?.blocked_by ?? [];
671
+ if (!blockers.length) {
672
+ const task = getTask(board.db, id), from = task ? statusOf(board.db, task) : null;
673
+ if (task) return { from, to: from, moved: false, already: true };
674
+ }
675
+ let result;
676
+ for (const blocker of blockers) result = await applyAct(board, id, name, { roles, payload: { ...payload, on: blocker.id }, extra, instead });
677
+ if (result) return result;
678
+ }
679
+ if (name === 'backlog') {
680
+ const task = getTask(board.db, id);
681
+ if (task?.backlog) return { from: statusOf(board.db, task), to: statusOf(board.db, task), moved: false, already: true };
682
+ if (task?.paused && payload.pause_revision == null) payload = { ...payload,
683
+ pause_revision: task.paused.generation ?? one(board.db, "SELECT id FROM task_events WHERE task_id=? AND kind='paused' ORDER BY id DESC LIMIT 1", id)?.id };
684
+ const entry = await loadWorkflow(board.root);
685
+ view(board, db => validateEvent(db, board, entry, id, name, { roles: sender, payload, extra, localOnly: true }));
686
+ const pendingHold = latestEvent(board.db, id, ['dispatch_held', 'dispatch_released', 'session_adopted'], 'id,kind,payload');
687
+ if (pendingHold?.kind === 'dispatch_held' && json(pendingHold.payload, {})?.backlog_request) backlogEvent = pendingHold.id;
688
+ if (task && !['done', 'archived', 'cancelled'].includes(task.status) && !backlogQualification(board.db, id).qualified) {
689
+ backlogEvent = tx(board, db => {
690
+ const hold = latestEvent(db, id, ['dispatch_held', 'dispatch_released', 'session_adopted'], 'id,kind,payload');
691
+ const held = json(hold?.payload, {}) ?? {};
692
+ const marker = event(db, id, 'dispatch_held', { reason: 'backlog requested; ending its execution before placement', backlog_request: true,
693
+ requester: actorOf() ?? current().caller ?? principalOf(), sender, backlog_payload: payload, backlog_extra: extra,
694
+ previous_hold: hold?.kind === 'dispatch_held' ? held.backlog_request ? held.previous_hold ?? null : { id: hold.id, payload: held } : null });
695
+ if (getTask(db, id)?.current_run_id) endRun(db, id, { outcome: 'backlogged', summary: payload.reason });
696
+ requestSessionClose(db, id);
697
+ return marker;
698
+ });
699
+ const { closeRequested } = await import('./dispatch.mjs');
700
+ await closeRequested(board, { undelivered: [] }, { taskId: id });
701
+ // The close pass can finish this exact request once it has the real stop receipt.
702
+ if (getTask(board.db, id)?.backlog) return { from: statusOf(board.db, task), to: statusOf(board.db, getTask(board.db, id)), moved: false };
703
+ const qualification = backlogQualification(board.db, id);
704
+ if (!qualification.qualified) throw requestDetail(qualification.reason);
705
+ }
706
+ }
707
+ return await applyAct(board, id, name, { roles, payload, extra, instead, backlogEvent });
708
+ } catch (error) {
709
+ if (error.ownerRule) return await routeRequest(board, { id, name, payload: original, reason: error });
710
+ if (error.boardDetail || error.noTransition || error.refusal) return await acceptRequest(board, { id, name, payload: original, reason: error });
711
+ throw error;
712
+ }
713
+ }
714
+
715
+ /** Finish retained backlog requests only after the native close pass has made them qualified. */
716
+ export async function completeBacklogRequests(board, { taskId = null } = {}) {
717
+ const pending = view(board, db => all(db, `SELECT e.id,e.task_id,e.payload FROM tasks t JOIN task_events e ON e.id=${latestEventIdSql('t.id', ['dispatch_held', 'dispatch_released', 'session_adopted'])}
718
+ WHERE e.kind='dispatch_held' AND json_extract(e.payload,'$.backlog_request')=1 ${taskId ? 'AND t.id=?' : ''}
719
+ ORDER BY e.id`, ...(taskId ? [taskId] : [])));
720
+ const completed = [];
721
+ for (const request of pending) {
722
+ if (!backlogQualification(board.db, request.task_id).qualified) continue;
723
+ const held = json(request.payload, {}) ?? {};
724
+ // Complete only with the original sender's roles.
725
+ if (!Array.isArray(held.sender) || !held.backlog_payload) continue;
726
+ const previousActor = actorOf();
727
+ try {
728
+ setActor(held.requester ?? held.by ?? null);
729
+ const result = await applyAct(board, request.task_id, 'backlog', { roles: held.sender, payload: held.backlog_payload,
730
+ extra: held.backlog_extra ?? {}, backlogEvent: request.id });
731
+ if (result.backlog_event === request.id) completed.push(request.task_id);
732
+ } catch (error) {
733
+ // A changed workflow or card retains its request; a later close pass can reconsider it without duplicate mail.
734
+ if (!error.ownerRule && !error.boardDetail && !error.noTransition && !error.refusal) throw error;
735
+ } finally { setActor(previousActor); }
736
+ }
737
+ return completed;
738
+ }
739
+
740
+ async function applyAct(board, id, name, { roles = null, payload = {}, extra = {}, instead = null, backlogEvent = null } = {}) {
491
741
  assertSourceReceipt(payload);
492
742
  const entry = await loadWorkflow(board.root);
493
- if (name === 'comment' && (!String(payload.body ?? '').trim() || !String(payload.author ?? '').trim())) throw new Error('a comment has an author and body');
743
+ const sender = roles ?? callerRoles(board.db, id), task = getTask(board.db, id);
744
+ if (task && !task.subtask_of && sender.includes('implementer') && !sender.includes('manager') && ['complete', 'cancel', 'archive'].includes(name))
745
+ throw ownerRule('The manager closes an arc; its own implementer requests that decision.');
746
+ if (task && name === 'request_review') {
747
+ const card = scopeOf(board.db, board, task, { params: entry.params, lazy: true, deferSources: true }).card;
748
+ if (card.reviewer_profile != null && (card.reviewer_profile === card.implementer || card.reviewer_profile === entry.params?.org_layer))
749
+ throw ownerRule('A review uses an independent reviewer profile; the manager selects it.');
750
+ }
751
+ if (task && name === 'set' && payload.field === 'reviewer' && payload.value != null
752
+ && [task.assignee, entry.params?.org_layer].includes(payload.value)) throw ownerRule('A review uses an independent reviewer profile; the manager selects it.');
494
753
  refuseCredentialDump(name === 'comment' ? `the comment on ${id}` : `${name.replace(/_/g, '-')} on ${id}`, payload);
495
754
  await refreshCardInputs(board, id, { commits: ['complete', 'request_review'].includes(name) });
496
755
  if (['complete', 'request_review', 'request_state'].includes(name)) view(board, db => validateEvent(db, board, entry, id, name,
497
756
  { roles: roles ?? callerRoles(db, id), payload, extra, localOnly: true }));
498
757
  const evidencePhase = ['complete', 'request_review', 'request_state'].includes(name) ? completionPhase(board, id, entry, name, payload, roles ?? callerRoles(board.db, id)) : null;
499
758
  if (evidencePhase) payload = { ...payload, ci: await observeCompletionEvidence(board, id, entry, payload, evidencePhase) };
500
- if (payload.ci?.required && !payload.ci.ready && (name !== 'request_state' || evidencePhase === 'review_verdict')) throw new Error(payload.ci.reason ?? 'The PR candidate could not be verified.');
759
+ // act retains missing review detail without firing the transition or consuming a queued request.
760
+ if (payload.ci?.required && !payload.ci.ready && (name !== 'request_state' || evidencePhase === 'review_verdict')) throw completionEvidenceRefusal(payload.ci, 'The PR candidate could not be verified.');
501
761
  const result = tx(board, (db) => {
502
762
  roles ??= callerRoles(db, id);
763
+ let backlogHold = null;
764
+ if (name === 'backlog' && backlogEvent != null) {
765
+ backlogHold = latestEvent(db, id, ['dispatch_held', 'dispatch_released', 'session_adopted'], 'id,kind,payload');
766
+ if (backlogHold?.id !== backlogEvent || backlogHold.kind !== 'dispatch_held' || !json(backlogHold.payload, {})?.backlog_request
767
+ || !backlogQualification(db, id).qualified) {
768
+ const from = statusOf(db, getTask(db, id));
769
+ return { from, to: from, moved: false, effects: [] };
770
+ }
771
+ }
772
+ if (name === 'request_review' && payload.queued_event != null) {
773
+ const queued = one(db, "SELECT payload FROM task_events WHERE task_id=? AND id=? AND kind='review_queued'", id, payload.queued_event);
774
+ const taken = one(db, "SELECT 1 FROM task_events WHERE task_id=? AND kind IN ('review_queue_started','review_queue_settled') AND json_extract(payload,'$.queued_event')=?", id, payload.queued_event);
775
+ if (!queued || taken) throw requestDetail('This queued review was already handled.');
776
+ }
503
777
  let r;
504
778
  try { r = fire(db, board, entry, id, name, { roles, payload, extra }); }
505
779
  catch (error) {
506
780
  const other = (error.refusal || error.noTransition) && instead ? instead(db, error, entry) : null;
507
781
  if (!other) throw error;
508
- r = { ...fire(db, board, entry, id, other.name, { roles, payload, extra }), instead: other };
782
+ r = { ...fire(db, board, entry, id, other.name, { roles, payload: other.payload ?? payload, extra }), instead: other };
783
+ }
784
+ if (backlogHold && getTask(db, id)?.backlog) {
785
+ const previous = json(backlogHold.payload, {})?.previous_hold;
786
+ event(db, id, 'dispatch_released', { reason: 'backlog placement completed', backlog_request: backlogEvent });
787
+ if (previous?.payload) event(db, id, 'dispatch_held', { ...previous.payload, restored_after_backlog_request: backlogEvent, restored_by: actorOf() });
788
+ r.backlogEvent = backlogEvent;
509
789
  }
510
790
  // Authorization and the comment row share one transaction: a refusal stores nothing.
791
+ if (name === 'request_review' && payload.queued_event != null && r.moved && r.to === 'review') event(db, id, 'review_queue_started', { queued_event: payload.queued_event });
792
+ // The review now running replaces every other pending request for its PR (ADR 0252).
793
+ if (name === 'request_review' && r.moved && r.to === 'review' && payload.review === 'pr' && [].concat(payload.pr ?? []).length === 1) {
794
+ const active = one(db, "SELECT id FROM task_events WHERE task_id=? AND kind='review_requested' ORDER BY id DESC LIMIT 1", id)?.id ?? null;
795
+ for (const row of pendingReviewRequests(db, id)) {
796
+ const named = [].concat(row.pr ?? []);
797
+ if (row.id === Number(payload.queued_event) || named.length !== 1 || prIdentity(named[0]) !== prIdentity([].concat(payload.pr)[0])) continue;
798
+ event(db, id, 'review_queue_settled', { queued_event: row.id, disposition: 'superseded', active_review_event: active,
799
+ queue_order: row.queue_order, review: 'pr', requested_pr: payload.pr, requested_pr_head: payload.pr_head });
800
+ }
801
+ }
511
802
  if (name === 'comment') {
803
+ if (payload.pr_evidence) {
804
+ const proof = payload.pr_evidence;
805
+ if (!preparedPrEvidence.has(proof)) throw new Error('PR evidence must enter through the verified comment door.');
806
+ const sender = roles ?? callerRoles(db, id);
807
+ if (!sender.some(role => ['implementer', 'manager'].includes(role))) throw ownerRule('PR evidence authority changed before commit.');
808
+ if (proof.kind === 'pr_release_readback') {
809
+ if (proof.verification?.kind === 'manager-attestation' && !sender.includes('manager')) throw ownerRule('Manager attestation authority changed.');
810
+ const disposition = prDisposition(board, id, proof.url, proof.head);
811
+ if (disposition.event !== proof.disposition_event || disposition.disposition !== 'release-only') throw new Error('PR disposition changed during readback.');
812
+ // Re-check the current verdict with the same binding validation used by readers.
813
+ const probe = { ...proof };
814
+ event(db, id, proof.kind, probe);
815
+ if (!releaseReadback(board, id, proof.url, proof.head)) throw new Error('The PR pass changed during readback.');
816
+ } else event(db, id, proof.kind, proof);
817
+ }
512
818
  r.commentId = Number(run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', id, payload.author.trim(), payload.body.trim(), now()).lastInsertRowid);
513
819
  joinThread(db, entry, id, payload.author.trim(), payload.body);
514
820
  }
@@ -522,25 +828,38 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
522
828
  await runEffects(board, result.effects);
523
829
  // A card this verb closed ends its agents' assignments; the verb hands the ends on itself (agent-declarations.mjs).
524
830
  if (result.moved) { const { endFinishedAgents } = await import('./agent-declarations.mjs'); await endFinishedAgents(board, [id]); }
525
- return { from: result.from, to: result.to, moved: result.moved, ...(result.commentId ? { commentId: result.commentId } : {}), ...(result.instead ? { instead: result.instead } : {}) };
831
+ return { from: result.from, to: result.to, moved: result.moved, ...(result.commentId ? { commentId: result.commentId } : {}), ...(result.instead ? { instead: result.instead } : {}), ...(result.backlogEvent != null ? { backlog_event: result.backlogEvent } : {}) };
526
832
  }
527
833
 
528
834
  export async function requestStateMany(board, ids, payload = {}) {
835
+ try { return await applyStateMany(board, ids, payload); }
836
+ catch (error) {
837
+ const results = [];
838
+ for (const id of [...new Set(ids.map(String))]) {
839
+ if (!error.ownerRule && !error.boardDetail && !error.noTransition && !error.refusal) throw error;
840
+ const receipt = await (error.ownerRule ? routeRequest : acceptRequest)(board, { id, name: 'request_state', payload, reason: error });
841
+ results.push([id, receipt.to]);
842
+ }
843
+ if (!results.length) await acceptRequest(board, { name: 'request_state', payload, reason: error });
844
+ return results;
845
+ }
846
+ }
847
+
848
+ async function applyStateMany(board, ids, payload = {}) {
529
849
  assertSourceReceipt(payload);
850
+ if (!String(payload.reason ?? '').trim()) payload = { ...payload, reason: 'none given' };
530
851
  const targets = [...new Set(ids.map(String))];
531
852
  if (!targets.length) throw new Error('A state request names at least one card.');
532
853
  const entry = await loadWorkflow(board.root);
533
854
  const prepared = new Map(await Promise.all(targets.map(async id => {
534
855
  const roles = callerRoles(board.db, id);
535
856
  const phase = completionPhase(board, id, entry, 'request_state', payload, roles);
536
- // As in complete: only a reviewer's verdict reads PRs from a state request; anyone else's would be dropped unread.
537
- if (phase !== 'review_verdict' && [].concat(payload.pr ?? [], payload.pr_head ?? []).length) throw new Error(`A state request records no PRs: declare the arc's PRs on it with comment <arc> "<why>" --pr URL --pr-head SHA (one pair per PR), then request the state without them. Nothing was requested.`);
538
857
  view(board, db => validateEvent(db, board, entry, id, 'request_state', { roles, payload, localOnly: true }));
539
858
  const evidence = await inspectCompletionEvidence(board, id, entry, payload, phase);
540
859
  // An ordinary request mails a decision to the manager, not a completion.
541
860
  // Keep a failed source observation in that mail. A reviewer recording an
542
861
  // actual verdict still has to verify the requested PR head.
543
- if (phase === 'review_verdict' && evidence.required && !evidence.ready) throw new Error(evidence.reason ?? 'The reviewed PR head could not be verified.');
862
+ if (phase === 'review_verdict' && evidence.required && !evidence.ready) throw completionEvidenceRefusal(evidence, 'The reviewed PR head could not be verified.');
544
863
  return [id, evidence];
545
864
  })));
546
865
  const result = tx(board, db => {
@@ -569,7 +888,10 @@ export async function goto(board, id, status, { roles = null, payload = {} } = {
569
888
  return { ...eventTo(db, board, entry, id, status, { roles, payload }), from: statusOf(db, getTask(db, id)) };
570
889
  })();
571
890
  if (from === status) return { from, to: status, moved: false, event: null };
572
- if (!name) throw new Error(refusal ?? `${id} is ${from}; no event you may send takes it to ${status}`);
891
+ if (!name) {
892
+ refuseUnreadManager();
893
+ return await acceptRequest(board, { id, name: 'goto', payload: { ...payload, status }, reason: refusal ?? `no workflow event admits moving ${id} from ${from} to ${status} for ${roles.length ? roles.join(', ') : 'this caller'}.` });
894
+ }
573
895
  return { ...(await act(board, id, name, { roles, payload })), event: name };
574
896
  }
575
897
 
@@ -580,7 +902,7 @@ export async function goto(board, id, status, { roles = null, payload = {} } = {
580
902
  // it keeps trying at the capped wait, and at EFFECT_ATTEMPTS failed attempts its card records `effect_stalled` and the
581
903
  // managers are mailed once, so a passed card whose merge keeps failing is seen, and still heals when the cause clears.
582
904
  export const EFFECT_ATTEMPTS = 6;
583
- const OWNED = new Set(['merge_reviewed', 'cleanup_workspace', 'rename_card_agents']);
905
+ const OWNED = new Set(['merge_reviewed', 'retire_workspace', 'cleanup_workspace', 'rename_card_agents']);
584
906
  const effectWait = (attempts) => Math.min(1800, 30 * 2 ** Math.max(0, attempts - 1));
585
907
  // A delivery pass claims a row by compare-and-set on its next_at (set ahead only while the row is due) before trying it,
586
908
  // so two passes (serve's, the hosted loop's, a person's `workflow deliver`, a verb's own) never try one effect at once.
@@ -666,7 +988,8 @@ export async function runEffects(board, effects = [], { pending: due = false, el
666
988
  /** One effect's I/O: true when it is done, false when it is not done yet (the caller counts the attempt). */
667
989
  async function runEffect(board, e) {
668
990
  if (e.kind === 'session_notice') { const { sessionNotice } = await import('./dispatch.mjs'); return await sessionNotice(board, e) !== false; }
669
- if (e.kind === 'cleanup_workspace') { await cleanupWorkspace(board, e.id); return true; }
991
+ // retire_workspace is what this release queues; cleanup_workspace rows are a predecessor's, retired by the same rule
992
+ if (e.kind === 'retire_workspace' || e.kind === 'cleanup_workspace') { await cleanupWorkspace(board, e.id, { scratch: e.kind === 'cleanup_workspace' || e.scratch === true }); return true; }
670
993
  if (e.kind === 'declare_card_agent') { const { declareCardAgentEffect } = await import('./agent-declarations.mjs'); return (await declareCardAgentEffect(board, e)) !== false; }
671
994
  if (e.kind === 'rename_card_agents') { const { renameCardAgentsEffect } = await import('./agent-declarations.mjs'); return await renameCardAgentsEffect(board, e); }
672
995
  // a verb's own ask files its card's mail at once (a local write; the mailbox delivers it)
@@ -701,7 +1024,7 @@ export function recomputeReady(board, effects = [], errors = []) {
701
1024
  // what the moves ask for is handed on only once the write commits: a retried write starts its lists again
702
1025
  const { moved, asked, failed } = tx(board, (db) => {
703
1026
  const asked = [], failed = [];
704
- let moved = 0;
1027
+ let moved = settleExpiringFlags(db, board, entry, asked);
705
1028
  for (const { id } of all(db, "SELECT id FROM tasks WHERE status NOT IN ('done', 'archived')")) {
706
1029
  const before = statusOf(db, getTask(db, id));
707
1030
  // another writer's commit is the whole write's to retry, never one card's failure
@@ -747,11 +1070,7 @@ export const complete = async (board, id, options = {}) => {
747
1070
  // A person's card closes only on that person's completion (personDone, as the board's platform event): no session,
748
1071
  // creator or manager completes it through this verb.
749
1072
  const person = view(board, (db) => personOf(getTask(db, id)?.assignee));
750
- if (person) throw Object.assign(new Error(`${id} is assigned to ${person}: it closes when that person completes it in the Workplace (person-done), never by complete. Nothing was completed.`), { refusal: true });
751
- if ([].concat(pr ?? [], pr_head ?? []).length && !view(board, (db) => roles ?? callerRoles(db, id)).includes('reviewer')) {
752
- const arc = view(board, (db) => one(db, 'SELECT subtask_of FROM supercode_cards WHERE task_id = ?', id))?.subtask_of ?? id;
753
- throw new Error(`complete records no PRs: declare the arc's PRs on it with comment ${arc} "<why>" --pr URL --pr-head SHA (one pair per PR), then complete ${id} without them. Nothing was completed.`);
754
- }
1073
+ if (person) return await routeRequest(board, { id, name: 'complete', payload: options, reason: `${id} is assigned to ${person}; obtain their completion through the Workplace person-done door.` });
755
1074
  return await act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId, commit, branch, pr, pr_head, reviewed_sha } });
756
1075
  };
757
1076
  /**
@@ -780,14 +1099,129 @@ export const block = async (board, id, { reason = null, on = null, waitingOn = n
780
1099
  await act(board, id, 'block', { roles, payload: { reason, on, kind, waiting_on: waitingOn, run_id: runId } });
781
1100
  export const unblock = (board, id, { reason = null, on = null, roles } = {}) => act(board, id, 'unblock', { roles, payload: { reason, on } });
782
1101
  export const requestReview = async (board, id, options = {}) => {
1102
+ try { return await applyRequestReview(board, id, options); }
1103
+ catch (error) {
1104
+ if (error.ownerRule) return await routeRequest(board, { id, name: 'request_review', payload: options, reason: error });
1105
+ if (error.boardDetail || error.noTransition || error.refusal) return await acceptRequest(board, { id, name: 'request_review', payload: options, reason: error });
1106
+ throw error;
1107
+ }
1108
+ };
1109
+ const applyRequestReview = async (board, id, options = {}) => {
783
1110
  assertSourceReceipt(options);
784
- const { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, branch = null, pr = null, pr_head = null, plan = false } = options;
1111
+ const { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, branch = null, plan = false, queuedEvent = null } = options;
1112
+ const sender = roles ?? callerRoles(board.db, id), entry = await loadWorkflow(board.root);
1113
+ const declared = declaredPullRequests(board.db, id);
1114
+ const previous = one(board.db, "SELECT id,payload FROM task_events WHERE task_id=? AND kind='review_requested' ORDER BY id DESC LIMIT 1", id);
1115
+ const previousPayload = json(previous?.payload, {});
1116
+ const requestedUrls = [].concat(previousPayload.requested_pr ?? []), requestedHeads = [].concat(previousPayload.requested_pr_head ?? []);
1117
+ const reviewed = [...candidatePullRequests(previousPayload.ci).map(source => ({ url: source.pr.url, head: source.commit })),
1118
+ ...requestedUrls.map((url, i) => ({ url, head: requestedHeads[i] })).filter(source => source.url && source.head)];
1119
+ const declaration = one(board.db, "SELECT MAX(id) AS id FROM task_events WHERE task_id=? AND kind='commented' AND json_type(payload,'$.sources')='array'", id)?.id ?? 0;
1120
+ const known = [...new Map((declaration > (previous?.id ?? 0) ? [...reviewed, ...declared] : [...declared, ...reviewed]).map(source => [source.url, source])).values()];
1121
+ const pr = options.pr ?? (known.length === 1 ? known[0].url : null);
1122
+ const named = [].concat(pr ?? []);
1123
+ const pr_head = options.pr_head ?? (named.length && named.every(url => known.some(source => source.url === url && source.head))
1124
+ ? named.map(url => known.find(source => source.url === url).head) : null);
785
1125
  // What a session's request asks for is what it says, never its summary's words: `--plan` asks the plan review of the
786
1126
  // proposed ADR's PR it names, and one PR named with its head (--pr, --pr-head) is that PR's review. The workflow's
787
1127
  // clauses read `review`; a request naming several PRs asks neither.
788
- const one = [].concat(pr ?? []).length === 1 && [].concat(pr_head ?? []).length === 1;
789
- const review = plan ? 'plan' : one ? 'pr' : null;
790
- return await act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId, commit, branch, pr, pr_head, review, plan: Boolean(plan) } });
1128
+ const singlePr = [].concat(pr ?? []).length === 1;
1129
+ const review = plan ? 'plan' : singlePr || !sender.includes('platform') ? 'pr' : null;
1130
+ const payload = { summary, metadata, reviewer, force, run_id: expectedRunId, commit, branch, pr, pr_head, review, plan: Boolean(plan),
1131
+ ...(queuedEvent != null ? { queued_event: queuedEvent, ...(options.requester ? { requester: options.requester } : {}) } : {}) };
1132
+ const candidateUrls = urls => [].concat(urls ?? []).map(url => typeof url === 'string' ? url.replace(/\/$/, '').toLowerCase() : url);
1133
+ const samePr = urls => [].concat(urls ?? []).length === 1 && candidateUrls(urls)[0] === candidateUrls(pr)[0];
1134
+ const sameCandidate = (urls, heads) => JSON.stringify(candidateUrls(urls)) === JSON.stringify(candidateUrls(pr)) && JSON.stringify([].concat(heads ?? [])) === JSON.stringify([].concat(pr_head ?? []));
1135
+ const activeCandidate = db => {
1136
+ const active = latestReviewRequest(db, id);
1137
+ const candidate = json(active?.payload, {}), sources = candidatePullRequests(candidate.ci);
1138
+ return active && active.kind === (review ?? 'final') && sameCandidate(candidate.requested_pr ?? sources.map(source => source.pr.url), candidate.requested_pr_head ?? sources.map(source => source.commit)) ? active.id : null;
1139
+ };
1140
+ // Recovery is explicit and names one unsettled receipt on this card. Inspect B without replacing the active
1141
+ // review's observation, then append B and settle A together. A failed fresh admission leaves A pending.
1142
+ if (options.supersedes != null && queuedEvent == null) {
1143
+ const supersedes = Number(options.supersedes);
1144
+ if (!Number.isSafeInteger(supersedes) || supersedes <= 0) throw requestDetail('--supersedes names a positive queued review event ID.');
1145
+ if (!singlePr) throw requestDetail('A queued PR replacement names one PR and its head: --pr URL --pr-head SHA.');
1146
+ const authorize = db => {
1147
+ const task = getTask(db, id);
1148
+ if (!task) throw requestDetail(`No card ${id} holds that queued review.`);
1149
+ const currentSender = roles ?? callerRoles(db, id);
1150
+ if (!currentSender.some(role => ['implementer', 'reviewer', 'auditor', 'arc', 'creator', 'manager'].includes(role))) throw ownerRule('A card\'s own role or manager supersedes its queued PR review.');
1151
+ const from = statusOf(db, task);
1152
+ const assignee = from === 'review' ? scopeOf(db, board, task, { params: entry.params, lazy: true, deferSources: true }).card.implementer : task.assignee;
1153
+ const pending = pendingReviewRequests(db, id), original = pending.find(row => row.id === supersedes);
1154
+ if (!original) {
1155
+ const settled = one(db, "SELECT id,payload FROM task_events WHERE task_id=? AND kind='review_queue_settled' AND json_extract(payload,'$.queued_event')=? ORDER BY id DESC LIMIT 1", id, supersedes);
1156
+ const prior = json(settled?.payload, {});
1157
+ if (prior.disposition === 'superseded' && prior.review === review
1158
+ && sameCandidate(prior.requested_pr, prior.requested_pr_head)) return { retry: {
1159
+ disposition: 'already', requested: 'request_review', id, pr, pr_head, supersedes,
1160
+ settlement_event: settled.id, ...(prior.replacement_event != null ? { event: prior.replacement_event, queue_event: prior.replacement_event, queue_order: prior.queue_order } : {}),
1161
+ ...(prior.active_review_event != null ? { active_review_event: prior.active_review_event, in_review: from === 'review' && activeCandidate(db) === prior.active_review_event } : {}),
1162
+ from, to: from, moved: false, already: true, reason: 'This correction was already recorded; nothing new was queued. Its original replacement receipt is retained.' } };
1163
+ throw requestDetail(`Queued review ${supersedes} is not pending on this card; its original receipt is retained.`);
1164
+ }
1165
+ return { task, from, assignee, currentSender, pending, original };
1166
+ };
1167
+ const checked = view(board, authorize);
1168
+ if (checked.retry) { (current().acceptedRequests ??= []).push(checked.retry); return checked.retry; }
1169
+ const evidence = await inspectCompletionEvidence(board, id, entry, payload, 'request_review');
1170
+ if (!evidence.ready || !candidatePullRequests(evidence).length) throw completionEvidenceRefusal(evidence, 'The replacement PR candidate could not be verified.');
1171
+ const receipt = tx(board, db => {
1172
+ const authorized = authorize(db);
1173
+ if (authorized.retry) return authorized.retry;
1174
+ const { from, assignee, currentSender, pending, original } = authorized;
1175
+ const requester = actorOf() ?? current().caller;
1176
+ const active = from === 'review' ? activeCandidate(db) : null;
1177
+ if (active) {
1178
+ const settlement = event(db, id, 'review_queue_settled', { queued_event: supersedes, disposition: 'superseded',
1179
+ active_review_event: active, queue_order: original.queue_order, requester, sender: currentSender, assignee, review,
1180
+ requested_pr: pr, requested_pr_head: pr_head, ci: evidence });
1181
+ return { accepted: true, disposition: 'already', requested: 'request_review', id, pr, pr_head, supersedes,
1182
+ active_review_event: active, settlement_event: settlement, in_review: true, from, to: from, moved: false, already: true,
1183
+ reason: `Queued review ${supersedes} is superseded by the candidate already under review.` };
1184
+ }
1185
+ const same = pending.find(row => row.review === review && sameCandidate(row.pr, row.pr_head));
1186
+ if (same?.id === supersedes) return { queued: true, disposition: 'queued', requested: 'request_review', id, pr, pr_head,
1187
+ event: same.id, queue_event: same.id, queue_order: same.queue_order, from, to: from, moved: false, already: true };
1188
+ const queued = same?.id ?? event(db, id, 'review_queued', { ...payload, superseded_event: supersedes, queue_order: original.queue_order,
1189
+ sender: currentSender, requester, assignee });
1190
+ const queueOrder = Math.min(original.queue_order, same?.queue_order ?? original.queue_order);
1191
+ const settlement = event(db, id, 'review_queue_settled', { queued_event: supersedes, disposition: 'superseded',
1192
+ replacement_event: queued, queue_order: queueOrder, requester, sender: currentSender, assignee, review,
1193
+ requested_pr: pr, requested_pr_head: pr_head, ci: evidence });
1194
+ return { queued: true, disposition: 'queued', requested: 'request_review', id, pr, pr_head, supersedes,
1195
+ event: queued, queue_event: queued, settlement_event: settlement, queue_order: queueOrder,
1196
+ from, to: from, moved: false, already: Boolean(same) };
1197
+ });
1198
+ (receipt.queued ? current().queuedRequests ??= [] : current().acceptedRequests ??= []).push(receipt);
1199
+ return receipt;
1200
+ }
1201
+ const receipt = tx(board, db => {
1202
+ const task = getTask(db, id);
1203
+ if (!task || statusOf(db, task) !== 'review') return null;
1204
+ if (!sender.some(role => ['implementer', 'reviewer', 'auditor', 'arc', 'creator', 'manager'].includes(role))) throw ownerRule('A card\'s own role or manager requests its PR review.');
1205
+ const assignee = scopeOf(db, board, task, { params: entry.params, lazy: true, deferSources: true }).card.implementer;
1206
+ if (activeCandidate(db))
1207
+ return { accepted: true, already: true, disposition: 'already', requested: 'request_review', id, from: 'review', to: 'review', moved: false, reason: 'This candidate is already under review.' };
1208
+ const pending = pendingReviewRequests(db, id);
1209
+ const same = pending.find(row => row.review === review && sameCandidate(row.pr, row.pr_head));
1210
+ if (same) return { queued: true, disposition: 'queued', requested: 'request_review', id, pr, pr_head, event: same.id, queue_event: same.id, queue_order: same.queue_order, from: 'review', to: 'review', moved: false, already: true };
1211
+ // A newer request for the same PR replaces its pending ones at the earliest of their positions (ADR 0252).
1212
+ const replaced = review === 'pr' && singlePr ? pending.filter(row => samePr(row.pr)) : [];
1213
+ const queueOrder = replaced.length ? Math.min(...replaced.map(row => row.queue_order)) : null;
1214
+ const requester = actorOf() ?? current().caller;
1215
+ const queued = event(db, id, 'review_queued', { ...payload, ...(queueOrder != null ? { queue_order: queueOrder } : {}), sender, requester, assignee });
1216
+ let settlement = null;
1217
+ for (const row of replaced) settlement = event(db, id, 'review_queue_settled', { queued_event: row.id, disposition: 'superseded',
1218
+ replacement_event: queued, queue_order: queueOrder, requester, sender, assignee, review, requested_pr: pr, requested_pr_head: pr_head });
1219
+ return { queued: true, disposition: 'queued', requested: 'request_review', id, pr, pr_head, event: queued, queue_event: queued,
1220
+ queue_order: queueOrder ?? queued, ...(settlement != null ? { settlement_event: settlement, superseded: replaced.map(row => row.id) } : {}),
1221
+ from: 'review', to: 'review', moved: false, already: false };
1222
+ });
1223
+ if (receipt) { (receipt.queued ? current().queuedRequests ??= [] : current().acceptedRequests ??= []).push(receipt); return receipt; }
1224
+ return await act(board, id, 'request_review', { roles, payload });
791
1225
  };
792
1226
  export const requestChanges = (board, id, { reason = null, reviewer = null, roles } = {}) => act(board, id, 'request_changes', { roles, payload: { reason, reviewer } });
793
1227
  export const reopen = (board, id, { author = 'user', reason = null, roles } = {}) => act(board, id, 'reopen', { roles, payload: { author, reason } });
@@ -805,11 +1239,20 @@ export async function reopenReview(board, id, { roles, reason = null } = {}) {
805
1239
  // the refusal, the check that the open run is a reviewer's and the restart are one write: no verdict lands between
806
1240
  const moved = await act(board, id, 'reopen_review', { roles, payload: { reason }, instead: (db, _error, entry) => {
807
1241
  const run = currentRun(db, id);
1242
+ if (!run || run.ended_at != null || run.metadata?.supercode?.slot !== 'reviewer') return null;
1243
+ // An arc re-declared since its review request (redeclaredSince) is not restarted on that request's frozen candidate:
1244
+ // the review is returned to its coder with no verdict, naming the declaration (`redeclared`), so the return holds
1245
+ // no returned set (returnedReview): the next review is requested from the declared set when the ordinary rules make
1246
+ // one due (reviewDue, settledReviewDue), as a new review_requested its reviewer binds (VO-3775).
1247
+ const asked = db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id=? AND kind='review_requested'").get(id)?.id ?? null;
1248
+ const redeclared = asked ? redeclaredSince(db, id, asked) : null;
1249
+ if (redeclared && transitionsFor(entry.workflow, 'review', 'return_review').length)
1250
+ return { name: 'return_review', run: run.id, returned: true, payload: { reason: reason ?? 'the arc re-declared its sources after its review request', redeclared } };
808
1251
  const stays = transitionsFor(entry.workflow, 'review', 'reclaim').some((t) => !t.target || t.target === 'review');
809
- return run && run.ended_at == null && run.metadata?.supercode?.slot === 'reviewer' && stays ? { name: 'reclaim', run: run.id } : null;
1252
+ return stays ? { name: 'reclaim', run: run.id } : null;
810
1253
  } });
811
1254
  const { instead, ...rest } = moved;
812
- return instead ? { ...rest, restarted_run: instead.run } : rest;
1255
+ return instead ? { ...rest, ...(instead.returned ? { returned_run: instead.run } : { restarted_run: instead.run }) } : rest;
813
1256
  }
814
1257
  export const schedule = (board, id, reason = null, { roles } = {}) => act(board, id, 'schedule', { roles, payload: { reason } });
815
1258
  export const promote = (board, id, { actor = 'user', reason = null, force = false, roles } = {}) => act(board, id, 'promote', { roles, payload: { actor, reason, force } });
@@ -841,11 +1284,19 @@ export async function specify(board, id, { title = null, body = null, readableId
841
1284
  // an arc's new task lines are filed as its subtasks once the specify itself is accepted
842
1285
  const steps = body != null && !one(board.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id = ?', id)?.subtask_of ? bodyTasks(body) : { body, tasks: [] };
843
1286
  const done = await act(board, id, 'specify', { roles, payload: { title, body: steps.body, readable_id: readableId, author, ...(declaration ? { workspace: declaration } : {}) } });
1287
+ if (done.routed || done.accepted) return done;
844
1288
  for (const step of steps.tasks) createCard(board, { ...step, subtaskOf: id, assignee: getTask(board.db, id)?.assignee || null });
845
1289
  return done;
846
1290
  }
847
1291
  export const editResult = (board, id, { result, summary = null, metadata = undefined, roles } = {}) => act(board, id, 'edit', { roles, payload: { result, summary, metadata } });
848
- export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
1292
+ export const heartbeat = async (board, id, note = null, { roles, until = null } = {}) => {
1293
+ const deadline = deadlineUntil(until, now(), 'heartbeat');
1294
+ if (Number.isNaN(deadline)) {
1295
+ await act(board, id, 'heartbeat', { roles, payload: { note, until: null } });
1296
+ return await acceptRequest(board, { id, name: 'heartbeat', payload: { note, until }, reason: 'Heartbeat recorded; its wait deadline was left blank because the supplied time was unreadable or outside the supported date range.' });
1297
+ }
1298
+ return await act(board, id, 'heartbeat', { roles, payload: { note, until: deadline } });
1299
+ };
849
1300
  export const setGoal = (board, id, { on = true, maxTurns = null, roles } = {}) => act(board, id, 'set_goal', { roles, payload: { goal: Boolean(on), max_turns: on ? maxTurns : null } });
850
1301
 
851
1302
  /** File the named card's tasks only if its workflow accepts decompose; refusal leaves no cards behind. */
@@ -884,14 +1335,14 @@ export function link(board, parent, child, { roles } = {}) {
884
1335
  export async function unlink(board, parent, child, { roles } = {}) {
885
1336
  const linked = view(board, (db) => one(db, 'SELECT 1 FROM task_links WHERE parent_id = ? AND child_id = ?', parent, child));
886
1337
  if (!linked) return false;
887
- await act(board, child, 'unlink', { roles, payload: { parent, child } });
888
- return true;
1338
+ const result = await act(board, child, 'unlink', { roles, payload: { parent, child } });
1339
+ return result.routed || result.accepted ? result : true;
889
1340
  }
890
1341
 
891
1342
  /** A comment on the card's thread; answers its id. */
892
- export async function comment(board, id, author, body, { roles, pr = null, pr_head = null, source = null } = {}) {
893
- if (!String(body ?? '').trim()) throw new Error('a comment has a body');
894
- if (!String(author ?? '').trim()) throw new Error('a comment has an author');
1343
+ export async function comment(board, id, author, body, { roles, pr = null, pr_head = null, source = null, disposition = null, release_target = null, readback = null, reason = null } = {}) {
1344
+ body = String(body ?? '');
1345
+ author = String(author ?? actorOf() ?? current().caller ?? 'board caller');
895
1346
  let sources, branches;
896
1347
  // The repositories and branches an arc changes are the arc's own: declared on it (`--source owner/repo:branch`, each
897
1348
  // repository the arc changes), never registered as a subtask's workspace to be reviewed (VO-2858 had to register
@@ -910,7 +1361,7 @@ export async function comment(board, id, author, body, { roles, pr = null, pr_he
910
1361
  if (new Set(branches.map((b) => `${b.repository.toLowerCase()}:${b.branch}`)).size !== branches.length) throw new Error('Declare each source once.');
911
1362
  const arc = one(board.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id = ?', id)?.subtask_of;
912
1363
  if (arc) throw new Error(`${id} is a task of ${arc}: an arc's sources are declared on the arc (comment ${arc} … --source owner/repository:branch).`);
913
- if (!(roles ?? callerRoles(board.db, id)).some((role) => role === 'implementer' || role === 'manager')) throw new Error(`Only ${id}'s implementer or the manager declares its sources.`);
1364
+ if (!(roles ?? callerRoles(board.db, id)).some((role) => role === 'implementer' || role === 'manager')) throw ownerRule(`Only ${id}'s implementer or the manager declares its sources.`);
914
1365
  }
915
1366
  if (pr != null || pr_head != null) {
916
1367
  const urls = [].concat(pr ?? []), heads = [].concat(pr_head ?? []);
@@ -925,7 +1376,7 @@ export async function comment(board, id, author, body, { roles, pr = null, pr_he
925
1376
  return { url: url.replace(/\/$/, ''), head: heads[i] };
926
1377
  });
927
1378
  // Only the card's own implementer or the board's manager declares its sources; the author text is not identity.
928
- if (!(roles ?? callerRoles(board.db, id)).some((role) => role === 'implementer' || role === 'manager')) throw new Error(`Only ${id}'s implementer or the manager records its PR sources.`);
1379
+ if (!(roles ?? callerRoles(board.db, id)).some((role) => role === 'implementer' || role === 'manager')) throw ownerRule(`Only ${id}'s implementer or the manager records its PR sources.`);
929
1380
  // A declared PR is in one of the arc's repositories (its workspace, its declared sources, its tasks'; a `--source`
930
1381
  // in this same comment counts with the arc's declared sources): the review reads no other, so one outside them is
931
1382
  // refused here and nothing is stored, never kept to be dropped from every review request (t_ef2aa6ce). The board's
@@ -941,8 +1392,19 @@ export async function comment(board, id, author, body, { roles, pr = null, pr_he
941
1392
  }
942
1393
  if (outside.length) throw new Error(outside.map(({ url, repository }) => `${url} is in ${repository}, which is not one of ${id}'s repositories (its workspace, its declared sources, its tasks'): declare ${repository} first with comment ${id} "<why>" --source ${repository}:<branch>, then record its PRs. Nothing was recorded.`).join(' '));
943
1394
  }
944
- const result = await act(board, id, 'comment', { roles, payload: { author: author.trim(), body, ...(sources ? { sources } : {}), ...(branches ? { branches } : {}) } });
945
- return result.commentId;
1395
+ let prEvidence;
1396
+ if (disposition != null || readback != null) {
1397
+ const sender = roles ?? callerRoles(board.db, id);
1398
+ if (!sender.some(role => ['implementer', 'manager'].includes(role))) throw ownerRule('Only the implementer or manager records PR disposition/readback.');
1399
+ if (sources?.length !== 1) throw new Error('PR disposition/readback names one --pr and --pr-head.');
1400
+ if (disposition != null && readback != null) throw new Error('Declare the disposition before recording its readback.');
1401
+ prEvidence = await preparePrEvidence(board, id, sources[0], { disposition, target: release_target, readback, reason }, { manager: sender.includes('manager'), actor: actorOf() });
1402
+ preparedPrEvidence.add(prEvidence);
1403
+ }
1404
+ try {
1405
+ const result = await act(board, id, 'comment', { roles, payload: { author: author.trim(), body, ...(sources ? { sources } : {}), ...(branches ? { branches } : {}), ...(prEvidence ? { pr_evidence: prEvidence } : {}) } });
1406
+ return result.routed || result.accepted ? result : result.commentId;
1407
+ } finally { if (prEvidence) preparedPrEvidence.delete(prEvidence); }
946
1408
  }
947
1409
 
948
1410
  // ------------------------------------------------------------------ subscriptions
@@ -951,10 +1413,19 @@ export async function comment(board, id, author, body, { roles, pr = null, pr_he
951
1413
  * Subscribe `address` (a supercode session address) to a card's events from now on. `role` is `creator` (told of
952
1414
  * starts, completion and blocks) or `worker` (told of updates to the card it works).
953
1415
  */
954
- export function subscribeIn(db, id, address, role = 'creator', { explicit = false, own = false } = {}) {
1416
+ export function subscribeIn(db, id, address, role = 'creator', { explicit = false, own = false, check = false } = {}) {
955
1417
  // a session that is an agent's main follows a card under its agent's address: one follower, one subscription
956
1418
  if (!['worker', 'dependency'].includes(role)) address = followerAddress(address);
957
1419
  if (personCustody(db, address)) return;
1420
+ const outranked = FOLLOWER_RANKS.includes(role) ? FOLLOWER_RANKS.slice(FOLLOWER_RANKS.indexOf(role) + 1) : ['participant'];
1421
+ if (check) {
1422
+ const held = one(db, "SELECT delivery_metadata FROM kanban_notify_subs WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=''", id, address);
1423
+ if (!held) return true;
1424
+ const metadata = json(held.delivery_metadata, {}) ?? {};
1425
+ return Boolean(role === 'worker' && metadata.role !== 'worker' && ![].concat(metadata.also ?? []).includes('worker')
1426
+ || (own || explicit) && metadata.own !== true || explicit && metadata.unsubscribed
1427
+ || outranked.includes(metadata.role));
1428
+ }
958
1429
  const cursor = one(db, 'SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?', id).c;
959
1430
  run(db, `INSERT OR IGNORE INTO kanban_notify_subs (task_id, platform, chat_id, thread_id, chat_type, delivery_mode, delivery_metadata, created_at, last_event_id)
960
1431
  VALUES (?, 'supercode', ?, '', 'dm', 'notify+wake', ?, ?, ?)`, id, address, JSON.stringify({ role, ...(own ? { own: true } : {}) }), now(), cursor);
@@ -970,7 +1441,6 @@ export function subscribeIn(db, id, address, role = 'creator', { explicit = fals
970
1441
  // participant or creator that is the board's manager hears it as the manager, never as both. What the new role hears
971
1442
  // is told from now on, never the card's history (the cursor moves to the card's last event).
972
1443
  // A worker or a dependency follower takes over a participant's subscription, as before.
973
- const outranked = FOLLOWER_RANKS.includes(role) ? FOLLOWER_RANKS.slice(FOLLOWER_RANKS.indexOf(role) + 1) : ['participant'];
974
1444
  // the role it held is kept beside it (`also`): what that role heard, the follower still hears (a manager that created
975
1445
  // a card is told its audit rounds as its creator was)
976
1446
  // (only a follower's role is kept so, appended to what it kept before; a worker or dependency hears its own kinds)
@@ -1068,7 +1538,7 @@ export function unsubscribe(board, id, address) {
1068
1538
  }
1069
1539
 
1070
1540
  /** Reconcile only the current worker's audience, inside the binding transaction. */
1071
- export function replaceWorkerSubscriptions(db, id, address) {
1541
+ export function replaceWorkerSubscriptions(db, id, address, { check = false } = {}) {
1072
1542
  const active = currentRun(db, id), session = active?.metadata?.supercode;
1073
1543
  const bound = session?.address ?? (session?.session_id && session.machine ? `sc:${session.machine}:${session.harness ?? 'claude-code'}:${session.session_id}` : null);
1074
1544
  if (!address || bound !== address || active.ended_at) return false;
@@ -1079,15 +1549,17 @@ export function replaceWorkerSubscriptions(db, id, address) {
1079
1549
  const removed = [];
1080
1550
  for (const { task_id: card } of family) {
1081
1551
  for (const old of replaces ? all(db, "SELECT chat_id,thread_id FROM kanban_notify_subs WHERE task_id=? AND platform='supercode' AND json_extract(delivery_metadata,'$.role')='worker' AND chat_id!=?", card, address) : []) {
1552
+ if (check) return true;
1082
1553
  run(db, "DELETE FROM kanban_notify_subs WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=? AND json_extract(delivery_metadata,'$.role')='worker'", card, old.chat_id, old.thread_id);
1083
1554
  removed.push({ card, address: old.chat_id });
1084
1555
  }
1085
1556
  // INSERT OR IGNORE preserves an independent creator/manager subscription
1086
1557
  // and the current worker's existing cursor on repeated reconciliation.
1087
- subscribeIn(db, card, address, 'worker');
1558
+ const needed = subscribeIn(db, card, address, 'worker', { check });
1559
+ if (check && needed) return true;
1088
1560
  }
1089
1561
  if (removed.length) event(db, id, 'notice_subscriptions_replaced', { address, role: 'worker', superseded: removed, reason: 'the current worker binding replaced its audience' }, active.id);
1090
- return true;
1562
+ return !check;
1091
1563
  }
1092
1564
 
1093
1565
  export function subscribe(board, id, address, role = 'creator', { own = false } = {}) {
@@ -1122,21 +1594,30 @@ export function subscribeDependency(board, ownerId, sourceId, { remove = false }
1122
1594
  });
1123
1595
  }
1124
1596
 
1125
- export function reconcileDependencySubscriptions(db) {
1126
- for (const row of all(db, "SELECT * FROM kanban_notify_subs WHERE platform='supercode' AND json_extract(delivery_metadata,'$.dependent_card') IS NOT NULL")) {
1597
+ export function reconcileDependencySubscriptions(db, id, { check = false } = {}) {
1598
+ for (const row of all(db, "SELECT * FROM kanban_notify_subs WHERE task_id=? AND platform='supercode' AND json_extract(delivery_metadata,'$.dependent_card') IS NOT NULL", id)) {
1127
1599
  const metadata = json(row.delivery_metadata, {});
1128
1600
  const owner = getTask(db, metadata.dependent_card);
1129
1601
  const address = owner ? dependencyRecipient(db, owner.id) : null;
1130
1602
  const where = "WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=?";
1131
1603
  const identity = [row.task_id, row.chat_id, row.thread_id];
1132
- if (!owner || owner.detached || owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) run(db, `DELETE FROM kanban_notify_subs ${where}`, ...identity);
1604
+ if (!owner || owner.detached || owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) {
1605
+ if (check) return true;
1606
+ run(db, `DELETE FROM kanban_notify_subs ${where}`, ...identity);
1607
+ }
1133
1608
  // a subscription moved to its arc's new session starts from the card's last event: the new session is told what
1134
1609
  // happens from now on, never the card's history the old one was told or missed
1135
1610
  // (a subscription that waited for its arc to have a session keeps its cursor: what it waited to tell is still told)
1136
- else if (address && (address !== row.chat_id || metadata.waiting_recipient)) run(db, `UPDATE kanban_notify_subs SET chat_id=?,delivery_metadata=?,last_event_id=CASE WHEN ? THEN MAX(last_event_id,(SELECT COALESCE(MAX(id),0) FROM task_events WHERE task_id=?)) ELSE last_event_id END ${where}`,
1137
- address, JSON.stringify({ ...metadata, notice_wait: undefined, waiting_recipient: false, failures: 0 }), address !== row.chat_id ? 1 : 0, row.task_id, ...identity);
1138
- else if (!address && !metadata.waiting_recipient) run(db, `UPDATE kanban_notify_subs SET delivery_metadata=? ${where}`, JSON.stringify({ ...metadata, waiting_recipient: true }), ...identity);
1611
+ else if (address && (address !== row.chat_id || metadata.waiting_recipient)) {
1612
+ if (check) return true;
1613
+ run(db, `UPDATE kanban_notify_subs SET chat_id=?,delivery_metadata=?,last_event_id=CASE WHEN ? THEN MAX(last_event_id,(SELECT COALESCE(MAX(id),0) FROM task_events WHERE task_id=?)) ELSE last_event_id END ${where}`,
1614
+ address, JSON.stringify({ ...metadata, notice_wait: undefined, waiting_recipient: false, failures: 0 }), address !== row.chat_id ? 1 : 0, row.task_id, ...identity);
1615
+ } else if (!address && !metadata.waiting_recipient) {
1616
+ if (check) return true;
1617
+ run(db, `UPDATE kanban_notify_subs SET delivery_metadata=? ${where}`, JSON.stringify({ ...metadata, waiting_recipient: true }), ...identity);
1618
+ }
1139
1619
  }
1620
+ return false;
1140
1621
  }
1141
1622
 
1142
1623
  /** A child hears what its parents' subscribers hear (Hermes copies them at link time); a subtask only its arc's session. */
@@ -1163,21 +1644,29 @@ export const subscriptions = (db, id = null) => (id ? all(db, 'SELECT * FROM kan
1163
1644
 
1164
1645
  // ------------------------------------------------------------------ reading
1165
1646
 
1166
- export function listCards(db, { status = null, assignee = null, machine = null, includeArchived = false, includeSubtasks = true, nativeHolds = false } = {}) {
1647
+ export function listCards(db, { status = null, assignee = null, machine = null, includeArchived = false, includeSubtasks = true, nativeHolds = false, backlog = null } = {}) {
1167
1648
  const stateSql = cardStateSql({ nativeHolds });
1168
- const rows = all(db, `SELECT t.*, n.number, c.machine, c.subtask_of, c.stage, c.tags, c.paused, c.blocked_by FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id
1169
- WHERE (? IS NULL OR ${stateSql} = ? OR (? NOT IN ('paused','blocked') AND c.stage = ?)) AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND ${CARD_VISIBILITY_SQL}
1170
- ORDER BY t.priority DESC, t.created_at ASC`, status, status, status, status, assignee, assignee, machine, machine, includeSubtasks ? 1 : 0, includeArchived ? 1 : 0);
1649
+ const rows = all(db, `SELECT t.*, n.number, c.machine, c.subtask_of, c.stage, c.tags, c.paused, c.backlog, c.blocked_by FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id
1650
+ WHERE (? IS NULL OR ${stateSql} = ? OR (? NOT IN ('paused','blocked') AND c.stage = ?)) AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? IS NULL OR (c.backlog IS NOT NULL AND c.backlog != 'null') = ?) AND ${CARD_VISIBILITY_SQL}
1651
+ ORDER BY t.priority DESC, t.created_at ASC`, status, status, status, status, assignee, assignee, machine, machine, backlog == null ? null : Number(Boolean(backlog)), backlog == null ? null : Number(Boolean(backlog)), includeSubtasks ? 1 : 0, includeArchived ? 1 : 0);
1171
1652
  return rows.map((r) => cardState({ ...r, skills: json(r.skills, []) ?? [], execution: executionOf(r.current_run_id ? one(db, 'SELECT id,metadata,ended_at FROM task_runs WHERE id=?', r.current_run_id) : null, readingContext(db, r.id, r.current_run_id)) }, { nativeHolds }));
1172
1653
  }
1173
1654
 
1174
1655
  export function showCard(db, id, options = {}) {
1175
1656
  const task = cardState({ ...must(db, id), reviewer: reviewerOf(db, id) }, options);
1657
+ const review = options.review ? reviewedPullRequest(db, id) : null;
1658
+ if (review) return {
1659
+ task: { ...task, body: cardContext(db, id, options), result: null },
1660
+ review_request: { id: review.id, kind: review.kind, payload: json(review.payload, {}) },
1661
+ parents: [], children: [], subtasks: openPrReviewFindings(db, id, review),
1662
+ pr_evidence: [], comments: [], events: [], runs: [], subscriptions: [],
1663
+ };
1176
1664
  return {
1177
1665
  task,
1178
1666
  parents: parentsOf(db, id),
1179
1667
  children: childrenOf(db, id),
1180
- subtasks: all(db, `SELECT t.id, n.number, t.status, t.title, c.stage, c.paused, c.blocked_by FROM tasks t JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at`, id).map(row => cardState(row, options)),
1668
+ subtasks: all(db, `SELECT t.id, n.number, t.status, t.title, c.stage, c.paused, c.backlog, c.blocked_by FROM tasks t JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at`, id).map(row => cardState(row, options)),
1669
+ pr_evidence: all(db, "SELECT id,kind,payload FROM task_events WHERE task_id=? AND kind IN ('pr_disposition_recorded','pr_release_readback') ORDER BY id", id).map(row => ({ ...row, payload: json(row.payload, null) })),
1181
1670
  comments: all(db, 'SELECT author, body, created_at FROM task_comments WHERE task_id = ? ORDER BY id', id),
1182
1671
  runs: all(db, 'SELECT * FROM task_runs WHERE task_id = ? ORDER BY id', id).map((r) => ({ ...r, metadata: json(r.metadata, null) })),
1183
1672
  events: all(db, 'SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? ORDER BY id', id).map((e) => ({ ...e, payload: json(e.payload, null) })),
@@ -1189,13 +1678,49 @@ export function showCard(db, id, options = {}) {
1189
1678
  // existed is withheld there, naming why, so no prompt carries it.
1190
1679
  const cap = (text, n) => { text = text ? withheldIfDump(text) : text; return text && text.length > n ? `${text.slice(0, n)}… (${text.length - n} more characters)` : text; };
1191
1680
 
1192
- /** What a session starting on a card reads first: the card, its parents' results, earlier attempts and the thread. */
1681
+ /** The PR review request the card's current reviewer run judges, or null: a plan or final review reads the whole arc. */
1682
+ export function reviewedPullRequest(db, id) {
1683
+ const session = currentRun(db, id)?.metadata?.supercode;
1684
+ if (session?.role !== 'reviewer' && session?.slot !== 'reviewer') return null;
1685
+ const request = latestReviewRequest(db, id);
1686
+ return request?.kind === 'pr' ? request : null;
1687
+ }
1688
+
1689
+ /** Whether the calling session or run is the card's current reviewer: its brief, context and show are its review's. */
1690
+ export function reviewsCard(db, id, { sessionId = null, runId = null } = {}) {
1691
+ const run = currentRun(db, id), session = run?.metadata?.supercode;
1692
+ if (!run || run.ended_at != null || (session?.role !== 'reviewer' && session?.slot !== 'reviewer')) return false;
1693
+ return (runId != null && Number(runId) === run.id) || (sessionId != null && session?.session_id === sessionId);
1694
+ }
1695
+
1696
+ function reviewBody(request) {
1697
+ const payload = json(request.payload, {});
1698
+ return [
1699
+ `PR review request ${request.id}. Review the named diff against its base, judged against the card's purpose above and the ADRs the change falls under, whether or not the diff edits them.`,
1700
+ ...candidatePullRequests(payload.ci).map(p => `${p.pr.url} at ${p.commit ?? p.pr.head}${p.pr.base ? ` against ${p.pr.base}` : ''}${p.repository ? ` (${p.repository})` : ''}.`),
1701
+ ...(payload.summary ? [`Summary: ${withheldIfDump(payload.summary)}`] : []),
1702
+ ].join('\n');
1703
+ }
1704
+
1705
+ /** What a session starting on a card reads first: the card, its parents' results, earlier attempts and the thread. A PR
1706
+ * review's reviewer (`review`) reads the card itself, then that PR's frozen candidate and its open findings in place of
1707
+ * the rest; each brief replaces the last in its retained conversation. */
1193
1708
  export function cardContext(db, id, options = {}) {
1194
1709
  const task = cardState(must(db, id), options);
1195
1710
  const out = [`# Card ${task.id}: ${task.title}`, ''];
1196
1711
  out.push(`Readable id: ${task.readable_id}.`);
1197
1712
  out.push(`Status: ${task.status}. Machine: ${task.machine ?? 'unset'}. Workspace: ${task.workspace_kind}${task.workspace_anchor ? ` ${task.workspace_anchor}` : task.workspace_path ? ` ${task.workspace_path}` : ''}${task.branch_name ? ` (branch ${task.branch_name})` : ''}.${task.subtask_of ? ` A subtask of ${task.subtask_of}.` : ''}`);
1198
1713
  if (task.body) out.push('', cap(task.body, 8 * 1024));
1714
+ const review = options.review ? reviewedPullRequest(db, id) : null;
1715
+ if (review) {
1716
+ out.push('', reviewBody(review));
1717
+ const findings = openPrReviewFindings(db, id, review);
1718
+ if (findings.length) {
1719
+ out.push('', '## This PR\'s open findings');
1720
+ for (const finding of findings) out.push(`- ${finding.id} [${finding.status}] ${withheldIfDump(finding.title)}${finding.body ? `\n${withheldIfDump(finding.body)}` : ''}`);
1721
+ }
1722
+ return out.join('\n');
1723
+ }
1199
1724
  const parents = all(db, 'SELECT t.id, t.title, t.result FROM tasks t JOIN task_links l ON l.parent_id = t.id WHERE l.child_id = ?', id);
1200
1725
  if (parents.length) {
1201
1726
  out.push('', '## Parent cards');
@@ -1285,9 +1810,17 @@ export function stats(db, options = {}) {
1285
1810
  for (const r of all(db, `SELECT t.assignee, ${stateSql} AS board_status, COUNT(*) AS n ${rows} WHERE t.status != 'archived' AND t.assignee IS NOT NULL GROUP BY t.assignee, board_status`)) (byAssignee[r.assignee] ??= {})[r.board_status] = Number(r.n);
1286
1811
  const byMachine = {};
1287
1812
  for (const r of all(db, `SELECT c.machine, ${stateSql} AS board_status, COUNT(*) AS n ${rows} WHERE t.status != 'archived' AND c.machine IS NOT NULL GROUP BY c.machine, board_status`)) (byMachine[r.machine] ??= {})[r.board_status] = Number(r.n);
1288
- const oldest = one(db, `SELECT MIN(t.created_at) AS ts ${rows} WHERE ${stateSql} = 'ready'`)?.ts;
1813
+ const byLane = {};
1814
+ for (const row of all(db, `SELECT COALESCE(c.stage,t.status) AS lane,COUNT(*) AS n ${rows} WHERE t.status != 'archived' GROUP BY lane`)) byLane[row.lane] = Number(row.n);
1815
+ const backlog = { total: 0, by_assignee: {}, by_machine: {} };
1816
+ for (const row of all(db, `SELECT t.assignee,c.machine,COUNT(*) AS n ${rows} WHERE t.status != 'archived' AND c.backlog IS NOT NULL AND c.backlog != 'null' GROUP BY t.assignee,c.machine`)) {
1817
+ const count = Number(row.n); backlog.total += count;
1818
+ if (row.assignee != null) backlog.by_assignee[row.assignee] = (backlog.by_assignee[row.assignee] ?? 0) + count;
1819
+ if (row.machine != null) backlog.by_machine[row.machine] = (backlog.by_machine[row.machine] ?? 0) + count;
1820
+ }
1821
+ const oldest = one(db, `SELECT MIN(t.created_at) AS ts ${rows} WHERE ${stateSql} = 'ready' AND (c.backlog IS NULL OR c.backlog = 'null')`)?.ts;
1289
1822
  const t = now();
1290
- return { by_status: byStatus, by_assignee: byAssignee, by_machine: byMachine, oldest_ready_age_seconds: oldest ? t - Number(oldest) : null, now: t };
1823
+ return { by_status: byStatus, by_lane: byLane, by_assignee: byAssignee, by_machine: byMachine, oldest_ready_age_seconds: oldest ? t - Number(oldest) : null, backlog, now: t };
1291
1824
  }
1292
1825
 
1293
1826
  export function runs(db, id, { stateType = null, stateName = null } = {}) {
@@ -1297,13 +1830,14 @@ export function runs(db, id, { stateType = null, stateName = null } = {}) {
1297
1830
  }
1298
1831
 
1299
1832
  /** Retain assignment facts in checkpoints, then delete old thread events of done and archived cards; their creation and
1300
- * branch records (branch_named, worktree_made) stay, by which a reopened card finds its worktree folder. */
1833
+ * branch records (branch_named, worktree_made) stay, by which a reopened card finds its worktree folder, and so do its
1834
+ * worktree's retirement records (worktree_kept, worktree_removed), by which gc reads a kept worktree again. */
1301
1835
  export function gcEvents(board, seconds) {
1302
1836
  return tx(board, db=>{
1303
1837
  const cut=now()-seconds;
1304
1838
  const cards=all(db,`SELECT DISTINCT task_id FROM task_events WHERE kind IN (${AGENT_KIND_LIST.map(()=>'?').join(',')}) AND created_at<? AND task_id IN (SELECT id FROM tasks WHERE status IN ('done','archived'))`,...AGENT_KIND_LIST,cut);
1305
1839
  let compacted=0;for(const card of cards)compacted+=compactCardAgentEvents(db,card.task_id);
1306
- return compacted+Number(run(db,"DELETE FROM task_events WHERE kind NOT IN ('created','branch_named','worktree_made') AND created_at<? AND task_id IN (SELECT id FROM tasks WHERE status IN ('done','archived'))",cut).changes);
1840
+ return compacted+Number(run(db,"DELETE FROM task_events WHERE kind NOT IN ('created','branch_named','worktree_made','worktree_kept','worktree_removed') AND created_at<? AND task_id IN (SELECT id FROM tasks WHERE status IN ('done','archived'))",cut).changes);
1307
1841
  });
1308
1842
  }
1309
1843
 
@@ -1352,6 +1886,7 @@ export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, stran
1352
1886
  if (lost >= 2) out.push({ kind: 'repeated_session_loss', severity: 'warning', title: `its session was lost ${lost}x in 24h`, data: { lost }, actions: [`supercode workflow set ${task.id} machine <machine>`] });
1353
1887
  const crashes = events.filter((e) => e.kind === 'crashed' && t - e.created_at < 24 * 3600).length;
1354
1888
  if (crashes >= 2) out.push({ kind: 'repeated_crashes', severity: 'error', title: `its worker crashed ${crashes}x in 24h`, data: { crashes }, actions: [`supercode workflow log ${task.id}`] });
1889
+ if (task.backlog) out.push({ kind: 'backlog', severity: 'info', title: holdDescription(task), data: { backlog: task.backlog, lane: task.stage ?? task.workflow_status ?? task.status }, actions: [`supercode workflow plan ${task.id}`] });
1355
1890
  if (task.status === 'paused') out.push({ kind: 'paused', severity: 'info', title: holdDescription(task), data: { paused: task.paused }, actions: [`supercode workflow resume ${task.id}`] });
1356
1891
  if (task.status === 'blocked' && (!nativeHolds || task.blocked_by.length)) {
1357
1892
  const since = [...events].reverse().find((e) => e.kind === 'blocked' || e.kind === 'gave_up')?.created_at ?? task.created_at;
@@ -1359,7 +1894,7 @@ export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, stran
1359
1894
  const cycles = events.filter((e) => e.kind === 'unblocked' && t - e.created_at < 24 * 3600).length;
1360
1895
  if (cycles >= 3) out.push({ kind: 'block_unblock_cycling', severity: 'warning', title: `block→unblock cycled ${cycles}x in 24h`, data: { cycles }, actions: [] });
1361
1896
  }
1362
- if (!task.detached && task.status === 'ready' && task.assignee) {
1897
+ if (!task.detached && !task.backlog && task.status === 'ready' && task.assignee) {
1363
1898
  const since = [...events].reverse().find((e) => ['created', 'promoted', 'unblocked', 'reclaimed', 'session_lost', 'promoted_manual'].includes(e.kind))?.created_at ?? task.created_at;
1364
1899
  const age = t - since;
1365
1900
  if (age >= strandedSeconds) {
@@ -1394,8 +1929,11 @@ export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, stran
1394
1929
  for (const row of all(db, "SELECT kind, payload FROM task_events WHERE task_id = ? AND kind IN ('notice_filed', 'session_notice_filed', 'actor_notice_filed', 'board_message_sent') ORDER BY id DESC LIMIT 200", task.id)) {
1395
1930
  const p = json(row.payload, {}) ?? {};
1396
1931
  const what = row.kind === 'notice_filed' ? `its ${p.kind} notice` : row.kind === 'board_message_sent' ? 'its board mail' : `its ${row.kind.replace('_filed', '').replace('_', ' ')}`;
1397
- const expiry = p.message_id && expiredMail?.get(p.message_id), waiting = p.message_id && waitingMail?.get(p.message_id);
1398
- if (expiry) out.push({ kind: 'mail_expired', severity: 'warning', title: `${what} to ${expiry.to} was never delivered: ${expiry.reason} (${expiry.attempts} attempts); it stays unread in that mailbox`, data: { message_id: p.message_id, to: expiry.to, attempts: expiry.attempts, expired_at_ms: expiry.expired_at_ms }, actions: [] });
1932
+ const recorded = p.message_id && expiredMail?.get(p.message_id), waiting = p.message_id && waitingMail?.get(p.message_id);
1933
+ // a held failure its carrier later saw delivered is over (mail/carrier.mjs `delivered_at_ms`)
1934
+ const expiry = recorded && !(recorded.held && recorded.delivered_at_ms != null) ? recorded : null;
1935
+ if (expiry?.held) out.push({ kind: 'mail_expired', severity: 'warning', title: `${what} to ${expiry.to} is unconfirmed since ${at(expiry.held_since_ms ?? expiry.expired_at_ms)}: ${expiry.reason}; its recipient's input queue still holds it`, data: { message_id: p.message_id, to: expiry.to, held: true }, actions: [] });
1936
+ else if (expiry) out.push({ kind: 'mail_expired', severity: 'warning', title: `${what} to ${expiry.to} was never delivered: ${expiry.reason} (${expiry.attempts} attempts); it stays unread in that mailbox`, data: { message_id: p.message_id, to: expiry.to, attempts: expiry.attempts, expired_at_ms: expiry.expired_at_ms }, actions: [] });
1399
1937
  else if (waiting) out.push({ kind: 'mail_waiting', severity: 'warning', title: waiting.waiting_for ? `${what} to ${waiting.to} is waiting for ${waiting.waiting_for} since ${at(waiting.since_ms)} (offline)` : `${what} to ${waiting.to} is waiting since ${at(waiting.since_ms)}: ${waiting.last_error ?? 'not delivered yet'}`, data: { message_id: p.message_id, to: waiting.to, waiting_for: waiting.waiting_for, since_ms: waiting.since_ms }, actions: [] });
1400
1938
  }
1401
1939
  }