@volter/supercode-orchestrator 0.5.226 → 0.5.227

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.
package/board/cli.mjs CHANGED
@@ -12,15 +12,15 @@ import { BOARD_OPERATION_VERBS, boardHome, boardOwnerSocket, CALLER_FACTS } from
12
12
  import { current, isCallersCall, ownCall, within } from './call.mjs';
13
13
  import { cardState, holdDescription } from './card-state.mjs';
14
14
  import { detachSession } from './custody.mjs';
15
- import { taskStatusOf } from './published.mjs';
15
+ import { reclaimSpace, taskStatusOf, trimDue, trimPublications } from './published.mjs';
16
16
  import { executionOf, readingContext } from './session-work.mjs';
17
17
  import { attachmentRecord, readAttachment } from './attachment-read.mjs';
18
18
  import { receiveAttachment } from './attachment-write.mjs';
19
19
  import { listCardsInTurns, READ_BY_OWNER, writeCardList } from './listing.mjs';
20
20
  import { redactGuardText as redactText, withCredentialGuard } from './credential-guard.mjs';
21
21
  import { openOwnerDoor } from './owner-door.mjs';
22
- import { inBoardGeneration, generationWork, admitGeneration, grantGenerationWriter, parkGeneration, stopGeneration, tellOwner } from './generation.mjs';
23
- import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
22
+ import { inBoardGeneration, generationWork, admitGeneration, grantGenerationWriter, parkGeneration, stopGeneration, tellOwner, admittedWork, GENERATION_STOPPED } from './generation.mjs';
23
+ import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, statfsSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
24
24
  import { notRuntime, ownHome } from './owner-files.mjs';
25
25
  import { execFile, spawnSync } from 'node:child_process';
26
26
  import { supercodeInvocation } from '@volter/supercode-harness-sdk/supercode-program';
@@ -76,13 +76,13 @@ async function boardPublishing(root) {
76
76
  const board = listed?.boards?.find((b) => b.root === here);
77
77
  return board ? { ...board, refused: listed.refused ?? null } : null;
78
78
  }
79
- import { answerQuestion, dispatchingOf, knowLocalMachine, requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, stopDispatching, resumeDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
79
+ import { answerQuestion, dispatchingOf, knowLocalMachine, requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, closeEnded, lanesOf, localMachine, observeFleet, paused, plan, startCard, releaseClaims, stopDispatching, resumeDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
80
80
  import { watchBoard } from './wake.mjs';
81
81
  import { attachSideSession, releaseSideSession, sideArcs, sideSessions } from './side-sessions.mjs';
82
82
  import { onPanesChanged, onRecordsChanged, readFleetRecords, fleetRecordsCurrent } from './machine-fleet.mjs';
83
83
  import { FleetListenerRouter, fleetListenerStatus } from './fleet-listeners.mjs';
84
84
  import { enrichFleetEvent, observeRoleFleet } from './fleet-observer.mjs';
85
- import { fileFleetNotice } from './dispatch.mjs';
85
+ import { drainAgentCredentials, fileFleetNotice } from './dispatch.mjs';
86
86
  import { streamBoardPublications } from './event-stream.mjs';
87
87
  import { completionOwnerOf, followHome, isLatestReviewer, loadWorkflow, readHome, requestSessionClose, scopeOf, statusOf, timeOf, useWorkflow, workflowEntry, workflowFor, watchWorkflows } from './workflow.mjs';
88
88
  import { evaluate } from './expr.mjs';
@@ -101,18 +101,20 @@ const callerPath = (named) => notRuntime(resolve(current().cwd, named));
101
101
  const SAFE_SESSION = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
102
102
  const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
103
103
 
104
- A card is named by its id (t_4406c88a) or by its number, keyed or bare (VO-42, vo-42, #42 or 42): the short number the board
105
- gave it when it was made, read with the board's key, the first letters of the board's name.
104
+ A card is named by its readable id (voice-calls), by its id (t_4406c88a) or by its number, keyed or bare (VO-42, vo-42, #42
105
+ or 42): the short number the board gave it when it was made, read with the board's key, the first letters of the board's name.
106
+ Its creator gives the readable id, a few lowercase words naming its outcome, unique on the board; it names the card's branch,
107
+ worktree folder, sessions and agents.
106
108
 
107
109
  The board's verbs, with their flags:
108
110
  declaration [--json] read the serving board declaration
109
111
  init [--backing sqlite|files|ztrack] [--document <arcs.md>] [--commit] [--manager <address>] [--dispatching on|off] [--archive-max-lines N] [--archive-period month]
110
112
  migrate --backing ztrack --document <arcs.md> --status-map <json>
111
113
  boards list|create|rm|switch|show|rename|set-default-workdir|export|import
112
- create <title> [--readable-id ID] [--body | --body-file PATH|-] [--assignee] [--parent]... [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch] [--project]
114
+ create <title> --readable-id ID [--body | --body-file PATH|-] [--assignee] [--parent]... [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch] [--project]
113
115
  [--tenant] [--priority] [--triage] [--idempotency-key] [--max-runtime 30m] [--created-by] [--skill]... [--max-retries]
114
116
  [--model] [--provider] [--reviewer PROFILE] [--goal|--no-goal] [--goal-max-turns] [--initial-status blocked|running]
115
- swarm <goal> --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
117
+ swarm <goal> --readable-id ID --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
116
118
  list [--mine] [--assignee] [--status] [--tenant] [--session] [--subtasks] [--archived] [--sort] show <id> context <id> brief <id> [--run N] runs <id>
117
119
  set <id> <assignee|machine|model|every|reviewer> <value|none> [--provider] [--ids ...] assign <id> <profile|none> set-model <id> [model] [--provider] set-goal <id>... [--goal-max-turns N] [--off]
118
120
  reassign <id> --agent AGENT [--role implementer] (moves a card role's assignment to another agent; sessions keep their identity)
@@ -120,11 +122,14 @@ The board's verbs, with their flags:
120
122
  comment <id> (<text> | --file PATH|-) [--author] [--max-len] [(--pr URL --pr-head SHA)... | --pr none] [--source owner/repo:branch... | --source none] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
121
123
  attachment-read <id> <attachment-id> [--offset N --length N --version V] bounded base64/metadata JSON, including through --at
122
124
  complete <id>... [--result] [--summary] [--metadata JSON] [--commit SHA] edit <id> --result [--summary] [--metadata]
125
+ person-done <id> --person person:<principal> (--result <answer> | --result-file PATH|-) [--workplace-task <Task id>]
126
+ a person's card (create/set --assignee person:<principal>) closes on that person's completion: the Workplace
127
+ integration reports it from the home; the board checks it names the card's person. Never a session's verb.
123
128
  finish <id> --result TEXT (when declared: end your completed attempt; the board selects review)
124
129
  block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
125
130
  pause <id> --reason <why> resume <id> cancel <id> --reason <why>
126
131
  request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
127
- unblock <id>... --on <blocker> --reason <what arrived> request-review <id> (--pr URL --pr-head SHA)... [--summary] [--reviewer] [--force] [--commit SHA] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... reopen <id>... [--reason]
132
+ unblock <id>... --on <blocker> --reason <what arrived> request-review <id> [--plan] --pr URL --pr-head SHA [--summary] [--reviewer] [--force] [--commit SHA] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... --reason <why> (voids the verdict, or restarts a review run that has none) return-review <id> --reason <why> (manager: back to its coder, no verdict) reopen <id>... [--reason]
128
133
  promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... --reason <why> [--rm <id>...]
129
134
  specify <id> [--readable-id ID] [--title --body | --body-file PATH|-] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--machine M|none] [--all]
130
135
  decompose <id> [--child "<title>[@<profile>]"]... [--all] file the card's subtasks atomically, if its workflow allows
@@ -132,7 +137,7 @@ The board's verbs, with their flags:
132
137
  github-usage [--since 1h] local REST attempts by caller/endpoint; no GitHub read
133
138
  notify-subscribe <id> --platform P --chat-id C [--thread-id] notify-list [<id>] notify-unsubscribe <id> --platform P --chat-id C
134
139
  capacity (each cap's count against its max, and why each card waits; docs/adr/0009-board-capacity.md)
135
- dispatching [on|off] [--reason R] dispatch [--dry-run] [--max N] [--failure-limit N] daemon [--interval S] [--max N] [--failure-limit N] [--pidfile F] [--verbose] gc [--event-retention-days] [--log-retention-days] repair
140
+ dispatching [on|off] [--reason R] dispatch [--dry-run] [--max N] [--failure-limit N] daemon [--interval S] [--max N] [--failure-limit N] [--pidfile F] [--verbose] gc [--event-retention-days] [--log-retention-days] compact repair
136
141
  pause [reason] · resume (the board's emergency stop: new starts stop, running sessions go on)
137
142
 
138
143
  And:
@@ -154,7 +159,10 @@ And:
154
159
  handover --entry <orchestrator.mjs> --supercode <native> prepare a board generation, retain its predecessor
155
160
  answer <id> <option> choose an option (its number or label) of the question the card's session shows on its pane: the
156
161
  card's manager, its creator or a signed-in person; recorded on the card as question_answered with who answered
157
- notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency]
162
+ notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency|participant]
163
+ notify-unsubscribe <id> [--address A]: stop following a card; the board says so on the card, and an @mention of you
164
+ (or your own comment) brings you back. A comment's author and everyone it @mentions (@manager, @sc:<address>,
165
+ @<agent>) follow the card; every comment reaches every follower but its author.
158
166
  notify-subscribe <dependency> --for-card <your-arc>: subscribe only your arc's session to dependency notices.
159
167
  notify-unsubscribe <dependency> --for-card <your-arc>: remove that arc's subscription only.
160
168
  Any verb … --at sc:<machine>:board:<home>: applied on that board through this machine's board door, as this session; its answer and exit code are the board's.
@@ -167,7 +175,8 @@ And:
167
175
  specify <id> --workspace … --machine M: declares the workspace on M and moves the card there, never asking the machine it leaves.
168
176
  comment <id> TEXT (--pr URL --pr-head SHA)...: the card's implementer or the manager adds PRs (including merged ones) to the arc's discovered source set, in the arc's repositories (its workspace, its declared sources, its tasks'), each followed at its current head; each declaration adds to the earlier ones, on any run, and --pr none alone clears them. This records evidence; only the board starts its review.
169
177
  comment <arc> TEXT --source owner/repo:branch...: the arc's implementer or the manager declares every repository and branch the arc changes, on the arc itself; its review discovers PRs from them and accepts --pr declarations in them, with no task registered there. Each declaration adds to the earlier ones, and --source none alone clears them.
170
- request-review <id> (--pr URL --pr-head SHA)...: hand over every open PR the arc changed, each at its pushed head; one review covers them all.
178
+ request-review <id> --pr URL --pr-head SHA [--summary "<what the PR does>"]: hand one ready PR to its PR review at its pushed head, whatever other PRs the arc has; merge it at that head once the review passes. A request naming its PR is a PR review.
179
+ request-review <id> --plan --pr URL --pr-head SHA: a big arc's plan review, of the proposed ADR's PR at its pushed head, before any code. The arc's final review (of every PR not merged at a PR-reviewed head, with its Acceptance) is the board's.
171
180
  request-state <id> --state done (--reviewed-sha SHA)...: record the verdict on every requested PR head.
172
181
  request-state <id>... --state STATE --reason REASON: request several decisions in one retained manager mail.
173
182
  complete <id>: creator/manager accepts the independently reviewed arc; deployment CI is not a board gate.
@@ -342,7 +351,7 @@ const ts = (t) => {
342
351
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
343
352
  };
344
353
  // a card's status is the workflow's (triaging, reviewing, …); kanban.db stores the Hermes status it maps to
345
- const line = (key) => (t) => `${ICONS[t.status] ?? '?'} ${engine.cardLabel(key, t.number, t.id, t.readable_id).padEnd(18)} ${(['paused','blocked'].includes(t.status) ? t.status : t.stage ?? t.status).padEnd(8)}${holdDescription(t) ? ` [${holdDescription(t)}]` : t.detached ? ` [session belongs to ${t.detached.person}]` : ['running','review'].includes(t.status) ? ` [session ${t.execution?.state ?? 'unknown'}${t.execution?.reason ? ` — ${t.execution.reason}` : ''}]` : ''} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
354
+ const line = (key) => (t) => `${ICONS[t.status] ?? '?'} ${engine.cardLabel(key, t.number, t.id, t.number ? t.readable_id : null).padEnd(32)} ${(['paused','blocked'].includes(t.status) ? t.status : t.stage ?? t.status).padEnd(8)}${holdDescription(t) ? ` [${holdDescription(t)}]` : t.detached ? ` [session belongs to ${t.detached.person}]` : ['running','review'].includes(t.status) ? ` [session ${t.execution?.state ?? 'unknown'}${t.execution?.reason ? ` — ${t.execution.reason}` : ''}]` : ''} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
346
355
  // `key` is the card's board's (store.mjs boardKey); `ref` the card as a person names it (`VO-856`)
347
356
  const stateOptions = root => ({ nativeHolds: Boolean(workflowFor(root).workflow.flags?.paused) });
348
357
  const cardDict = (raw, key, options) => { const t = cardState(raw, options); return ({
@@ -388,13 +397,14 @@ const metadataOf = (v) => {
388
397
  // included). A number no card here has, or a key that is not this board's, is refused. A key is two or more letters, so
389
398
  // a message id (`m-…`, `a-…`) is never read as one.
390
399
  const NO_CARD_WORDS = new Set(['create', 'new', 'swarm', 'list', 'ls', 'migrate', 'init', 'boards', 'usage', 'events', 'listeners', 'dispatch',
391
- 'capacity', 'deliver', 'daemon', 'serve', 'gc', 'repair', 'dispatching', 'attach-rm', 'run-session', 'stats', 'assignees', 'diagnostics', 'diag',
400
+ 'capacity', 'deliver', 'daemon', 'serve', 'gc', 'compact', 'repair', 'dispatching', 'attach-rm', 'run-session', 'stats', 'assignees', 'diagnostics', 'diag',
392
401
  'watch', 'help', 'github-usage']);
393
402
  // the verbs that act on every positional as a card (each loops over them all)
394
403
  const ALL_CARD_WORDS = new Set(['complete', 'archive', 'request-state', 'request_state', 'set-goal', 'unblock', 'reopen', 'reopen-review']);
395
404
  const CARD_FLAGS = ['ids', 'subtask-of', 'parent', 'on', 'for-card', 'task', 'rm'];
396
405
  const CARD_NUMBER = /^(?:#|([A-Za-z][A-Za-z0-9]+)-)?([1-9]\d*)$/;
397
- function resolveCardNumbers(verb, rest, args, withBoard, keyOf) {
406
+ const READABLE_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
407
+ function resolveCardNumbers(verb, rest, args, withBoard, keyOf, exists) {
398
408
  const words = NO_CARD_WORDS.has(verb) ? 0 : ALL_CARD_WORDS.has(verb) ? rest.length : ['link', 'unlink'].includes(verb) ? 2 : 1;
399
409
  const places = [];
400
410
  for (let i = 0; i < Math.min(words, rest.length); i++) places.push([rest, i]);
@@ -403,9 +413,12 @@ function resolveCardNumbers(verb, rest, args, withBoard, keyOf) {
403
413
  else if (typeof args[flag] === 'string') places.push([args, flag]);
404
414
  }
405
415
  const numbered = places.filter(([holder, key]) => CARD_NUMBER.test(String(holder[key])));
406
- if (!numbered.length) return;
416
+ // a card named by its readable id (`voice-calls`) is that card, where one is; any other word stays as it was said
417
+ const named = exists() ? places.filter(([holder, key]) => !CARD_NUMBER.test(String(holder[key])) && READABLE_ID.test(String(holder[key]))) : [];
418
+ if (!numbered.length && !named.length) return;
407
419
  const own = keyOf();
408
420
  withBoard((b) => view(b, (db) => {
421
+ for (const [holder, at] of named) holder[at] = engine.cardByReadableId(db, String(holder[at])) ?? holder[at];
409
422
  for (const [holder, at] of numbered) {
410
423
  const [, named, digits] = CARD_NUMBER.exec(String(holder[at]));
411
424
  if (named && named.toUpperCase() !== own) throw new Error(`${holder[at]} names a card of board ${named.toUpperCase()}, not of this board (${own}); nothing was done`);
@@ -448,7 +461,7 @@ export async function runVerb(argv) {
448
461
  const withBoardAsync = async (fn, create = false) => { const b = open(create); try { return await fn(b); } finally { b.close(); } };
449
462
  // the board's key (store.mjs boardKey), read when a card is named or shown by its number
450
463
  const keyOf = () => boardKey(root, slug);
451
- resolveCardNumbers(verb, rest, args, withBoard, keyOf);
464
+ resolveCardNumbers(verb, rest, args, withBoard, keyOf, () => boardExists(root, slug));
452
465
  const id = rest[0];
453
466
  const need = (n, what) => { if (rest.length < n) throw new Error(`${verb} needs ${what}`); };
454
467
  const ids = (first = rest) => [...first, ...(args.ids ?? [])];
@@ -600,7 +613,7 @@ export async function runVerb(argv) {
600
613
  // thread records the start. A create never runs a dispatch tick inline.
601
614
  // the creator hears what happens from here on; a start answered above is not told twice
602
615
  const address = args['no-notify'] ? null : args.notify ?? callerAddress();
603
- if (address) withBoard((b) => engine.subscribe(b, created.id, address, 'creator'));
616
+ if (address) withBoard((b) => engine.subscribe(b, created.id, address, 'creator', { own: true }));
604
617
  const task = withBoard((b) => view(b, (db) => engine.getTask(db, created.id)));
605
618
  const recipients = withBoard(b => engine.subscriptions(b.db, created.id)).filter(s => s.platform === 'supercode').map(s => s.chat_id);
606
619
  if (args.json) { say(args, '', { ...cardDict(task, keyOf(), stateOptions(root)), ...answer, notify: address ?? null, recipients }); return answer.failed ? 1 : 0; }
@@ -617,7 +630,7 @@ export async function runVerb(argv) {
617
630
  return { profile, title, skills: skills ? skills.split(',').map((x) => x.trim()).filter(Boolean) : [] };
618
631
  });
619
632
  if (!args.verifier || !args.synthesizer) throw new Error('a swarm names its --verifier and --synthesizer');
620
- const made = withBoard((b) => engine.swarm(b, { goal: rest.join(' '), workers, verifier: args.verifier, synthesizer: args.synthesizer, tenant: args.tenant ?? null, createdBy: args['created-by'] ?? null, priority: Number(args.priority) || 0, idempotencyKey: args['idempotency-key'] ?? null }), true);
633
+ const made = withBoard((b) => engine.swarm(b, { goal: rest.join(' '), readableId: args['readable-id'], workers, verifier: args.verifier, synthesizer: args.synthesizer, tenant: args.tenant ?? null, createdBy: args['created-by'] ?? null, priority: Number(args.priority) || 0, idempotencyKey: args['idempotency-key'] ?? null }), true);
621
634
  say(args, `Swarm root: ${made.root_id}\nWorkers: ${(made.worker_ids ?? []).join(', ')}\nVerifier: ${made.verifier_id}\nSynthesizer: ${made.synthesizer_id}`, made);
622
635
  return 0;
623
636
  }
@@ -672,10 +685,10 @@ export async function runVerb(argv) {
672
685
  say(args, '', { task: cardDict(t, keyOf(), stateOptions(root)), sources: withBoard((b) => arcSources(b.db, t.id)), side_sessions: withBoard((b) => sideSessions(b.db, t.id)), latest_summary: latest, parents: card.parents.map((p) => p.id), children: card.children, subtasks: card.subtasks, comments: card.comments, events: card.events, runs: card.runs, time: card.time, subscriptions: card.subscriptions, diagnostics: card.diagnostics });
673
686
  return 0;
674
687
  }
675
- const labels = withBoard((b) => view(b, (db) => engine.numbersOf(db, [t.subtask_of, ...card.parents.map((p) => p.id), ...card.children].filter(Boolean))));
676
688
  const boardKeyNow = keyOf();
677
- const label = (cardId) => engine.cardLabel(boardKeyNow, labels.get(cardId), cardId);
678
- const out = [`Task ${engine.cardLabel(boardKeyNow, t.number, t.id, t.readable_id)}: ${t.title}`, ` status: ${['paused','blocked'].includes(t.status) ? t.status : taskStatusOf(card.stage, t.completed_at)} (lane ${card.stage})`, ` assignee: ${t.assignee || '-'}`];
689
+ const labels = withBoard((b) => view(b, (db) => engine.labelsOf(db, boardKeyNow, [t.subtask_of, ...card.parents.map((p) => p.id), ...card.children].filter(Boolean))));
690
+ const label = (cardId) => labels.get(cardId) ?? cardId;
691
+ const out = [`Task ${engine.cardLabel(boardKeyNow, t.number, t.id, t.subtask_of ? null : t.readable_id)}: ${t.title}`, ` status: ${['paused','blocked'].includes(t.status) ? t.status : taskStatusOf(card.stage, t.completed_at)} (lane ${card.stage})`, ` assignee: ${t.assignee || '-'}`];
679
692
  if (holdDescription(t)) out.push(` hold: ${holdDescription(t)}`);
680
693
  if (t.machine) out.push(` machine: ${t.machine}`);
681
694
  if (t.reviewer) out.push(` reviewer: ${t.reviewer} (card override)`);
@@ -853,6 +866,8 @@ export async function runVerb(argv) {
853
866
 
854
867
  case 'comment': {
855
868
  need(args.file !== undefined ? 1 : 2, 'a card id and the text (or --file PATH, - for stdin)');
869
+ // A comment's text comes from --file or from its words, never both: words beside --file were dropped unread.
870
+ if (args.file !== undefined && rest.length > 1) throw new Error(`comment ${id} takes its text from --file or from words, not both; unexpected: ${JSON.stringify(rest.slice(1).join(' ').slice(0, 120))}. Nothing was recorded.`);
856
871
  let text = textFrom(args.file, rest.slice(1).join(' '));
857
872
  if (args['max-len']) text = text.slice(0, Number(args['max-len']));
858
873
  const n = await withBoardAsync((b) => engine.comment(b, id, isLatestReviewer(b.db, id, actorOf()) ? actorOf() : args.author ?? callerAddress() ?? callerName(), text, { pr: args.pr ?? null, pr_head: args['pr-head'] ?? null, source: args.source ?? null }));
@@ -908,6 +923,25 @@ export async function runVerb(argv) {
908
923
  for (const t of rest) await withBoardAsync((b) => engine.complete(b, t, { summary: args.summary ?? null, result: args.result ?? null, metadata: metadata ?? null, commit: args.commit ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? null, reviewed_sha: args['reviewed-sha'] ?? null }));
909
924
  say(args, rest.map((t) => `Completed ${t}`).join('\n'), { ok: true, completed: rest }); return 0;
910
925
  }
926
+ // A person completed the card assigned to them, reported by the Workplace integration through the home it serves
927
+ // (sdk/teams/workplace/organization.mjs), as the board's own platform event; a session never reports one.
928
+ case 'person-done': {
929
+ need(1, 'a card id');
930
+ if (sid) throw new Error('A person\'s completion is reported by the Workplace integration from the home, never by a session; nothing was done.');
931
+ // The board's refusal (the card is no person's, or another person's, or already ended) is said with exit code 2: the
932
+ // reporter does not send it again. Any other failure is the reporter's to retry; the same report again is `already`.
933
+ let done;
934
+ try { done = await withBoardAsync((b) => engine.personDone(b, id, { person: args.person ?? null, result: textFrom(args['result-file'], args.result), task: args['workplace-task'] ?? null })); }
935
+ catch (error) {
936
+ // the card's refusal is final, as dispatch.mjs takes a moved card's (a transition that does not apply there), and
937
+ // so is an answer refused as a credential dump: sent again, it is refused again
938
+ if (!error.refusal && !error.noTransition && !/does not apply|there is for/.test(String(error.message)) && error.code !== 'credential_dump') throw error;
939
+ say(args, `${id} refused ${args.person ?? 'the person'}'s completion: ${error.message}`, { ok: false, card: id, person: args.person ?? null, refused: error.message });
940
+ return 2;
941
+ }
942
+ say(args, done.already ? `${id} was already completed by ${args.person}` : `Completed ${id} for ${args.person}`, { ok: true, card: id, person: args.person, already: done.already });
943
+ return 0;
944
+ }
911
945
  case 'edit': {
912
946
  need(1, 'a card id');
913
947
  if (args.result === undefined) throw new Error('edit needs --result');
@@ -935,7 +969,7 @@ export async function runVerb(argv) {
935
969
  if (args.reason) for (const [t] of results) await withBoardAsync((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
936
970
  say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
937
971
  }
938
- case 'request-review': need(1, 'a card id'); await withBoardAsync((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? args.head ?? args.commit ?? null, force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
972
+ case 'request-review': need(1, 'a card id'); await withBoardAsync((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? args.head ?? args.commit ?? null, plan: Boolean(args.plan), force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
939
973
  case 'request-changes': {
940
974
  need(2, 'a card id and the reason');
941
975
  // each finding is filed as a task on the arc first: the arc's review is due again once every one is closed (ci.mjs answeredChanges)
@@ -955,8 +989,17 @@ export async function runVerb(argv) {
955
989
  case 'reopen-review': {
956
990
  need(1, 'a card id');
957
991
  const results = [];
958
- for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.reopenReview(b, t, { reason: args.reason ?? null }))).to]);
959
- say(args, results.map(([t, s]) => `Reopened ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
992
+ for (const t of ids()) { const moved = await withBoardAsync((b) => engine.reopenReview(b, t, { reason: args.reason ?? null })); results.push([t, moved.to, moved.restarted_run ?? null]); }
993
+ // a review still running with no verdict is restarted, not voided: its run ends, its reviewer session is closed and
994
+ // the board's next review run resumes that same session with the round's brief
995
+ say(args, results.map(([t, s, run]) => run ? `Restarted ${t}'s review: run ${run} ended with no verdict and its reviewer session was closed; the board's next review run resumes it (${s}). No verdict was voided.` : `Reopened ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
996
+ }
997
+ case 'return-review': {
998
+ // the manager's door out of review: a card there by mistake goes back to its coder, its review run ending as returned
999
+ need(1, 'a card id');
1000
+ const reason = args.reason ?? (rest.slice(1).join(' ') || null);
1001
+ const moved = await withBoardAsync((b) => engine.returnReview(b, id, { reason }));
1002
+ say(args, `Returned ${id} from review → ${moved.to}: its review run ended as returned with no verdict; its coder continues, and the board reviews again once a pushed PR head changes.`, { ok: true, ...moved }); return 0;
960
1003
  }
961
1004
  case 'promote': {
962
1005
  need(1, 'a card id');
@@ -1000,7 +1043,7 @@ export async function runVerb(argv) {
1000
1043
  for (const t of targets) {
1001
1044
  if (verb === 'specify' && (args['readable-id'] !== undefined || args.title !== undefined || args.body !== undefined || args['body-file'] !== undefined || args.workspace !== undefined || args.branch !== undefined || args.machine !== undefined)) {
1002
1045
  const moved = await withBoardAsync((b) => engine.specify(b, t, { readableId: args['readable-id'] ?? null, title: args.title ?? null, body: textFrom(args['body-file'], args.body) ?? null, workspace: args.workspace ?? null, branch: args.branch ?? null, machine: args.machine ?? null, author: args.author ?? callerAddress() ?? callerName() }));
1003
- results.push({ id: t, specified: true, status: moved.to });
1046
+ results.push({ id: t, specified: true, status: moved.to, from: moved.from, moved: Boolean(moved.moved) });
1004
1047
  } else if (verb === 'decompose' && args.child?.length) {
1005
1048
  const children = args.child.map((c) => { const [title, assignee] = c.split(/@(?=[^@]*$)/); return { title: title.trim(), assignee: assignee?.trim() || undefined }; });
1006
1049
  results.push({ id: t, children: await withBoardAsync((b) => engine.decompose(b, t, children, { author: args.author ?? callerAddress() ?? callerName() })) });
@@ -1009,7 +1052,7 @@ export async function runVerb(argv) {
1009
1052
  results.push({ id: t, session: session.address, mode: session.mode });
1010
1053
  }
1011
1054
  }
1012
- say(args, results.map((r) => (r.specified ? `Specified ${r.id} → ${r.status}` : r.children ? `Decomposed ${r.id} into ${r.children.join(', ')}` : `${r.id}: session ${r.session} (${r.mode}) is writing its ${verb === 'specify' ? 'spec' : 'breakdown'}`)).join('\n'), results);
1055
+ say(args, results.map((r) => (r.specified ? (r.moved ? `Specified ${r.id}: ${r.from} → ${r.status}` : `Specified ${r.id}; it stays ${r.status}`) : r.children ? `Decomposed ${r.id} into ${r.children.join(', ')}` : `${r.id}: session ${r.session} (${r.mode}) is writing its ${verb === 'specify' ? 'spec' : 'breakdown'}`)).join('\n'), results);
1013
1056
  return 0;
1014
1057
  }
1015
1058
 
@@ -1050,7 +1093,7 @@ export async function runVerb(argv) {
1050
1093
  if (!existsSync(file)) {
1051
1094
  // a pane session keeps no log file: its transcript is the harness's own, named by the run
1052
1095
  const s = withBoard((b) => view(b, (db) => engine.lastSession(db, id)));
1053
- current().out(s ? `(${id} runs as session ${s.address}; its transcript is that session's: \`supercode sessions show ${s.session_id}\`)\n` : `(no log for ${id})\n`);
1096
+ current().out(s ? `(${id} runs as session ${s.address}; its transcript is that session's, read on whichever machine holds it: \`supercode sessions show ${s.address ?? s.session_id}\`)\n` : `(no log for ${id})\n`);
1054
1097
  return 0;
1055
1098
  }
1056
1099
  const text = readFileSync(file, 'utf8');
@@ -1092,13 +1135,18 @@ export async function runVerb(argv) {
1092
1135
  const platform = args.platform ?? 'supercode';
1093
1136
  const chat = args['chat-id'] ?? args.address ?? (platform === 'supercode' ? callerAddress() : null);
1094
1137
  if (!chat) throw new Error(`${verb} names the subscriber: --platform P --chat-id C (a supercode session's address with --platform supercode)`);
1138
+ // a supercode follower's unsubscribe is the card's (engine.unsubscribe: kept, said on the card); a chat's ends here
1139
+ if (verb === 'notify-unsubscribe' && platform === 'supercode' && !args['thread-id']) {
1140
+ const gone = withBoard((b) => engine.unsubscribe(b, id, chat));
1141
+ say(args, gone ? `Unsubscribed ${chat} from ${id}; mention @${engine.mentionName(chat)} on the card to bring them back` : `${chat} was not subscribed to ${id}`, { ok: gone }); return gone ? 0 : 1;
1142
+ }
1095
1143
  if (verb === 'notify-unsubscribe') {
1096
1144
  const gone = withBoard((b) => tx(b, (db) => db.prepare('DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?').run(id, platform, chat, args['thread-id'] ?? '').changes > 0));
1097
1145
  say(args, gone ? `Unsubscribed ${platform}:${chat} from ${id}` : `${platform}:${chat} was not subscribed to ${id}`, { ok: gone }); return gone ? 0 : 1;
1098
1146
  }
1099
1147
  withBoard((b) => tx(b, (db) => {
1100
1148
  if (!engine.getTask(db, id)) throw new Error(`no card ${id}`);
1101
- if (platform === 'supercode') return engine.subscribeIn(db, id, chat, args.role ?? 'creator');
1149
+ if (platform === 'supercode') return engine.subscribeIn(db, id, chat, args.role ?? 'creator', { explicit: true });
1102
1150
  const cursor = db.prepare('SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?').get(id).c;
1103
1151
  db.prepare(`INSERT INTO kanban_notify_subs (task_id, platform, chat_id, thread_id, user_id, user_id_alt, chat_type, notifier_profile, delivery_mode, created_at, last_event_id)
1104
1152
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(task_id, platform, chat_id, thread_id) DO UPDATE SET chat_type = COALESCE(excluded.chat_type, chat_type), delivery_mode = excluded.delivery_mode`)
@@ -1110,7 +1158,7 @@ export async function runVerb(argv) {
1110
1158
  case 'notify-list': {
1111
1159
  const subs = withBoard((b) => engine.subscriptions(b.db, id ?? null));
1112
1160
  if (args.json) { say(args, '', subs); return 0; }
1113
- current().out(subs.length ? `${subs.map((s) => `${s.task_id} ${s.platform}:${s.chat_id}${s.thread_id ? `#${s.thread_id}` : ''} ${s.platform === 'supercode' ? s.delivery_metadata?.role ?? 'creator' : s.delivery_mode} cursor=${s.last_event_id}`).join('\n')}\n` : '(no subscriptions)\n');
1161
+ current().out(subs.length ? `${subs.map((s) => `${s.task_id} ${s.platform}:${s.chat_id}${s.thread_id ? `#${s.thread_id}` : ''} ${s.platform === 'supercode' ? `${s.delivery_metadata?.role ?? 'creator'}${s.delivery_metadata?.unsubscribed ? ' (unsubscribed)' : ''}` : s.delivery_mode} cursor=${s.last_event_id}`).join('\n')}\n` : '(no subscriptions)\n');
1114
1162
  return 0;
1115
1163
  }
1116
1164
 
@@ -1121,7 +1169,7 @@ export async function runVerb(argv) {
1121
1169
  // planned through the workflow's own dispatch (its start event, eligible, order and limits), writing nothing
1122
1170
  const reports = await plan({ root, lanes, settings, params, slugs: [slug] });
1123
1171
  const would = reports.flatMap((r) => r.would), reviews = reports.flatMap((r) => r.would_review ?? []);
1124
- const lines = [...reviews.map((r) => `would request review of ${r.id}: ${r.no_code ? r.no_code.kind === 'disposition' ? 'scratch operational disposition (card evidence and closed tasks)' : `no code changed (${r.no_code.repository} ${r.no_code.branch} holds no commit beyond ${r.no_code.base}, no PR from it)` : r.prs.map((url, i) => `${url} at ${r.heads[i]}`).join(', ')}`),
1172
+ const lines = [...reviews.map((r) => `would request review of ${r.id}: ${r.no_code ? r.no_code.kind === 'disposition' ? 'scratch operational disposition (card evidence and closed tasks)' : `no code changed (${r.no_code.repository} ${r.no_code.branch} holds no commit beyond ${r.no_code.base}, no PR from it)` : [...r.prs.map((url, i) => `${url} at ${r.heads[i]}`), ...(r.pr_reviewed ?? []).map((p) => `${p.url} at ${p.head} (settled by its PR review)`)].join(', ')}`),
1125
1173
  ...would.map((w) => `would start ${w.id} (${w.status}${w.role ? `, its ${w.role}` : ''}) as ${w.surface} for ${w.assignee}${w.machine ? ` on ${w.machine}` : ''}`)];
1126
1174
  say(args, lines.length ? lines.join('\n') : 'nothing would start', args.json ? { would, would_review: reviews, queued: reports.flatMap(r => r.queued ?? []) } : would);
1127
1175
  return 0;
@@ -1142,12 +1190,12 @@ export async function runVerb(argv) {
1142
1190
  const reports = await plan({ root, lanes, settings });
1143
1191
  const out = { capacity: reports.capacity, would: reports.flatMap((r) => r.would.map((w) => w.id)), waiting: reports.flatMap((r) => r.queued.map(([id, why]) => ({ id, why }))) };
1144
1192
  if (args.json) { say(args, '', out); return 0; }
1145
- // each card as a person reads it, by the number its own board gave it
1193
+ // each card as a person reads it, by its readable id and the number its own board gave it
1146
1194
  const shown = [...out.would, ...out.waiting.map((w) => w.id), ...out.capacity.flatMap((cap) => cap.groups.flatMap((g) => g.cards))];
1147
1195
  const labels = new Map();
1148
1196
  for (const board of listBoards(root)) {
1149
1197
  const b = openBoard(root, board);
1150
- try { const own = boardKey(root, board); for (const [cardId, n] of view(b, (db) => engine.numbersOf(db, shown))) labels.set(cardId, engine.cardLabel(own, n, cardId)); } finally { b.close(); }
1198
+ try { const own = boardKey(root, board); for (const [cardId, label] of view(b, (db) => engine.labelsOf(db, own, shown))) labels.set(cardId, label); } finally { b.close(); }
1151
1199
  }
1152
1200
  const label = (cardId) => labels.get(cardId) ?? cardId;
1153
1201
  const lines = [];
@@ -1200,6 +1248,44 @@ export async function runVerb(argv) {
1200
1248
  say(args, `GC complete: ${removedWs} workspace(s), ${events} event row(s), ${logs} log file(s) removed`, { workspaces: removedWs, events, logs });
1201
1249
  return 0;
1202
1250
  }
1251
+ case 'compact': {
1252
+ // The store gives its free pages back to the volume (published.mjs reclaimSpace), answered by the board's owner on
1253
+ // its own connection: the first time a rebuild, which holds the board's writer (and the owner) while it copies
1254
+ // every live page, later a slice; then its WAL is checkpointed and cut. It deletes nothing a reader reads.
1255
+ const sized = (file) => { try { return statSync(file).size; } catch { return 0; } };
1256
+ const report = await withBoardAsync(async (b) => {
1257
+ // the store's own file, as its connection names it (a ztrack board's path is its marker; its store is beside it)
1258
+ const path = b.db.prepare('PRAGMA database_list').all().find((d) => d.name === 'main')?.file;
1259
+ if (!path) throw new Error(`board ${slug} has no store file to compact`);
1260
+ const onDisk = () => sized(path) + sized(`${path}-wal`);
1261
+ const vacuum = (sql) => (b.vacuum ? b.vacuum(sql) : b.db.exec(sql));
1262
+ const pageSize = Number(b.db.prepare('PRAGMA page_size').get().page_size);
1263
+ const pages = () => Number(b.db.prepare('PRAGMA page_count').get().page_count), free = () => Number(b.db.prepare('PRAGMA freelist_count').get().freelist_count);
1264
+ const before = { pages: pages(), free: free(), bytes: onDisk() }, began = performance.now();
1265
+ // The older changes still stored complete are cut first, one transaction a slice as the owner's wakes do, the
1266
+ // owner answering other calls between slices, so the rebuild gives back what they held. A slice that moves the
1267
+ // trim nowhere ends it.
1268
+ let trimmed = 0;
1269
+ const through = () => { try { return JSON.parse(b.db.prepare("SELECT value FROM supercode_board_state WHERE key = 'publication_trim'").get()?.value ?? 'null')?.through ?? null; } catch { return null; } };
1270
+ while (trimDue(b.db)) {
1271
+ const at = through();
1272
+ trimmed += b.tx((db) => trimPublications(db));
1273
+ if (through() === at) break;
1274
+ await new Promise((done) => setImmediate(done));
1275
+ }
1276
+ // The rebuild writes a copy of every live page and a WAL as large: it runs only with twice that and 1 GB more free
1277
+ // on the store's volume, and says so otherwise.
1278
+ const live = (pages() - free()) * pageSize, room = statfsSync(dirname(path)), available = Number(room.bavail) * Number(room.bsize);
1279
+ if (available < 2 * live + 1024 ** 3) return { trimmed, refused: `the rebuild needs ${Math.ceil((2 * live + 1024 ** 3) / 1048576)} MB free on the store's volume and ${Math.floor(available / 1048576)} MB are`, pages_before: before.pages, free_before: before.free };
1280
+ reclaimSpace(b.db, vacuum);
1281
+ // the WAL is cut once no reader holds it; a reader that does is waited for, a few times, then left to a later checkpoint
1282
+ for (let tries = 0; tries < 5 && sized(`${path}-wal`) > 0; tries++) { vacuum('PRAGMA wal_checkpoint(TRUNCATE)'); if (sized(`${path}-wal`) > 0) await new Promise((done) => setTimeout(done, 1000)); }
1283
+ return { trimmed, reclaimed_bytes: before.bytes - onDisk(), wal_bytes: sized(`${path}-wal`), pages_before: before.pages, free_before: before.free, pages_after: pages(), free_after: free(), seconds: Math.round((performance.now() - began) / 100) / 10 };
1284
+ });
1285
+ if (report.refused) { say(args, `${report.trimmed} older change(s) cut; no rebuild: ${report.refused}`, report); return 1; }
1286
+ say(args, `${report.trimmed} older change(s) cut; the store and its WAL are ${Math.round(report.reclaimed_bytes / 1048576)} MB smaller on disk after ${report.seconds} s (${report.pages_before} pages, ${report.free_before} free, to ${report.pages_after}, ${report.free_after} free; WAL ${report.wal_bytes} bytes)`, report);
1287
+ return 0;
1288
+ }
1203
1289
  case 'repair': {
1204
1290
  const path = boardPath(root, slug);
1205
1291
  if (!existsSync(path)) { say(args, `No kanban DB at ${path} — nothing to repair.`, { status: 'missing', db_path: path }); return 0; }
@@ -1380,14 +1466,19 @@ async function boardsVerb(root, rest, args) {
1380
1466
  const slug = normalSlug(target);
1381
1467
  if (slug === DEFAULT_BOARD) throw new Error("the 'default' board cannot be removed");
1382
1468
  if (!existsSync(boardDir(root, slug))) throw new Error(`board '${slug}' does not exist`);
1469
+ // Every machine this board handed an agent's own Workplace records keeps none of them from here; one that cannot
1470
+ // be told now is named, and its lease there ends the copy while that machine's daemon runs (dispatch.mjs
1471
+ // drainAgentCredentials).
1472
+ const left = boardExists(root, slug) ? await drainAgentCredentials(root, slug) : [];
1473
+ const leftLine = left.length ? `\n Agent credentials not yet removed (each ends at its lease while that machine's daemon runs): ${left.map((each) => `${each.agent} on ${each.machine} by ${each.leaseUntil ?? 'a day after its last keep'}`).join('; ')}` : '';
1383
1474
  if (boardOf(root, {}) === slug) rmSync(currentFile(root), { force: true });
1384
- if (args.delete || action === 'delete') { rmSync(boardDir(root, slug), { recursive: true, force: true }); say(args, `Board '${slug}' deleted.`, { slug, action: 'deleted' }); return 0; }
1475
+ if (args.delete || action === 'delete') { rmSync(boardDir(root, slug), { recursive: true, force: true }); say(args, `Board '${slug}' deleted.${leftLine}`, { slug, action: 'deleted', agent_credentials_left: left }); return 0; }
1385
1476
  const archive = join(root, 'kanban', 'boards', '_archived');
1386
1477
  mkdirSync(archive, { recursive: true });
1387
1478
  let dest = join(archive, `${slug}-${now()}`);
1388
1479
  for (let n = 1; existsSync(dest); n += 1) dest = join(archive, `${slug}-${now()}-${n}`);
1389
1480
  renameSync(boardDir(root, slug), dest);
1390
- say(args, `Board '${slug}' archived → ${dest}`, { slug, action: 'archived', new_path: dest });
1481
+ say(args, `Board '${slug}' archived → ${dest}${leftLine}`, { slug, action: 'archived', new_path: dest, agent_credentials_left: left });
1391
1482
  return 0;
1392
1483
  }
1393
1484
  case 'switch': case 'use': {
@@ -1701,7 +1792,7 @@ function authorize(db, verb, rest, args, own, { copyServe = false } = {}) {
1701
1792
  if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
1702
1793
  // verbs that act on the board rather than on a card are the manager's and the board's, never a card's session's
1703
1794
  // (what a session may do to a card is its workflow's: each transition's `by`)
1704
- if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'release', 'detach', 'close', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
1795
+ if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'compact', 'repair', 'adopt', 'release', 'detach', 'close', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
1705
1796
  if (verb === 'set' && !(own.worked.has(rest[0]) && rest[1] === 'every')) refuse("sets only Every on its own auditor card; other properties are the manager's");
1706
1797
  if (args.all) refuse(`does not ${verb} every card; only its own`);
1707
1798
  if (['notify-subscribe', 'notify-unsubscribe'].includes(verb) && args['for-card']) {
@@ -1736,7 +1827,7 @@ async function closeSession(board, id) {
1736
1827
  export function summary(r) {
1737
1828
  const parts = [`board ${r.board}:`];
1738
1829
  if (r.error) parts.push(`error ${r.error}`);
1739
- for (const [k, label] of [['mailed', 'mailed updates'], ['started', 'started'], ['queued', 'waiting'], ['failed', 'failed'], ['lost', 'lost'], ['crashed', 'crashed'], ['timed_out', 'timed out'], ['reclaimed', 'reclaimed'], ['auto_blocked', 'gave up'], ['notified', 'notices'], ['closed', 'closed sessions'], ['undelivered', 'undelivered (retrying)'], ['subscriptions_ended', 'worker subscriptions to ended sessions ended']]) {
1830
+ for (const [k, label] of [['mailed', 'mailed updates'], ['started', 'started'], ['queued', 'waiting'], ['failed', 'failed'], ['lost', 'lost'], ['crashed', 'crashed'], ['timed_out', 'timed out'], ['reclaimed', 'reclaimed'], ['retired', 'retired unheld runs'], ['auto_blocked', 'gave up'], ['notified', 'notices'], ['closed', 'closed sessions'], ['undelivered', 'undelivered (retrying)'], ['subscriptions_ended', 'worker subscriptions to ended sessions ended']]) {
1740
1831
  if (r[k]?.length) parts.push(`${label} ${r[k].length}`);
1741
1832
  }
1742
1833
  if (r.promoted) parts.push(`promoted ${r.promoted}`);
@@ -1808,7 +1899,7 @@ async function answerVerbs(root, log) {
1808
1899
  * Show/list/comment/create also retain receipts below the slow-log threshold;
1809
1900
  * absence from a slow-only log cannot prove those calls met the time bound. */
1810
1901
  export async function answerOne(root, line, { readOnly = false } = {}) {
1811
- if (inBoardGeneration && !readOnly) return generationWork(() => answerOneAdmitted(root, line));
1902
+ if (inBoardGeneration && !readOnly) return generationWork(() => answerOneAdmitted(root, line), 'a card verb');
1812
1903
  return answerOneAdmitted(root, line, { readOnly });
1813
1904
  }
1814
1905
 
@@ -1938,11 +2029,23 @@ export async function serveBoardGeneration(root, args = {}, argv = ['serve', '--
1938
2029
  // handles, nothing more, and the successor takes a fresh epoch that fences any older writer anyway.
1939
2030
  say('park: dispatching stopped; admission closes');
1940
2031
  stopDispatching();
1941
- await parkGeneration(async () => {
2032
+ // The admitted work a park still waits on is named every 2 s, in the log and as the park's step to the owner, so
2033
+ // a park that outlives the owner's wait says what held it: pin H's on 2026-10-24 (predecessor 25904, 20:38:29Z)
2034
+ // said nothing after admission closed, and the owner's refusal named the generation's first round from 20:28.
2035
+ const waiting = setInterval(() => {
2036
+ const held = admittedWork();
2037
+ if (!held.length) return;
2038
+ say(`park: waiting on ${held.length} admitted work: ${held.join('; ')}`);
2039
+ tellOwner({ type: 'startup-step', phase: 'parking', step: `park: waiting on ${held.join('; ')}` });
2040
+ }, 2_000);
2041
+ waiting.unref?.();
2042
+ try { await parkGeneration(async () => {
1942
2043
  say('park: admitted work drained; settling documents');
1943
2044
  const { settleDocuments } = await import('./git-document.mjs'); await settleDocuments();
1944
2045
  say('park: documents settled');
1945
- });
2046
+ // its claims end here, while it still writes: a parked generation outlives its park (dispatch.mjs releaseClaims)
2047
+ try { say(`park: ${releaseClaims(root)} claim(s) released`); } catch (error) { say(`park: claims not released (${error.message}); they hold until their TTL`); }
2048
+ }); } finally { clearInterval(waiting); }
1946
2049
  say('park: the writer given up');
1947
2050
  retireHeldBoards();
1948
2051
  tellOwner({ type: 'parked' });
@@ -2134,11 +2237,13 @@ async function serve(root, args = {}) {
2134
2237
  if (stopped || !fleetListeners.enabled || !listenerBootstrapped || !fleetRecordsCurrent()) return;
2135
2238
  const revision = listenerRevision;
2136
2239
  try {
2137
- await ownCall(() => {
2240
+ await ownCall(function listenerScan() {
2138
2241
  if (stopped || revision !== listenerRevision || !fleetListeners.enabled || !fleetRecordsCurrent()) return;
2139
2242
  observeRoleFleet(root, fleetListeners, listenerRecords, listenerLog, { waiting });
2140
2243
  });
2141
- } catch {
2244
+ } catch (error) {
2245
+ // A scan its stopping generation refused never ran, and this generation scans nothing again: said as that.
2246
+ if (error?.code === GENERATION_STOPPED) { listenerLog(`fleet listener scan not run: ${error.message}`); return; }
2142
2247
  // Every caller may schedule this without awaiting it. Preparation refusal leaves source cursors untouched;
2143
2248
  // the next admitted scan retries the retained observations, while the owner's public door stays available.
2144
2249
  listenerLog('fleet listener scan refused; retained observations wait for a prepared owner call');
@@ -2157,10 +2262,10 @@ async function serve(root, args = {}) {
2157
2262
  return listenerReading;
2158
2263
  };
2159
2264
  fleetListeners = new FleetListenerRouter({ root, log: listenerLog,
2160
- file: (to, body, key) => ownCall(() => fileFleetNotice(root, to, body, key)),
2265
+ file: (to, body, key) => ownCall(() => fileFleetNotice(root, to, body, key), 'a fleet notice'),
2161
2266
  // The scan/flush prepares custody once; enqueue needs the event synchronously before committing its cursor.
2162
2267
  enrich: event => enrichFleetEvent(root, event),
2163
- work: ownCall,
2268
+ work: fn => ownCall(fn, 'the listener flush'),
2164
2269
  retry: () => scanListeners({ waiting: false }),
2165
2270
  });
2166
2271
  const unfollowRecords = onRecordsChanged(records => { listenerRecords = records; scanListeners(); });
@@ -2172,11 +2277,14 @@ async function serve(root, args = {}) {
2172
2277
  listenerReloadDue = false;
2173
2278
  const revision = listenerRevision;
2174
2279
  try {
2175
- const orchestration = await ownCall(() => readHome(root));
2280
+ const orchestration = await ownCall(() => readHome(root), 'the home read');
2176
2281
  if (revision !== listenerRevision || stopped) continue;
2177
2282
  fleetListeners.configure(orchestration.workflow?.listeners ?? {});
2178
2283
  if (fleetListeners.enabled) { scanListeners(); void readListenerRecords(); }
2179
2284
  } catch (error) {
2285
+ // A home read its stopping generation refused never ran: nothing is suspended and nothing is retried here, as
2286
+ // this generation serves no further round; its successor reads the configuration itself.
2287
+ if (error?.code === GENERATION_STOPPED) { listenerLog(`fleet listener configuration not read: ${error.message}`); break; }
2180
2288
  fleetListeners.suspend();
2181
2289
  listenerLog(`fleet listener configuration unread; retained obligations wait: ${String(error.message).slice(0, 300)}`);
2182
2290
  // A failed source read is retried by the next round; a failed home read also retains no stale intent.
@@ -2223,22 +2331,33 @@ async function serve(root, args = {}) {
2223
2331
  wakeReason = null;
2224
2332
  const loopStep = async (name, fn) => { const at = performance.now(); try { return await fn(); } finally { noteStep(`owner loop: ${name}`, performance.now() - at); } };
2225
2333
  try {
2226
- const state = await loopStep('lane state', () => ownCall(() => laneState(root)));
2334
+ const state = await loopStep('lane state', () => ownCall(() => laneState(root), 'the lane state'));
2227
2335
  const settings = { ...state.settings, ...override };
2228
2336
  interval = settings.interval;
2229
2337
  // Publishing is the owner's, dispatching or not: what a writer outside this process marked changed is published
2230
2338
  // on the wake its write caused, and the retained history kept bounded (store.mjs publishMarked).
2231
- const published = await loopStep('publish marked', () => ownCall(async () => publishMarked(root)));
2339
+ const published = await loopStep('publish marked', () => ownCall(async () => publishMarked(root), 'publishing marked cards'));
2232
2340
  if (published && args.verbose) log(`published ${published} card(s) a writer outside the owner changed`);
2233
2341
  // Dispatching is a controller over the boards whose own state says it is on; the owner answers verbs either way.
2234
2342
  const dispatching = listBoards(root).filter((slug) => dispatchingOf(root, slug).on);
2235
- const reports = dispatching.length ? await ownCall(() => tick({ root, lanes: state.lanes, settings, params, slugs: dispatching, deploymentReceipt: true })) : [];
2236
- if (inBoardGeneration && generationServingDue && reports.every(report => !report.error)) {
2343
+ // The first round's budget bounds its progress: each phase it ends and each door call that settles is a step, said
2344
+ // with the step's budget, so a round slow only on another machine's answers is not ended for its length.
2345
+ const progress = inBoardGeneration && generationServingDue
2346
+ ? (step) => tellOwner({ type: 'startup-step', phase: 'serving', step: `completing the first native board round (${step})`, budget_ms: 65000 }) : null;
2347
+ const reports = dispatching.length ? await ownCall(() => tick({ root, lanes: state.lanes, settings, params, slugs: dispatching, deploymentReceipt: true, progress }), 'the dispatch round') : [];
2348
+ if (inBoardGeneration && generationServingDue && !reports.cut && reports.every(report => !report.error)) {
2237
2349
  activationSaid(`the first native board round is clean (${dispatching.length} dispatching board(s))`);
2238
2350
  generationServingDue = false;
2239
2351
  tellOwner({ type: 'serving', pid: process.pid, boards: listBoards(root) });
2240
2352
  }
2241
- // with dispatching off on every board the owner only answers verbs: no round, no delivery, nothing told
2353
+ // A board whose dispatching is off runs no round and is delivered nothing; only its close pass runs, so an ended
2354
+ // run's session still closes (dispatching off stops launches, not closes).
2355
+ const resting = listBoards(root).filter((slug) => !dispatching.includes(slug));
2356
+ const closes = resting.length && !stopped ? await loopStep('close pass', () => ownCall(() => closeEnded({ root, slugs: resting, settings }), 'the close pass')) : [];
2357
+ for (const r of closes) {
2358
+ if (r.error || r.closed.length || args.verbose) log(summary(r));
2359
+ for (const [t, to, why] of r.undelivered) log(` close of ${to} for ${t} waits: ${why}`);
2360
+ }
2242
2361
  if (dispatching.length) {
2243
2362
  round += 1;
2244
2363
  // what the round recorded is delivered by a pass of its own, which this loop never waits for
@@ -2264,8 +2383,11 @@ async function serve(root, args = {}) {
2264
2383
  for (const [t, to, why, what] of r.undelivered) log(what === 'close' ? ` close of ${to} for ${t} waits: ${why}` : ` notice for ${t} to ${to} not filed: ${why}`);
2265
2384
  }
2266
2385
  } catch (error) {
2267
- log(`tick failed: ${error.message}`);
2268
- if (!stopped) await watchRounds([{ board: '*', error: `tick failed: ${error.message}` }], began);
2386
+ if (error?.code === GENERATION_STOPPED) log(`round not run: ${error.message}`);
2387
+ else {
2388
+ log(`tick failed: ${error.message}`);
2389
+ if (!stopped) await watchRounds([{ board: '*', error: `tick failed: ${error.message}` }], began);
2390
+ }
2269
2391
  }
2270
2392
  // each round is a line of its own in the slow log, with the steps it spent its time on
2271
2393
  recordSlow({ kind: 'call', name: 'dispatch round', ms: Date.now() - began, slowest: null, extra: { steps: takeSteps() } });
package/board/custody.mjs CHANGED
@@ -3,7 +3,11 @@ import { randomUUID } from 'node:crypto';
3
3
  import { hostname } from 'node:os';
4
4
  import { actorOf, claimer, event, json, now, tx } from './store.mjs';
5
5
 
6
- export const sessionAddress = s => s?.address ?? (s?.machine && s?.harness && s?.session_id ? `sc:${s.machine}:${s.harness}:${s.session_id}` : null);
6
+ /** The person a card is assigned to (`person:<Teams principal>`, the form callerPrincipal gives a signed-in person), or
7
+ * null for a card a profile works. A person's card is a dependency on that person: the board starts no session for it,
8
+ * no review judges it, and it closes when that person completes it (workflow `complete`, from `person-done`). */
9
+ export const personOf = (assignee) => /^person:\S+$/.test(String(assignee ?? '')) ? String(assignee) : null;
10
+ export const sessionAddress = s => s?.address ??(s?.machine && s?.harness && s?.session_id ? `sc:${s.machine}:${s.harness}:${s.session_id}` : null);
7
11
  export const detachedCard = (db, id) => json(db.prepare('SELECT detached FROM supercode_cards WHERE task_id=?').get(id)?.detached, null);
8
12
  export const personCustody = (db, address) => address ? db.prepare('SELECT * FROM supercode_session_custody WHERE address=?').get(address) ?? null : null;
9
13
  export const detachedSession = (db, session) => Boolean(session?.detached_at || personCustody(db, sessionAddress(session)));