@volter/supercode-orchestrator 0.5.226 → 0.5.228

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.
@@ -17,26 +17,28 @@
17
17
  // `workflow dispatch`) is harmless: every act of a round is a compare-and-set claim, a transaction that re-reads what it
18
18
  // acts on and does nothing when another round already acted (see `tick`). Every side effect follows a committed claim, so
19
19
  // a crash between the two steps is answered by the next round.
20
+ import { GENERATION_STOPPED } from './generation.mjs';
20
21
  import { machineCall } from './doors.mjs';
21
- import { detachedCard, detachedSession, personCustody, sessionOperation, takeSessionCustody } from './custody.mjs';
22
+ import { detachedCard, detachedSession, personCustody, personOf, sessionOperation, takeSessionCustody } from './custody.mjs';
22
23
  import { processSession } from './caller-session.mjs';
23
24
  import { callerOf, current, isCallersCall, ownCall } from './call.mjs';
24
25
  import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
25
26
  import { monitorsRuns, recoversRuns, retainRecovery, failedObservation, surfaceIncident, auditEndedRuns, originOf } from './incidents.mjs';
26
27
  import { readMachineFleet } from './machine-fleet.mjs';
27
28
  import { homeFleetMachines } from './fleet-targets.mjs';
28
- import { delegatedWork, idleInterval, questionText } from './session-work.mjs';
29
+ import { delegatedWork, idleInterval, questionText, resumedWatch } from './session-work.mjs';
29
30
  import { localSessionDelegations } from '../agents.mjs';
30
31
  import { cardSectionHeading, creatorOf, sessionProgress } from './workflow.mjs';
31
32
  import { boardLabel, boardName } from '@volter/teams/board-name';
32
33
  let fleetRoot = null;
33
- import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, withFreshReviewReads, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
34
+ import { inspectCompletionEvidence, returnedReview, noCodeChange, openReviewPullRequests, reviewDue, settledReviewDue, prReviewedHead, withCompletionEvidence, withFreshReviewReads, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
34
35
  import { setImmediate as yieldToOwner } from 'node:timers/promises';
35
36
  import { ACTIVE_RUN_IDS, latestEvent, readRunPages } from './queries.mjs';
36
37
  import { runtimeDoor } from './runtime-door.mjs';
37
38
  import { agentScope, localCardAgent, localReaderContext, localAgentSessions, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, recordUsageUnread, agentPreamble, agentEnvironment, recordAgentRun, cardAgentAssignments,renameCardAgent,handOffAgents, agentRunForSession } from '../agents.mjs';
38
39
  import { finishCardAgentAssignment, recordCardAgentAssignment } from './agent-declarations.mjs';
39
40
  import { materializeAgentHome } from '../agent-home.mjs';
41
+ import { installedAgentRecords } from '../installed-apps.mjs';
40
42
  import { memorySnapshot } from '../hermes-cli.mjs';
41
43
  import { hermesModel, codexPaneRoute } from '../worker-route.mjs';
42
44
  import { refreshVault } from '../orchestration.mjs';
@@ -46,22 +48,23 @@ import { declareWorkerHome } from '../activation.mjs';
46
48
  import { MODEL_ARGUMENTS, NAME_ARGUMENTS } from '../activation.mjs';
47
49
  import { createRequire } from 'node:module';
48
50
  import { spawn as spawnChild, execFile } from 'node:child_process';
49
- import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
51
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
50
52
  import { homedir, hostname, tmpdir, totalmem } from 'node:os';
51
53
  import { basename, dirname, join } from 'node:path';
52
54
  import { createHash, randomUUID } from 'node:crypto';
53
55
  import { fileURLToPath } from 'node:url';
56
+ import { AsyncLocalStorage } from 'node:async_hooks';
54
57
  import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, NOTICE_RUN_IDS, openBoard, openBoardSnapshot, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
55
- import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, unrelayedStateAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
56
- import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
58
+ import { doors, fire, ownCoder, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, unrelayedStateAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
59
+ import { branchClaim, checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace, workspaceTask } from './workspace.mjs';
57
60
  import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
58
61
  import { evaluate, holds, render } from './expr.mjs';
59
- import { cardNumber, identifyNotice, numbered } from './notices.mjs';
62
+ import { cardName, cardNumber, identifyNotice, noticeName, numbered } from './notices.mjs';
60
63
  import { noticeReceiptsInTurns, noticeKey, requireNoticeBasis, noticeWriterBasis } from './notice-receipts.mjs';
61
64
  import { pendingRequestMail } from './request-mail.mjs';
62
65
  import {
63
- cardContext, claimTtl, currentRun, DEFAULT_FAILURE_LIMIT, extendClaim, getTask, lastSession,
64
- recomputeReady, runEffects, queueEffects, setRunSession, subscribeIn, replaceWorkerSubscriptions, subscriptions, reconcileDependencySubscriptions,
66
+ cardContext, cardLabel, claimTtl, currentRun, DEFAULT_FAILURE_LIMIT, extendClaim, getTask, lastSession,
67
+ recomputeReady, runEffects, queueEffects, setRunSession, subscribeIn, replaceWorkerSubscriptions, subscriptions, reconcileDependencySubscriptions, mentioned,
65
68
  } from './engine.mjs';
66
69
 
67
70
  const WORKER = fileURLToPath(new URL('../bin/hermes-worker.mjs', import.meta.url));
@@ -158,7 +161,7 @@ export function callerSession() {
158
161
  export async function observeFleet(root = fleetRoot) {
159
162
  const machines = await homeFleetMachines(root, normalMachine);
160
163
  const fleet = await readMachineFleet(machines, localMachine());
161
- presenceReading = { known: fleet.presenceKnown === true, online: new Set([localMachine(), ...fleet.online]), offline: fleet.offline ?? new Set(), unknown: fleet.unknown ?? new Set(), errors: fleet.errors ?? new Map() };
164
+ presenceReading = { known: fleet.presenceKnown === true, online: new Set([...(fleet.unknown?.has(localMachine()) ? [] : [localMachine()]), ...fleet.online]), offline: fleet.offline ?? new Set(), unknown: fleet.unknown ?? new Set(), errors: fleet.errors ?? new Map() };
162
165
  notePresence(presenceReading);
163
166
  return fleet;
164
167
  }
@@ -422,10 +425,23 @@ const offlineMachine = (machine) => {
422
425
  const m = normalMachine(machine ?? localMachine());
423
426
  return m !== localMachine() && presenceReading?.known === true && presenceReading.offline.has(m);
424
427
  };
428
+ // The doors a round only reads through. A park (stopDispatching) ends each one still waiting, and answers it as unread
429
+ // at once afterwards: every reader takes an answer that is not 0 as unknown, read next round, as it takes an offline
430
+ // machine's. A round's supervision reads every running card's discovery one after another (up to NATIVE_DISCOVERY_MS
431
+ // each), so a park that waited for them waited out most of the round, past the owner's 8 s wait for 'parked' (VO-3578
432
+ // r2454 F11: 13.6 s and 13.0 s of held card verbs). A door that acts (a pane prepared, a session owned) is never cut: it
433
+ // finishes and the park waits for it.
434
+ const READ_DOORS = new Set(['harness.v1.agent.show', 'harness.v1.launches.get', 'harness.v1.panes.capture', 'harness.v1.panes.list',
435
+ 'harness.v1.panes.presence', 'harness.v1.sessions.activity_under', 'harness.v1.sessions.discover', 'harness.v1.sessions.load']);
436
+ const refusal = (code, message) => ({ code: 1, stdout: '', stderr: JSON.stringify({ code, message }) });
425
437
  /** A machine's public door (`harness.v1.*`), answered at once when the machine reads as offline. */
426
- const doorOn = async (machine, method, params, options) => offlineMachine(machine)
427
- ? { code: 1, stdout: '', stderr: JSON.stringify({ code: 'machine_offline', message: `machine ${normalMachine(machine)} is unlinked in the current fleet record: ${presenceReading.errors.get(normalMachine(machine)) ?? 'unlinked from the team'}` }) }
428
- : await runtimeDoor(normalMachine(machine ?? localMachine()) === localMachine() ? null : machine, method, params, options);
438
+ const doorOn = async (machine, method, params, options) => {
439
+ if (offlineMachine(machine)) return refusal('machine_offline', `machine ${normalMachine(machine)} is unlinked in the current fleet record: ${presenceReading.errors.get(normalMachine(machine)) ?? 'unlinked from the team'}`);
440
+ const reads = READ_DOORS.has(method);
441
+ if (reads && stopping) return refusal('board_parked', parkedError().message);
442
+ try { return await runtimeDoor(normalMachine(machine ?? localMachine()) === localMachine() ? null : machine, method, params, reads ? { ...options, signal: park.signal } : options); }
443
+ finally { roundStep(`${method} on ${normalMachine(machine ?? localMachine())}`); }
444
+ };
429
445
  const unavailable = message => Object.assign(new Error(message), { launchDeferred: true });
430
446
 
431
447
  // ------------------------------------------------------------------ supervise
@@ -516,7 +532,39 @@ async function recordBounces(board, fleet, entry, report) {
516
532
  }
517
533
  }
518
534
 
535
+ /**
536
+ * An unended run is its card's current run, or it is ended. Every path that ends a run (end_run, endRun, a re-claim,
537
+ * the supervision below) reaches it through `tasks.current_run_id`, so a run no card holds as current is one nothing
538
+ * supervises and nothing will end: it reads `running` for ever, keeps its session counted live (sessionEnded) and its
539
+ * pane protected from closing (recordInputClose). Each such run is ended here as reclaimed, with its card's status at
540
+ * the time, whether its card is done, cancelled or still open, and whether it recorded a session or none. Its session
541
+ * is not closed here, and none is guaranteed gone: its card's current run may carry it, or its pane may still be alive
542
+ * with no owner (a live card a rebuild from the documents deleted keeps its runs, ztrack.mjs `plan.remove`). Where the
543
+ * workflow monitors runs and the retired run is its open card's latest, reconnectContinuedSessions finds that pane
544
+ * still running and reopens the run as the card's current run, under supervision again; any other such pane is left
545
+ * as it is.
546
+ */
547
+ const UNHELD_RUNS = `SELECT r.id, r.task_id, r.metadata, t.status AS card_status, c.stage FROM task_runs r
548
+ LEFT JOIN tasks t ON t.id = r.task_id LEFT JOIN supercode_cards c ON c.task_id = r.task_id
549
+ WHERE r.ended_at IS NULL AND NOT EXISTS (SELECT 1 FROM tasks h WHERE h.current_run_id = r.id)`;
550
+ function retireUnheldRuns(board, report) {
551
+ // read first: a round with nothing to retire opens no write; the write re-reads what it ends
552
+ if (!view(board, (db) => db.prepare(`${UNHELD_RUNS} LIMIT 1`).get())) return;
553
+ tx(board, (db) => {
554
+ for (const row of db.prepare(UNHELD_RUNS).all()) {
555
+ const session = json(row.metadata, {})?.supercode ?? null;
556
+ const status = row.stage ?? row.card_status ?? null;
557
+ const reason = `run ${row.id} is not the current run of ${row.task_id}${status ? ` (${status})` : ''}; nothing supervises it`;
558
+ db.prepare(`UPDATE task_runs SET status = 'reclaimed', outcome = 'reclaimed', summary = COALESCE(summary, ?), ended_at = ?,
559
+ claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ? AND ended_at IS NULL`).run(reason, now(), row.id);
560
+ event(db, row.task_id, 'run_retired', { reason, card_status: status, session_id: session?.session_id ?? null, pane: session?.pane ?? null, machine: session?.machine ?? null }, row.id);
561
+ (report.retired ??= []).push([row.task_id, row.id]);
562
+ }
563
+ });
564
+ }
565
+
519
566
  async function supervise(board, fleet, settings, report, entry, effects) {
567
+ retireUnheldRuns(board, report);
520
568
  const rows = board.db.prepare(entry.workflow.run ? "SELECT id FROM tasks WHERE current_run_id IS NOT NULL" : "SELECT id FROM tasks WHERE status = 'running'").all();
521
569
  // Published native observations are the only liveness input. Missing data remains unknown. A launch the
522
570
  // observations show stopped is asked, through its machine's door, how it ended.
@@ -883,7 +931,9 @@ async function homeHoldings(root, slugs, lanes, fleet, entry) {
883
931
  // each run's session read before it is counted live or not (unknown stays unknown)
884
932
  for (const r of b.db.prepare('SELECT r.metadata FROM tasks t JOIN task_runs r ON r.id = t.current_run_id').all()) await readBefore(fleet, json(r.metadata, {})?.supercode);
885
933
  const storedOf = (id) => storedStatus(b.db, b, entry, id).stored;
886
- out.push(...await holdings(b, { lanes, fleetOk: fleet.ok, alive: (s) => sessionAlive(fleet, s), liveRecord: live, modelOf: transcriptModel, storedOf, machineOf: normalMachine, home: localMachine(),
934
+ // a session on a machine whose record is not current is not known gone: it keeps its place in the caps
935
+ out.push(...await holdings(b, { lanes, fleetOk: fleet.ok,
936
+ alive: (s) => sessionAlive(fleet, s) ?? (s?.surface !== 'headless' && s?.machine && fleet.unknown?.has(normalMachine(s.machine)) ? true : null), liveRecord: live, modelOf: transcriptModel, storedOf, machineOf: normalMachine, home: localMachine(),
887
937
  runLaneOf: row => runLane(b.db, b, entry, getTask(b.db, row.id), lanes) }));
888
938
  }
889
939
  finally { b.close(); }
@@ -974,6 +1024,9 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
974
1024
  if (task.subtask_of || task.current_run_id) continue;
975
1025
  const state = scopeFor(task).card;
976
1026
  if (state.archived || ['done', 'cancelled'].includes(state.status)) continue;
1027
+ // A person's card is a dependency on that person, never work for a session: the board starts none for it, whatever
1028
+ // its workflow's start would allow, and it closes when that person completes it (person-done).
1029
+ if (state.person) { waiting(task, `assigned to ${state.person}: a person's card runs no session; it closes when that person completes it`); continue; }
977
1030
  if (state.status === 'running' && completedWork(board.db, task.id)) {
978
1031
  waiting(task, 'work is complete; the board must select its independent review before another implementer run'); continue;
979
1032
  }
@@ -1134,9 +1187,8 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
1134
1187
  const surface = lane.surface === 'pane' ? 'pane' : 'headless';
1135
1188
  const scope = view(board, (db) => scopeOf(db, board, task, { params: entry.params }));
1136
1189
  const where = whereOf(board, task);
1137
- // a subtask's run also sees its arc: the arc's text, tasks and thread (where its owner's rulings are)
1138
- const arcContext = task.subtask_of ? view(board, (db) => cardContext(db, task.subtask_of, { nativeHolds: Boolean(entry.workflow.flags?.paused) })) : '';
1139
- let prompt = render(spec.prompt, { ...scope, where, context: view(board, (db) => cardContext(db, id, { nativeHolds: Boolean(entry.workflow.flags?.paused) })), arc_context: arcContext, doors: doors(entry.workflow, status, spec.role, id, where, surface) });
1190
+ const told = view(board, (db) => roleBrief(db, board, entry, task, status, surface));
1191
+ let prompt = told.prompt;
1140
1192
  // the run's session record knows its status, role and slot (a reviewer's slot is never the implementer's); the slot
1141
1193
  // is a template, so a step can ask for isolation by naming a new one
1142
1194
  const slot = String(templateValue(spec.session ?? 'worker', scope) ?? 'worker');
@@ -1166,13 +1218,26 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
1166
1218
  throw Object.assign(new Error(refused.why), { launchPending: true });
1167
1219
  }
1168
1220
  }
1221
+ // A machine whose record is not current lists none of its sessions or panes: nothing opened there could be checked
1222
+ // against what already runs (a continuation, a cap's holdings). An explicit start or a deferred one waits for its
1223
+ // reading, as a round's launcherCheck does; its clock moves with the wait (reconcileLaunches).
1224
+ const host = lane.surface === 'pane' ? executionHost(lane, task.machine, localMachine(), normalMachine) : null;
1225
+ if (host && fleet?.unknown?.has(host)) {
1226
+ const why = `machine ${host}'s fleet record is not current: ${fleet.errors?.get(host) ?? 'no current record'}; the launch waits for it`;
1227
+ runTx(board, id, runId, db => {
1228
+ const previous = currentRun(db, id)?.metadata?.supercode;
1229
+ setRunSession(db, runId, { status, role: spec.role, slot, launched_at: now(), launch_deferred: true, launch_deferred_error: why });
1230
+ if (previous?.launch_deferred_error !== why) event(db, id, 'launch_deferred', { reason: why }, runId);
1231
+ });
1232
+ throw Object.assign(new Error(why), { launchDeferred: true, launchPending: true });
1233
+ }
1169
1234
  if (spec.role === 'reviewer') {
1170
1235
  const worker = view(board, (db) => lastSession(db, id, 'worker'));
1171
1236
  const reviewer = view(board, (db) => lastSession(db, id, slot));
1172
1237
  if (slot === 'worker' || (worker?.session_id && worker.session_id === reviewer?.session_id)) throw new Error('a reviewer must have a different session from the implementer');
1173
1238
  }
1174
1239
  // the model it runs is recorded with the session, for caps that count by model
1175
- const meta = { status, role: spec.role, slot, model: task.model_override ?? lane.model ?? null, ...(spec.role === 'reviewer' ? { review_candidate: reviewCandidate(board, id) } : {}) };
1240
+ const meta = { status, role: spec.role, slot, model: task.model_override ?? lane.model ?? null, prompt_digest: told.digest, ...(spec.role === 'reviewer' ? { review_candidate: reviewCandidate(board, id) } : {}) };
1176
1241
  try {
1177
1242
  // Teams is never on a launch's path: the card's agent is its identity minted on this host under its retained intent,
1178
1243
  // and its sessions are joined to it off this path, after the launch. No Teams answer starts, stops or holds a card.
@@ -1237,15 +1302,86 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
1237
1302
  * line stays one line and far under that size whatever the card holds; the session on the board's machine reads the
1238
1303
  * file, one elsewhere prints it through the board's door.
1239
1304
  */
1240
- export function briefByReference(board, task, runId, role, where, prompt) {
1305
+ export function briefByReference(board, task, runId, role, where, prompt, how = 'started') {
1241
1306
  const file = briefPath(board.root, board.slug, task.id, runId);
1242
1307
  mkdirSync(dirname(file), { recursive: true });
1243
1308
  writeFileSync(file, `${prompt}\n`, { mode: 0o600 });
1309
+ const card = cardLabel(boardKey(board.root, board.slug), cardNumber(board.db, task.id), task.id, readableIdOf(board, task.id));
1310
+ const opening = how === 'attached' ? `The ${board.label} board attached you to run ${runId} of card ${card} as its ${role}`
1311
+ : how === 'changed' ? `The ${board.label} board's brief for the ${role} of card ${card} changed (run ${runId})`
1312
+ : `The ${board.label} board started run ${runId} of card ${card} and you are its ${role}`;
1313
+ return `${opening}. Your instructions from the board are the brief ${briefFrom(board, task, file, where)}, then carry it out as your own task without waiting for another message.`;
1314
+ }
1315
+
1316
+ /**
1317
+ * A card role's brief: the workflow's prompt for the role the card's status runs (the home's prompts/<role>.md), rendered
1318
+ * over the card, its arc and the doors that role has. It is the one text every path that puts a session on a card in a
1319
+ * role gives that session: a launch (a fresh start, a resumed or replaced session, a reviewer's), an adoption, and a
1320
+ * telling again when the home's prompt for the role changes (retellBriefs). `digest` names the prompt it came from.
1321
+ */
1322
+ export function roleBrief(db, board, entry, task, status, surface = 'pane') {
1323
+ const spec = runSpec(entry, status, task);
1324
+ if (!spec) return null;
1325
+ const where = whereOf(board, task);
1326
+ const nativeHolds = Boolean(entry.workflow.flags?.paused);
1327
+ const scope = scopeOf(db, board, task, { params: entry.params });
1328
+ // a subtask's run also sees its arc: the arc's text, tasks and thread (where its owner's rulings are)
1329
+ const prompt = render(spec.prompt, { ...scope, where, context: cardContext(db, task.id, { nativeHolds }),
1330
+ arc_context: task.subtask_of ? cardContext(db, task.subtask_of, { nativeHolds }) : '', doors: doors(entry.workflow, status, spec.role, task.id, where, surface) });
1331
+ return { spec, where, prompt, digest: promptDigest(spec) };
1332
+ }
1333
+ const promptDigest = (spec) => spec?.prompt ? createHash('sha256').update(String(spec.prompt)).digest('hex').slice(0, 16) : null;
1334
+
1335
+ /** File a role's brief (roleBrief) to the session at `address` on run `runId`, by reference, as a session notice in the
1336
+ * caller's transaction, and record which prompt it was. */
1337
+ function queueBrief(db, board, task, runId, address, told, how) {
1338
+ const role = told.spec.role ?? 'implementer';
1339
+ const text = briefByReference(board, task, runId, role, told.where, told.prompt, how);
1340
+ const source = event(db, task.id, 'brief_told', { address, role, how, prompt_digest: told.digest }, runId);
1341
+ const effect = { kind: 'session_notice', id: task.id, runId, address, text, sourceEvent: source, verb: how === 'changed' ? 'brief_changed' : 'brief',
1342
+ messageKey: `board-brief-${task.id}-${runId}-${told.digest}-${how}-${createHash('sha256').update(address).digest('hex').slice(0, 12)}` };
1343
+ db.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
1344
+ setRunSession(db, runId, { prompt_digest: told.digest });
1345
+ }
1346
+
1347
+ /**
1348
+ * Each session working a card is told its role's brief again, once, when the home's prompt for that role is not the one
1349
+ * it was told (its run's `prompt_digest`; a run that records none was never told this one), as a room's guidance is told
1350
+ * again on every change. A session being launched, closed, released or in a person's hands is told nothing here.
1351
+ */
1352
+ function retellBriefs(board, entry, report) {
1353
+ // each card in its own write: a brief that cannot be told (its prompt unreadable, its card changed) is said in the
1354
+ // round's report and stops no other card's
1355
+ for (const { id } of view(board, (db) => db.prepare('SELECT id FROM tasks WHERE current_run_id IS NOT NULL').all())) {
1356
+ try {
1357
+ tx(board, (db) => {
1358
+ const run = currentRun(db, id), session = run?.metadata?.supercode, address = addressOf(session);
1359
+ if (!run || run.ended_at != null || !address || session.surface === 'headless' || session.launch_pending || session.launch_deferred
1360
+ || session.close_requested || session.close_confirmed_at || session.released || detachedSession(db, session)) return;
1361
+ const task = getTask(db, id), status = statusOf(db, task);
1362
+ if (promptDigest(runSpec(entry, status, task)) === (session.prompt_digest ?? null)) return;
1363
+ const told = roleBrief(db, board, entry, task, status, 'pane');
1364
+ if (!told?.digest) return;
1365
+ queueBrief(db, board, task, run.id, address, told, 'changed');
1366
+ (report.briefs_retold ??= []).push([id, run.id, told.digest]);
1367
+ });
1368
+ } catch (error) { (report.failed ??= []).push([id, `brief: ${String(error.message).slice(-200)}`]); }
1369
+ }
1370
+ }
1371
+ /** Where a run's session reads its brief: the file on the board's machine, the board's door on any other. */
1372
+ function briefFrom(board, task, file, where) {
1244
1373
  const local = !task.machine || normalMachine(task.machine) === localMachine();
1245
- const from = local ? `in the file ${file}: read the whole file now`
1374
+ return local ? `in the file ${file}: read the whole file now`
1246
1375
  : `kept by the board: print it with \`supercode workflow brief ${task.id} ${where}\` (into a file outside your repository when it is long) and read all of it now`;
1247
- const ref = cardHandleOf(board, task.id)?.toUpperCase();
1248
- return `The ${board.label} board started run ${runId} of card ${ref ? `${ref} (${task.id})` : task.id} and you are its ${role}. Your instructions from the board are the brief ${from}, then carry it out as your own task without waiting for another message.`;
1376
+ }
1377
+ /**
1378
+ * The run's pending brief, said again in an idle reminder: a session that stopped before its first turn (its first input
1379
+ * undelivered, its turn interrupted) still has the whole of its instructions in the brief, and a reminder that only
1380
+ * says "continue" names nothing it was told. Null when the run kept no brief (a headless run's prompt is its argv).
1381
+ */
1382
+ function pendingBrief(board, task, runId, where) {
1383
+ const file = briefPath(board.root, board.slug, task.id, runId);
1384
+ return existsSync(file) ? `Run ${runId}'s instructions from the board are its brief ${briefFrom(board, task, file, where)}, and carry it out.` : null;
1249
1385
  }
1250
1386
 
1251
1387
  /** Start one card now by a person's word (a triage card's session, a manager's start): the workflow's start event. */
@@ -1258,6 +1394,8 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
1258
1394
  const before = getTaskNow(board, id), checked = await checkWorkspace(board, before);
1259
1395
  const moved = tx(board, (db) => {
1260
1396
  if (detachedCard(db, id)) throw new Error(`${id} is detached to a person; request and verify adoption before starting it`);
1397
+ const person = personOf(getTask(db, id)?.assignee);
1398
+ if (person) throw new Error(`${id} is assigned to ${person}: a person's card runs no session; it closes when that person completes it`);
1261
1399
  assertWorkspaceOwner(db, before, checked);
1262
1400
  if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, id);
1263
1401
  if (!roles.some(role => ['manager', 'creator'].includes(role))) throw new Error('Only the manager or creator starts a held card; request its decision.');
@@ -1332,8 +1470,11 @@ async function turnAtClose(session, pane = null, { launchWindow = null, quiet =
1332
1470
  const machine = session.machine ?? localMachine();
1333
1471
  const recordOf = (rows, id, harness) => rows.find((row) => row.locator?.session_id === id && row.locator?.harness === harness);
1334
1472
  let activity, rows = null;
1473
+ // A Codex pane may run under its own Codex home (a pane route's CODEX_HOME, recorded on the run as codex_home), which
1474
+ // the machine's default sources do not hold: every read of its session here names that home, as readNative does.
1475
+ const codexHome = (session.harness ?? 'claude-code') === 'codex' ? session.codex_home ?? null : null;
1335
1476
  if (pane?.pid) {
1336
- const r = await doorOn(machine, 'harness.v1.sessions.activity_under', { pids: [pane.pid] }, { timeout: NATIVE_DISCOVERY_MS });
1477
+ const r = await doorOn(machine, 'harness.v1.sessions.activity_under', { pids: [pane.pid], ...(codexHome ? { homes: { codex: join(codexHome, 'sessions') } } : {}) }, { timeout: NATIVE_DISCOVERY_MS });
1337
1478
  const page = r.code === 0 ? lastJson(r.stdout) : null;
1338
1479
  if (!Array.isArray(page?.activities)) return { why: `the session in pane ${session.pane} could not be read just now (${(r.stderr || r.stdout).trim().slice(-160)})` };
1339
1480
  activity = page.activities.find((item) => item.pid === pane.pid)?.activity ?? null;
@@ -1349,7 +1490,7 @@ async function turnAtClose(session, pane = null, { launchWindow = null, quiet =
1349
1490
  if (command && !HARNESS_COMMANDS.has(command)) return null;
1350
1491
  const unfound = `pane ${session.pane} runs ${command || 'a program it does not name'}, whose session could not be read`;
1351
1492
  if (!session.session_id) return { why: unfound };
1352
- try { rows = (await discoverNative(machine, session.harness ?? 'claude-code', session.session_id)).sessions; }
1493
+ try { rows = (await discoverNative(machine, session.harness ?? 'claude-code', session.session_id, codexHome)).sessions; }
1353
1494
  catch (error) { return { why: `${unfound} (${String(error.message).slice(0, 160)})` }; }
1354
1495
  activity = recordOf(rows, session.session_id, session.harness ?? 'claude-code')?.activity ?? null;
1355
1496
  // a row that shows no live owner beside a pane that runs a harness places nothing: another session may run there
@@ -1359,7 +1500,7 @@ async function turnAtClose(session, pane = null, { launchWindow = null, quiet =
1359
1500
  return { why: `pane ${session.pane} names no process to read` };
1360
1501
  } else {
1361
1502
  if (!session.session_id) return { why: 'its session is not known yet' };
1362
- try { rows = (await discoverNative(machine, session.harness ?? 'claude-code', session.session_id)).sessions; }
1503
+ try { rows = (await discoverNative(machine, session.harness ?? 'claude-code', session.session_id, codexHome)).sessions; }
1363
1504
  catch (error) { return { why: `its session could not be read just now (${String(error.message).slice(0, 160)})` }; }
1364
1505
  activity = nativeReading(rows, session).row?.activity ?? null;
1365
1506
  if (!activity) return { why: `its session ${addressOf(session)} is not in its machine's discovery` };
@@ -1383,7 +1524,7 @@ async function turnAtClose(session, pane = null, { launchWindow = null, quiet =
1383
1524
  if (blocked) return { stopped: blocked };
1384
1525
  const sessionId = activity.session_id ?? session.session_id, harness = activity.harness ?? session.harness ?? 'claude-code';
1385
1526
  if (!sessionId || !quiet) return { why: unknown };
1386
- if (!rows) try { rows = (await discoverNative(machine, harness, sessionId)).sessions; }
1527
+ if (!rows) try { rows = (await discoverNative(machine, harness, sessionId, codexHome)).sessions; }
1387
1528
  catch (error) { return { why: `${unknown}, and its record could not be read just now (${String(error.message).slice(0, 160)})` }; }
1388
1529
  const record = recordOf(rows, sessionId, harness);
1389
1530
  if (TURN_IN_PROGRESS.has(record?.activity?.turn)) return inTurn(record.activity.turn);
@@ -1469,7 +1610,17 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1469
1610
  if (current) retireSupersededRuns(db, current.task_id, row.id, json(current.metadata, {})?.supercode);
1470
1611
  }
1471
1612
  });
1613
+ // Each machine's pane list, read once per pass: its live panes, and its dead ones. A dead pane is still the machine's
1614
+ // until it is closed (a pane whose program failed is kept, marked dead, for its last screen), so it is closed as any.
1472
1615
  const panes = new Map();
1616
+ const paneListOn = async (machine) => {
1617
+ if (!panes.has(machine)) {
1618
+ const r = await doorOn(machine, 'harness.v1.panes.list',{}, { timeout: 60_000, as: boardAddress(board.label) });
1619
+ const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
1620
+ panes.set(machine, Array.isArray(list) ? { live: new Map(list.filter(p => !p.dead).map((p) => [p.pane, { pid: p.pid ?? null, command: p.activeCommand ?? null }])), dead: new Set(list.filter(p => p.dead).map((p) => p.pane)) } : { error: `cannot read panes on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}` });
1621
+ }
1622
+ return panes.get(machine);
1623
+ };
1473
1624
  for (const row of rows) {
1474
1625
  await yieldToOwner();
1475
1626
  let session = json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
@@ -1533,6 +1684,8 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1533
1684
  .some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); })) continue;
1534
1685
  finalFailure = false;
1535
1686
  } else if (observed.present !== false) continue;
1687
+ // presence answers for a pane whose program runs: one its machine still lists dead is not gone, and its refusal stands
1688
+ else if ((await paneListOn(session.machine ?? localMachine())).dead?.has(session.pane)) continue;
1536
1689
  }
1537
1690
  const active = view(board, (db) => db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all());
1538
1691
  // Persist retries even for historical ends that did not have a close_session action. The close is claimed in the
@@ -1566,6 +1719,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1566
1719
  return true;
1567
1720
  });
1568
1721
  if (!claimed) continue;
1722
+ noteClaim(board, 'runs', row.id);
1569
1723
  try {
1570
1724
  const machine = session.machine ?? localMachine();
1571
1725
  if (session.surface === 'native' && !session.pane) {
@@ -1578,17 +1732,17 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1578
1732
  await stopSession(board, session);
1579
1733
  }
1580
1734
  } else if (!finalFailure) {
1581
- if (!panes.has(machine)) {
1582
- const r = await doorOn(machine, 'harness.v1.panes.list',{}, { timeout: 60_000, as: boardAddress(board.label) });
1583
- const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
1584
- panes.set(machine, Array.isArray(list) ? { live: new Map(list.filter(p => !p.dead).map((p) => [p.pane, { pid: p.pid ?? null, command: p.activeCommand ?? null }])) } : { error: `cannot read panes on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}` });
1585
- }
1586
- const observed = panes.get(machine);
1735
+ const observed = await paneListOn(machine);
1587
1736
  if (observed.error) throw new Error(`close deferred: ${observed.error}`); // an unread pane list is no verdict on the pane
1588
- if (observed.live.has(session.pane)) {
1737
+ const dead = observed.dead.has(session.pane);
1738
+ if (dead || observed.live.has(session.pane)) {
1589
1739
  const protectedNow = view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all())
1590
1740
  .some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); });
1591
1741
  if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
1742
+ }
1743
+ // a dead pane's program has ended: it has no turn to close between, and is closed at once
1744
+ if (dead) { await stopSession(board, session); observed.dead.delete(session.pane); }
1745
+ else if (observed.live.has(session.pane)) {
1592
1746
  // An ended run's session is closed only between its turns, read last, after the claim and the pane list (a pane
1593
1747
  // already gone is settled above with no reading; t_f34710bc's coder was cut mid-proof at 15:01:13Z, 2026-10-04).
1594
1748
  if (row.ended_at) {
@@ -1652,7 +1806,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1652
1806
  }
1653
1807
 
1654
1808
  // When this process last read each run's turn (run id → seconds). Kept in memory on purpose: a board that restarted
1655
- // has watched nothing, and its first reading of a run is where that run's watch begins.
1809
+ // has read nothing itself, and its first reading of a run is where that run's watch resumes (resumedWatch).
1656
1810
  const idleWatch = new Map();
1657
1811
  /** Native idle intervals are independent of dispatch cadence and transcript writes.
1658
1812
  * Keep bounded readings on the run so an ordinary quiet stretch can be reviewed later. */
@@ -1720,24 +1874,35 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1720
1874
  // clock of the run moves on a round that could not see it
1721
1875
  if (session.machine && !fleet.online.has(session.machine)) return;
1722
1876
  if (session.surface === 'native' && sessionAlive(fleet, session) !== true) return;
1723
- // a session in no pane has its turn on its machine's discovery row, which sessionAlive just read
1877
+ // A session the machine's record publishes no activity for has its turn on its machine's discovery row, which
1878
+ // readBefore read: a session in no pane, and a live pane whose record names no session in it (a Codex pane on its
1879
+ // own Codex home, VO-3259 run 2408: supervise read its turn idle there while this read none, so its run took the
1880
+ // unknown-turn notice once and was never reminded or reclaimed). A pane is read so only while its record shows it.
1881
+ const published = fleet.sessions.find((x) => x.address === addressOf(session));
1724
1882
  let nativeRow = null;
1725
- if (session.surface === 'native') try { nativeRow = nativeReading(nativeSessions(session.machine, session.harness ?? 'claude-code', session.session_id), session).row; } catch { /* unread: no turn */ }
1726
- const live = fleet.sessions.find((x) => x.address === addressOf(session)) ?? (nativeRow ? { address: addressOf(session), turn: nativeRow.activity?.turn ?? null, waiting_for: nativeRow.activity?.evidence?.waiting_for ?? null,
1883
+ if (!published && (session.surface === 'native' || sessionAlive(fleet, session) === true)) try { nativeRow = nativeReading(nativeSessions(session.machine, session.harness ?? 'claude-code', session.session_id), session).row; } catch { /* unread: no turn */ }
1884
+ const live = published ?? (nativeRow ? { address: addressOf(session), turn: nativeRow.activity?.turn ?? null, waiting_for: nativeRow.activity?.evidence?.waiting_for ?? null,
1727
1885
  turn_source: nativeRow.activity?.evidence?.source ?? null, turn_since_ms: nativeRow.activity?.evidence?.native_state_since_ms ?? null } : undefined);
1728
1886
  const observedAt = now();
1729
1887
  // Quiet time is time the board watched. A run this process has not read for a while (the board restarted, was
1730
- // paused, could not read the fleet or the machine) starts its idle and unknown-turn clocks from this reading:
1731
- // the gap is not idleness, and no reminder is spent on it (volter-org t_9a9c2a9c: reminder 1 in the second its
1732
- // machine came back, the run reclaimed 45 minutes later).
1888
+ // paused, could not read the fleet or the machine) resumes its idle clock where resumedWatch reads it: from the
1889
+ // harness's own idle-since where its session reads idle with one and its machine's record was current over it,
1890
+ // else from this reading, so a gap the board could not see is not idleness and no reminder is spent on it
1891
+ // (volter-org t_9a9c2a9c: reminder 1 in the second its machine came back, the run reclaimed 45 minutes later).
1892
+ // The unknown-turn clock starts here.
1733
1893
  const lastWatched = idleWatch.get(runRow.id);
1734
1894
  if (lastWatched == null || observedAt - lastWatched > Math.max(3 * settings.interval, settings.idleQuiet / 2)) {
1735
- setRunSession(db, runRow.id, { watch_since: observedAt, unknown_since: null });
1736
- session = { ...session, watch_since: observedAt, unknown_since: null };
1895
+ const resumed = resumedWatch(session, live, runRow, fleet.linkedSince?.get(session.machine ?? localMachine()), observedAt);
1896
+ setRunSession(db, runRow.id, { watch_since: resumed, unknown_since: null });
1897
+ session = { ...session, watch_since: resumed, unknown_since: null };
1737
1898
  }
1738
1899
  watched = [runRow.id, observedAt];
1739
- const read = sessionProgress(db, task);
1900
+ // A reviewer's work is its verdict, and its verdict ends its run: nothing the card shows while the review runs is
1901
+ // the reviewer's progress (the implementer's commits, a card specified or a subtask closed would reset its reminders
1902
+ // for as long as anyone else works), and an unread checkout does not hold them.
1903
+ const reviewing = (session.slot ?? 'worker') === 'reviewer';
1740
1904
  const priorProgress = session.work_progress;
1905
+ const read = reviewing ? { event: priorProgress?.event ?? sessionProgress(db, task).event, commit: null } : sessionProgress(db, task);
1741
1906
  // The commit a run is measured from is the last one read. A checkout that could not be read (its machine silent,
1742
1907
  // its daemon without the door) keeps that baseline: its first reading after is where it stands, not work done.
1743
1908
  const progress = { event: read.event, commit: read.commit ?? priorProgress?.commit ?? null, ...(read.commit == null ? { commit_unread: read.commit_unread } : {}) };
@@ -1845,10 +2010,13 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1845
2010
  // A native idle receipt also works without a transcript timestamp.
1846
2011
  const interval = idleInterval(session, live, observedAt);
1847
2012
  reading.idle_since = interval.since; reading.quiet_seconds = interval.quiet;
2013
+ reading.idle_since_at = new Date(interval.since * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
1848
2014
  if (session.idle_since !== interval.since) setRunSession(db, runRow.id, { idle_since: interval.since });
1849
- // The workflow declares the threshold, repeat interval, eligibility and
1850
- // notice. The observer only supplies a watched native idle interval. This
1851
- // path precedes heartbeat/nudge cooldown and unread checkout handling.
2015
+ // The workflow declares the threshold, repeat interval, eligibility (whether
2016
+ // the board shows a recorded wait), audience and notice. The observer only
2017
+ // supplies a watched native idle interval. This path precedes
2018
+ // heartbeat/nudge cooldown and unread checkout handling.
2019
+ let stalled = false;
1852
2020
  if (handles('session_stalled')) {
1853
2021
  const limits = limitsOf(db, board, entry, task, session);
1854
2022
  const first = Number(limits.idle_notice), repeat = Number(limits.idle_notice_repeat);
@@ -1862,6 +2030,7 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1862
2030
  last_turn_time_source: Number(live?.turn_since_ms) > 0 ? 'native_idle_since' : turnAt ? 'last_session_update' : null, repeat: Boolean(prior) };
1863
2031
  const result = fire(db, board, entry, id, 'session_stalled', { roles: ['platform'], payload });
1864
2032
  staged.push(...result.effects);
2033
+ stalled = true;
1865
2034
  setRunSession(db, runRow.id, { idle_notice: { native_since_ms: nativeIdle, since: interval.since, at: observedAt } });
1866
2035
  report.notified.push([id, 'session_stalled', address]);
1867
2036
  } catch (error) {
@@ -1869,24 +2038,41 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1869
2038
  }
1870
2039
  }
1871
2040
  if (!handles('session_idle')) return;
2041
+ // a session told it stalled is not also reminded for a whole quiet period after that notice
2042
+ if (stalled || (Number(session.idle_notice?.at) > 0 && observedAt - Number(session.idle_notice.at) < settings.idleQuiet)) { retain('stall_notified'); return; }
1872
2043
  if (reading.quiet_seconds < settings.idleQuiet) { retain('below_threshold'); return; }
1873
2044
  if (session.goal_nudged_at && observedAt - session.goal_nudged_at < settings.idleQuiet) { retain('nudge_cooldown'); return; }
1874
2045
  // a session whose first input bounced was never told its work: its quiet is not idleness (recordBounces told its manager)
1875
2046
  if (session.first_input === 'bounced') { retain('first_input_bounced'); return; }
1876
2047
  // A reminder says the card shows no progress, and the last of them ends the run for it. With the card's checkout
1877
2048
  // unread that is not known: nothing is said or counted until it is read (the reading's `progress` says why).
1878
- if (read.commit == null) { retain('progress_unread'); return; }
2049
+ if (read.commit == null && !reviewing) { retain('progress_unread'); return; }
1879
2050
  try {
1880
2051
  const where = whereOf(board, task);
1881
2052
  // the ask names what the session may do, as its run's prompt did
1882
2053
  const said = doors(entry.workflow, status, session.role ?? runSpec(entry, status)?.role ?? 'implementer', id, where, session.surface !== 'headless' ? 'pane' : 'headless');
1883
- const r = fire(db, board, entry, id, 'session_idle', { roles: ['platform'], payload: { ...reading, turns: reading.turns_before, worked }, extra: { where, doors: said } });
2054
+ const brief = pendingBrief(board, task, runRow.id, where);
2055
+ const r = fire(db, board, entry, id, 'session_idle', { roles: ['platform'], payload: { ...reading, turns: reading.turns_before, worked }, extra: { where, doors: said, pending_brief: brief } });
2056
+ // a reminder wakes the session with what its run is for: send_to_actor saves it with the run's pending brief
1884
2057
  staged.push(...r.effects);
1885
2058
  const after = currentRun(db, id);
1886
2059
  const stored = json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(runRow.id)?.metadata, {})?.supercode;
1887
2060
  const observation = retain('dispatched', { turns_after: Number(stored?.goal_turns ?? 0), nudge_queued: r.effects.some(e => e.kind === 'send_to_actor'), run_ended: after?.id !== runRow.id });
1888
2061
  event(db, id, 'session_idle_observed', observation, runRow.id);
1889
2062
  if (after?.id === runRow.id) setRunSession(db, runRow.id, { goal_nudged_at: observedAt });
2063
+ else if (reviewing && db.prepare('SELECT ended_at FROM task_runs WHERE id = ?').get(runRow.id)?.ended_at != null) {
2064
+ // A reviewer that stayed idle through its reminders with no verdict has its run ended and its session closed;
2065
+ // the card's next review run (the board's own, its card still in review) resumes that same reviewer session
2066
+ // with its round's brief, as every review round does. Nothing is voided: no verdict was given, and every
2067
+ // earlier one stands.
2068
+ requestSessionClose(db, id, { runId: runRow.id });
2069
+ const ended = db.prepare('SELECT outcome FROM task_runs WHERE id = ?').get(runRow.id)?.outcome ?? null;
2070
+ event(db, id, 'review_run_restarted', { run_id: runRow.id, session: address, outcome: ended, reminders: reading.turns_before, quiet_seconds: reading.quiet_seconds }, runRow.id);
2071
+ const card = getTask(db, id), held = Boolean(card?.blocked_by?.length || card?.paused);
2072
+ const to = managers[0] ?? null;
2073
+ if (to) event(db, id, 'board_message', { to, text: `${id}: its reviewer session ${address} stayed idle with no verdict through ${reading.turns_before} reminder(s) carrying its brief; the board ended review run ${runRow.id} (${ended ?? 'ended'}) and closed that session. ${held ? 'The card is held (its failure limit or a block), so its next review resumes that reviewer session once it is released.' : 'Its next review run resumes that reviewer session.'} No verdict was voided.` }, null);
2074
+ (report.review_restarted ??= []).push([id, runRow.id, address]);
2075
+ }
1890
2076
  report.notified.push([id, 'session_idle', addressOf(session)]);
1891
2077
  } catch (error) {
1892
2078
  retain('dispatch_failed', { error: String(error.message).slice(-200) });
@@ -1923,7 +2109,7 @@ export async function sessionNotice(board, e) {
1923
2109
  const address = e.address ?? addressOf(prior);
1924
2110
  if (!address) throw new Error('lifecycle notice awaits the owned launch session identity');
1925
2111
  const card = view(board, (db) => getTask(db, e.id));
1926
- const subject = numbered(boardKey(board.root, board.slug), card?.number, `${String(e.verb ?? 'run_changed').replaceAll('_', ' ')} · run ${e.runId} · event ${e.sourceEvent ?? 'none'} · ${card?.title || e.id}`);
2112
+ const subject = numbered(boardKey(board.root, board.slug), card?.number, `${String(e.verb ?? 'run_changed').replaceAll('_', ' ')} · run ${e.runId} · event ${e.sourceEvent ?? 'none'} · ${card?.title || e.id}`, noticeName(card));
1927
2113
  const filed = await fileMail(board, address, e.text, effectMessageKey(e), { subject });
1928
2114
  tx(board, (db) => {
1929
2115
  const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
@@ -1946,7 +2132,7 @@ export async function sessionEffect(board, e) {
1946
2132
  if (!address) throw new Error('actor session identity unavailable');
1947
2133
  // filed in the actor's mailbox, which delivers it
1948
2134
  const card = view(board, (db) => getTask(db, e.id));
1949
- const filed = await fileMail(board, address, e.text, effectMessageKey(e), { subject: numbered(boardKey(board.root, board.slug), card?.number, `actor notice · run ${e.runId ?? 'none'} · ${card?.title || e.id}`) });
2135
+ const filed = await fileMail(board, address, e.text, effectMessageKey(e), { subject: numbered(boardKey(board.root, board.slug), card?.number, `actor notice · run ${e.runId ?? 'none'} · ${card?.title || e.id}`, noticeName(card)) });
1950
2136
  if (filed.new) tx(board, db => event(db, e.id, 'actor_notice_filed', { notice: e.durableId, address, message_id: filed.message_id }, e.runId));
1951
2137
  return true;
1952
2138
  }
@@ -2191,16 +2377,18 @@ async function readRunUsage(root,org,row,endedAt){
2191
2377
  /** The board's agent records: each run kept in the agent store, closed cards' assignments ended, declarations and ends
2192
2378
  * handed to this machine's daemon, and usage read from each machine's own doors. No Teams call: the daemon declares and
2193
2379
  * ends agents and binds their sessions (D137). It runs in the delivery pass (`deliver`), never in a dispatch round. */
2194
- async function synchronizeAgents(board, report) {
2380
+ async function synchronizeAgents(board, report, fleet = null) {
2195
2381
  // A delayed launch receipt/catalogue event binds the same declared agent, never another identity.
2196
2382
  // A resumed session is held by its newest run; an earlier run of the same session no longer owns it.
2197
2383
  const agentRuns=[...new Map((await readRunPages(board.db, 'id,task_id,metadata,ended_at', { where: "json_extract(metadata,'$.supercode.agent_id') IS NOT NULL" }))
2198
2384
  .map(row=>{const session=json(row.metadata,{})?.supercode;return [session?.session_id?addressOf(session):`run:${row.id}`,row];})).values()];
2385
+ const live=new Map();
2199
2386
  for(const scanned of agentRuns){
2200
2387
  await yieldToOwner();
2201
2388
  const row=view(board,db=>db.prepare('SELECT id,task_id,metadata,ended_at FROM task_runs WHERE id=?').get(scanned.id));
2202
2389
  if(!row)continue;
2203
2390
  const session=json(row.metadata,{})?.supercode;
2391
+ if(credentialsWanted(board,row,session,fleet))live.set(credentialsKey(session.machine,session.agent_id),{task:row.task_id,run:row.id});
2204
2392
  if(!session?.session_id||!session.agent_org||view(board,db=>detachedSession(db,session)))continue;
2205
2393
  const ref=`${session.harness}:${session.session_id}`;
2206
2394
  // An ended run whose session the agent store already holds for another agent lost that claim when it ran: no round
@@ -2219,6 +2407,7 @@ async function synchronizeAgents(board, report) {
2219
2407
  try{recordAgentRun(board.root,session.agent_org,session.agent_id,{session:ref,machine:session.machine,run:row.id});}
2220
2408
  catch(error){report.queued.push([row.task_id,`run ${row.id}: ${error.message}`]);continue;}
2221
2409
  }
2410
+ await keepAgentCredentialsCurrent(board,live,report);
2222
2411
  // G3: an ended run's session usage, read once from its harness's own records on its machine
2223
2412
  for(const org of new Set(agentRuns.map(row=>json(row.metadata,{})?.supercode?.agent_org).filter(Boolean))){
2224
2413
  let read=0;
@@ -2257,6 +2446,9 @@ async function reconcileLaunches(board, fleet, report, entry, effects, settings,
2257
2446
  const task=getTaskNow(board,id),status=view(board,db=>statusOf(db,task)),spec=runSpec(entry,status,task);
2258
2447
  const profile=spec&&view(board,db=>evaluate(entry.workflow.roles?.[spec.role]??'card.assignee',scopeOf(db,board,task,{params:entry.params})));
2259
2448
  const lane=lanes?.[profile];if(!lane){report.queued.push([id,'launch waits for its configured lane']);continue;}
2449
+ // a launch bound to a machine whose record is not current was not tried either: its clock moves with the wait too
2450
+ const host=lane.surface==='pane'&&task.machine?executionHost(lane,task.machine,localMachine(),normalMachine):null;
2451
+ if(host&&fleet.unknown?.has(host)){tx(board,db=>{if(stillDeferred(db))setRunSession(db,run_id,{launched_at:now()});});report.queued.push([id,`launch waits for machine ${host}'s fleet record: ${fleet.errors?.get(host)??'no current record'}`]);continue;}
2260
2452
  const why=launcherCheck(board.db,task,lane,fleet);if(why){report.queued.push([id,why]);continue;}
2261
2453
  const claimed=tx(board,db=>{if(!stillDeferred(db))return false;setRunSession(db,run_id,{launch_deferred:false});return true;});
2262
2454
  if(!claimed){report.queued.push([id,'its launch was taken by another round']);continue;}
@@ -2325,6 +2517,146 @@ async function reconcileLaunches(board, fleet, report, entry, effects, settings,
2325
2517
  }
2326
2518
  }
2327
2519
 
2520
+ // An agent's own Workplace credentials (its records in this home's apps/, installed-apps.mjs) reach each other machine
2521
+ // where a session of it runs (sdk/teams/workplace/agent-credentials.mjs keepAgentCredentialsHere), into a home there of
2522
+ // this board's own (its scope: sha256 of JSON [this machine, the real home, the slug]), so one board never keeps or
2523
+ // removes what another handed. They are handed over the board's operator channel before a pane opens, and again by the
2524
+ // delivery pass whenever they change (a renewal, a revocation) or half their lease is gone while a run of the agent is
2525
+ // live there; a machine where none is live any more is told to keep none.
2526
+ //
2527
+ // What the board handed each machine is kept in its own store (supercode_board_state `agent_credentials_handed`, per
2528
+ // machine and agent): the scope it was kept under and the home that answered, older scopes still to be emptied
2529
+ // (`stale`), a digest of the records without their tokens, the lease's end, the run and card it was handed for, and the
2530
+ // attempt that wrote it. Re-keeps and removals go to the stored scope, never one computed again (a machine name read
2531
+ // from the host name, a renamed machine or a home named through a link computes another); a launch whose scope differs
2532
+ // empties the stored one first. An entry is dropped only once the machine answered keeping none and nothing wrote it
2533
+ // meanwhile (compare-and-set on `attempt`), so an offline machine, a lost answer, a restarted board, a separate
2534
+ // `workflow deliver` or a concurrent pass is told again. Archiving or deleting a board drains its entries first
2535
+ // (drainAgentCredentials). And whatever the board forgets (an archived board's leftovers, a board switched off, a
2536
+ // store lost), each copy is a lease the receiving machine removes itself once it passes.
2537
+ const HANDED = 'agent_credentials_handed';
2538
+ const credentialsKey = (machine, agent) => `${normalMachine(machine)}|${agent}`;
2539
+ const credentialsDigest = (records) => createHash('sha256').update(JSON.stringify(records.map(({ token, ...rest }) => rest))).digest('hex');
2540
+ const realRoot = (root) => { try { return realpathSync(root); } catch { return root; } };
2541
+ const credentialsScope = (board) => createHash('sha256').update(JSON.stringify([localMachine(), realRoot(board.root), board.slug])).digest('hex');
2542
+ const handedOf = (db) => json(db.prepare('SELECT value FROM supercode_board_state WHERE key=?').get(HANDED)?.value, {}) ?? {};
2543
+ /** Change one entry of the handed set: `change(entry or null)` answers its next value, or null to drop it. */
2544
+ function setHanded(board, key, change) {
2545
+ tx(board, (db) => {
2546
+ const handed = handedOf(db), value = change(handed[key] ?? null);
2547
+ if (value) handed[key] = value; else delete handed[key];
2548
+ db.prepare('INSERT INTO supercode_board_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(HANDED, JSON.stringify(handed));
2549
+ });
2550
+ }
2551
+ const runOpen = (board, run) => run != null && Boolean(view(board, (db) => db.prepare('SELECT 1 FROM task_runs WHERE id=? AND ended_at IS NULL').get(run)));
2552
+ /** Whether a run's agent wants its credentials on the run's other machine: the run is live (bound yet or not), or it
2553
+ * was detached to a person and its session still runs there (or the fleet cannot say it does not). */
2554
+ function credentialsWanted(board, row, session, fleet) {
2555
+ if (!session?.agent_id || !session.machine || normalMachine(session.machine) === localMachine()) return false;
2556
+ if (!row.ended_at) return true;
2557
+ if (!view(board, (db) => detachedSession(db, session) && personCustody(db, addressOf(session)))) return false;
2558
+ const address = addressOf(session);
2559
+ if (!fleet?.ok || fleet.unknown?.has(normalMachine(session.machine))) return true;
2560
+ return Boolean(address && (fleet.alive?.has(address) || (fleet.native?.has(address) && fleet.native.get(address).alive !== false)));
2561
+ }
2562
+ const UNREACHABLE = new Set(['machine_offline', 'REQUEST_TIMEOUT', 'unavailable', 504, '504', 'ETIMEDOUT', 'ECONNRESET']);
2563
+ /** One keep (records) or removal (none) in the home of `scope` on `machine`: `{ home, leaseUntil }`, or `{ why }`. A
2564
+ * machine `unreachable` already holds this pass is not asked again in it. */
2565
+ async function credentialsDoor(board, machine, scope, agent, records, unreachable) {
2566
+ if (unreachable?.has(normalMachine(machine))) return { why: `machine ${machine} did not answer earlier in this pass` };
2567
+ const r = await doorOn(machine, 'harness.v1.agent.credentials.keep', { scope, agent, records }, { timeout: 30_000, as: boardAddress(board.label) });
2568
+ const kept = r.code === 0 ? json(r.stdout.trim(), null) : null;
2569
+ if (typeof kept?.home === 'string') return kept;
2570
+ const refusal = json(String(r.stderr ?? '').trim().split('\n').pop(), null);
2571
+ if (UNREACHABLE.has(refusal?.code)) unreachable?.add(normalMachine(machine));
2572
+ return { why: (r.stderr || r.stdout).trim().slice(-300) || 'the machine named no agent home' };
2573
+ }
2574
+ /** Empty the stale scopes of an entry; each emptied one leaves the list (and, should it be the entry's scope again,
2575
+ * marks the entry to be kept again: the removal may have landed after that keep). Answers the refusals. */
2576
+ async function emptyStale(board, key, machine, agent, held, unreachable) {
2577
+ const refused = [];
2578
+ for (const scope of held?.stale ?? []) {
2579
+ const done = await credentialsDoor(board, machine, scope, agent, [], unreachable);
2580
+ if (done.why) { refused.push(done.why); continue; }
2581
+ setHanded(board, key, (entry) => entry && ({ ...entry, stale: (entry.stale ?? []).filter((each) => each !== scope),
2582
+ ...(entry.scope === scope ? { digest: null, attempt: randomUUID() } : {}) }));
2583
+ }
2584
+ return refused;
2585
+ }
2586
+ /** Keep `agent`'s records in this board's home for it on `machine` (the stored scope; a launch's computed one, the stored
2587
+ * emptied first): `{ home }`, or `{ why }`. */
2588
+ async function keepAgentCredentialsOn(board, machine, agent, { records = installedAgentRecords(board.root, agent), run = null, task = null, launch = false, unreachable = null } = {}) {
2589
+ const key = credentialsKey(machine, agent), attempt = randomUUID();
2590
+ const held = view(board, handedOf)[key] ?? null;
2591
+ const scope = launch || !held?.scope ? credentialsScope(board) : held.scope;
2592
+ // Recorded before it is asked: an unanswered keep is still the board's to undo, at this scope.
2593
+ setHanded(board, key, (entry) => ({ ...(entry ?? {}), scope, home: entry?.scope === scope ? entry.home ?? null : null, digest: null, run, task, attempt,
2594
+ stale: [...new Set([...(entry?.stale ?? []), ...(entry?.scope && entry.scope !== scope ? [entry.scope] : [])])].filter((each) => each !== scope) }));
2595
+ await emptyStale(board, key, machine, agent, view(board, handedOf)[key], unreachable);
2596
+ const kept = await credentialsDoor(board, machine, scope, agent, records, unreachable);
2597
+ if (kept.why) return kept;
2598
+ setHanded(board, key, (entry) => entry?.attempt === attempt
2599
+ ? { ...entry, home: kept.home, digest: credentialsDigest(records), keptAt: new Date().toISOString(), leaseUntil: kept.leaseUntil ?? null } : entry);
2600
+ return { home: kept.home };
2601
+ }
2602
+ /** Tell `machine` to keep none of `agent`'s records for this board, at every scope the entry names; the entry is dropped
2603
+ * only when nothing wrote it since `held` was read, else marked to be decided again. Answers the refusals. */
2604
+ async function removeAgentCredentialsOn(board, key, held, unreachable) {
2605
+ const [machine, agent] = key.split('|');
2606
+ const refused = await emptyStale(board, key, machine, agent, held, unreachable);
2607
+ if (held?.scope) {
2608
+ const done = await credentialsDoor(board, machine, held.scope, agent, [], unreachable);
2609
+ if (done.why) return [...refused, done.why];
2610
+ }
2611
+ setHanded(board, key, (entry) => {
2612
+ if (!entry) return null;
2613
+ if (entry.attempt !== held?.attempt) return { ...entry, digest: null, attempt: randomUUID() };
2614
+ const rest = { ...entry, scope: null, home: null, digest: null, stale: entry.stale ?? [] };
2615
+ return rest.stale.length ? rest : null;
2616
+ });
2617
+ return refused;
2618
+ }
2619
+ /** Whether a kept entry is due again: its records changed, it holds none yet, or half its lease is gone. */
2620
+ function credentialsDue(held, records, now = Date.now()) {
2621
+ if (!held?.scope || held.digest !== credentialsDigest(records) || held.stale?.length) return true;
2622
+ const kept = Date.parse(held.keptAt), until = Date.parse(held.leaseUntil);
2623
+ return !(Number.isFinite(kept) && Number.isFinite(until) && now < kept + (until - kept) / 2);
2624
+ }
2625
+ /** The delivery pass's half: `live` maps each (machine, agent) with a run there that wants its credentials to that run. */
2626
+ async function keepAgentCredentialsCurrent(board, live, report) {
2627
+ const unreachable = new Set();
2628
+ for (const key of new Set([...live.keys(), ...Object.keys(view(board, handedOf))])) {
2629
+ const [machine, agent] = key.split('|');
2630
+ // read again for each: a launch or another pass may have written it since this pass began
2631
+ const held = view(board, handedOf)[key] ?? null, wanted = live.get(key) ?? (runOpen(board, held?.run) ? { run: held.run, task: held.task ?? null } : null);
2632
+ const task = wanted?.task ?? held?.task ?? null;
2633
+ if (wanted) {
2634
+ const records = installedAgentRecords(board.root, agent);
2635
+ if (!credentialsDue(held, records)) continue;
2636
+ const kept = await keepAgentCredentialsOn(board, machine, agent, { records, run: wanted.run, task, unreachable });
2637
+ if (kept.why) report.queued.push([task, `agent ${agent}'s own Workplace credentials could not be kept on ${machine}: ${kept.why}`]);
2638
+ } else {
2639
+ const refused = await removeAgentCredentialsOn(board, key, held, unreachable);
2640
+ if (refused.length) report.queued.push([task, `agent ${agent}'s own Workplace credentials could not be removed from ${machine} (its lease ends them by ${held?.leaseUntil ?? 'a day after their last keep'}): ${refused[0]}`]);
2641
+ }
2642
+ }
2643
+ }
2644
+ /** Before a board is archived or deleted: every machine it handed an agent's records is told to keep none. Answers what
2645
+ * could not be removed now (the lease on that machine ends it). */
2646
+ export async function drainAgentCredentials(root, slug) {
2647
+ await knowLocalMachine();
2648
+ const board = openBoard(root, slug);
2649
+ try {
2650
+ board.label = boardLabel(root, slug);
2651
+ const left = [], unreachable = new Set();
2652
+ for (const [key, held] of Object.entries(view(board, handedOf))) {
2653
+ const refused = await removeAgentCredentialsOn(board, key, held, unreachable);
2654
+ if (refused.length) left.push({ machine: key.split('|')[0], agent: key.split('|')[1], leaseUntil: held?.leaseUntil ?? null, reason: refused[0] });
2655
+ }
2656
+ return left;
2657
+ } finally { board.close(); }
2658
+ }
2659
+
2328
2660
  async function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'worker', meta = {}, orchestration } = {}) {
2329
2661
  const machine = executionHost(lane, task.machine, localMachine(), normalMachine);
2330
2662
  // Whether a board session opens a terminal window on its machine is the workflow's choice, stated on every launch;
@@ -2344,6 +2676,14 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2344
2676
  if (lane.harness === 'codex' && lane.reasoning_effort) tail.push('-c', `model_reasoning_effort=${JSON.stringify(lane.reasoning_effort)}`);
2345
2677
  const harness = HARNESSES[lane.harness] ? lane.harness : 'claude-code';
2346
2678
  const how = HARNESSES[harness];
2679
+ // A pane on another machine finds its agent's own Workplace credentials in the agent's home there, which its
2680
+ // SUPERCODE_AGENT_HOME names (the rh2 CLI reads it per call); a machine that could not keep them is said on the card,
2681
+ // and its pane opens as before, acting on no Workplace (never on a person's key).
2682
+ if (meta.agent_id && machine !== localMachine()) {
2683
+ const kept = await keepAgentCredentialsOn(board, machine, meta.agent_id, { run: runId, task: task.id, launch: true });
2684
+ if (kept.home) meta.agent_env = { ...meta.agent_env, SUPERCODE_AGENT_HOME: kept.home };
2685
+ else runTx(board, task.id, runId, (db) => event(db, task.id, 'agent_credentials_unkept', { machine, reason: kept.why }, runId));
2686
+ }
2347
2687
  // A card on this machine opens through this machine's own daemon; only another machine is reached with `--on`.
2348
2688
  const base = ['open', '--new', how.program, '--detach', `--window=${window}`, ...(machine === localMachine() ? [] : ['--on', task.machine]), '--cwd', task.workspace_path, '--env', 'SUPERCODE_BOARD_PROCESS_GUARD=1',
2349
2689
  ...Object.entries(meta.agent_env??{}).flatMap(([key,value])=>['--env',`${key}=${value}`])];
@@ -2369,6 +2709,9 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2369
2709
  // the round's own fleet reading: a launch never reads the fleet again
2370
2710
  const fresh = fleet;
2371
2711
  if (!fresh.ok) throw unavailable(`cannot check for a manual continuation of ${prev.session_id}: ${fresh.error}`);
2712
+ // a machine whose record is not current lists no session (continuationPane reads fleet.sessions): no continuation
2713
+ // can be ruled out there, so nothing is resumed beside one
2714
+ if (fresh.unknown?.has(normalMachine(machine))) throw unavailable(`cannot check for a manual continuation of ${prev.session_id}: machine ${machine}'s fleet record is not current: ${fresh.errors?.get(normalMachine(machine)) ?? 'no current record'}`);
2372
2715
  const r = await doorOn(machine, 'harness.v1.panes.list',{}, { timeout: 60_000 });
2373
2716
  const listed = r.code === 0 ? json(r.stdout.trim(), null) : null;
2374
2717
  if (!Array.isArray(listed)) throw unavailable(`cannot read panes before continuing ${prev.session_id}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
@@ -2467,7 +2810,14 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2467
2810
  fleet.native ??= new Map(); fleet.native.set(addressOf(prev), reading);
2468
2811
  if (reading.alive && !prev.released && (prev.closed_at || prev.close_requested || prev.forgotten)) throw unavailable(`native session ${addressOf(prev)} is still live; its close must finish before replacement`);
2469
2812
  }
2470
- if (prev?.forgotten) {
2813
+ // A forgotten session the board released (an adopted one, left running) is still the arc's while it runs: only the
2814
+ // manager's explicit replace_session lets a live one go. Every other forget (a retarget recorded before openCoder kept
2815
+ // the coder) leaves it kept below, as any released session is, and the run is delivered into it; never a fresh
2816
+ // implementer beside it (VO-3755 t_c2d5d21b run 2736). One that is gone is replaced as before.
2817
+ const liveForgotten = prev?.forgotten && prev.released && prev.machine === machine && (prev.harness ?? 'claude-code') === harness
2818
+ && !view(board, (db) => Number(latestEvent(db, task.id, ['session_forgotten'], 'created_at')?.created_at ?? 0) >= Number(prev.forgotten))
2819
+ && (prev.pane ? await panePresent(prev.machine, prev.pane) : sessionAlive(fleet, prev)) === true;
2820
+ if (prev?.forgotten && !liveForgotten) {
2471
2821
  if (!prev.close_confirmed_at && prev.pane && await panePresent(prev.machine, prev.pane)) throw new Error(`forgotten session ${prev.session_id} still has live pane ${prev.pane}; close it before replacement`);
2472
2822
  replaced = { session_id: prev.session_id, why: 'the workflow let it go' };
2473
2823
  }
@@ -2509,12 +2859,13 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2509
2859
  if (next === 'refuse') throw new Error(`session ${prev.session_id} still has live pane ${prev.pane}; cannot resume or replace it`);
2510
2860
  if (next === 'keep') {
2511
2861
  // the session is kept; the heartbeat's count belongs to the run that ended, so the new run starts it again
2512
- const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _is, idle_notice: _in, unknown_since: _us, unknown_notice: _un, work_progress: _wp, idle_observation: _io, idle_below_threshold: _ib, idle_eligible: _ie, close_requested: _q, closed_at: _c, close_confirmed_at: _cc, close_error: _ce, close_attempts: _ca, close_attempted_at: _ct, close_failed_at: _cf, close_unread: _cu, close_claim: _cl, monitor_held: _mh, released: _rl, review_held: _rh, retain_after_run: _rr, ...kept } = prev;
2862
+ const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _is, idle_notice: _in, unknown_since: _us, unknown_notice: _un, work_progress: _wp, idle_observation: _io, idle_below_threshold: _ib, idle_eligible: _ie, close_requested: _q, closed_at: _c, close_confirmed_at: _cc, close_error: _ce, close_attempts: _ca, close_attempted_at: _ct, close_failed_at: _cf, close_unread: _cu, close_claim: _cl, monitor_held: _mh, released: _rl, review_held: _rh, retain_after_run: _rr, handed_back_from: _hf, handed_back_run: _hr, forgotten: _fg, resumable: _rs, ...kept } = prev;
2513
2863
  session = { ...kept, mode: 'adopt', launched_at: now(), seen_at: now() };
2514
2864
  // The common binding below transfers ownership and subscriptions before filing the continuation prompt.
2515
2865
  } else if (prev.resumable !== false && await nativeAbsent(prev)) {
2516
- // nothing to resume and no context to keep: the slot's next session starts fresh
2517
- replaced = { session_id: prev.session_id, why: `${prev.machine} holds no ${prev.harness ?? 'claude-code'} session of that id (it never took a first turn)`, letGo: true };
2866
+ // its transcript is gone (never written, or removed): nothing to resume and no context to keep, so the slot's next
2867
+ // session starts fresh, and the run's session_replaced event says why
2868
+ replaced = { session_id: prev.session_id, why: `its transcript is gone: ${prev.machine}'s complete ${prev.harness ?? 'claude-code'} discovery lists no session of that id`, letGo: true };
2518
2869
  } else if (prev.resumable !== false) {
2519
2870
  const authorization = view(board, db => currentRun(db,task.id)?.metadata?.supercode?.restart_authorized);
2520
2871
  const released = view(board,db=>{
@@ -2522,11 +2873,12 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2522
2873
  return last?.kind === 'dispatch_released';
2523
2874
  });
2524
2875
  // Only a session whose run ended in a monitored incident waits for its manager or creator; one a workflow
2525
- // transition closed (a reviewer after its verdict, an implementer whose card came back) is the slot's
2526
- // deliberate handoff, resumed for the slot's next run like any other
2876
+ // transition closed (a reviewer after its verdict, an implementer whose card came back) is the slot's deliberate
2877
+ // handoff, resumed for the slot's next run: a card has one session per slot, and every round of that slot runs in
2878
+ // it with the context of the rounds before
2527
2879
  if (monitorsRuns(workflowFor(board.root)) && prev.monitor_held && !authorization && !released) throw new Error(`session ${prev.session_id} is stopped by incident ${prev.incident ?? 'unknown'}; only its manager or creator may restart it with workflow start`);
2528
2880
  // a resumed session is told what this run is for, as a kept one is: the run's own prompt (a reviewer resumed for
2529
- // its next round gets that round's prompt, its arc's context with it), not only that it was resumed
2881
+ // its next round gets that round's brief and PR heads), not only that it was resumed
2530
2882
  const told = input ? `The board resumed this session for this run of card ${task.id}. ${input}` : `The board resumed this session after it was lost; you are still on card ${task.id}: ${task.title}. Carry on.`;
2531
2883
  // a session is resumed in its own harness, whatever the lane's is now
2532
2884
  const its = HARNESSES[prev.harness ?? 'claude-code'] ?? HARNESSES['claude-code'];
@@ -2547,15 +2899,16 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2547
2899
  if (!session) {
2548
2900
  const sid = how.namesItself ? null : randomUUID();
2549
2901
  const launchKey = `board-${task.id}-r${runId}-${(sid ?? randomUUID()).slice(0,8)}`;
2550
- // A fresh session is named for its card (`vo-856-3f`; a reviewer's `vo-856-reviewer-3f`), where its harness takes a
2551
- // name, with the start of its id after it as the name's own suffix; a kept or resumed session keeps its name.
2552
- const handle = cardHandleOf(board, task.id), ref = handle?.toUpperCase();
2553
- const name = handle ? `${handle}${slot === 'worker' ? '' : `-${slot}`}-${(sid ?? randomUUID()).slice(0, 2)}` : null;
2902
+ // A fresh session is named for its card by its readable id (`voice-calls-3f`; a reviewer's `voice-calls-reviewer-3f`),
2903
+ // else its handle (`vo-856-3f`), where its harness takes a name, with the start of its id after it as the name's own
2904
+ // suffix; a kept or resumed session keeps its name.
2905
+ const handle = cardHandleOf(board, task.id), readable = readableIdOf(board, task.id), stem = readable ?? handle;
2906
+ const name = stem ? `${stem}${slot === 'worker' ? '' : `-${slot}`}-${(sid ?? randomUUID()).slice(0, 2)}` : null;
2554
2907
  const named = name && NAME_ARGUMENTS[harness] ? NAME_ARGUMENTS[harness](name) : [];
2555
2908
  // Codex names the thread after its native first-input receipt identifies it; Teams uses thread/name/set on
2556
2909
  // this launch's own app-server, then reads the name back. Continuations receive no naming hint.
2557
2910
  session = await openOwned(launchKey, sid, 'fresh', [...base, ...(name && harness === 'codex' ? ['--env', `SUPERCODE_SESSION_NAME=${name}`] : []), '--key', launchKey,
2558
- '--input', input ?? `Card ${ref ?? task.id}${ref ? ` (${task.id})` : ''}: ${task.title}`, '--', ...(sid ? how.fresh(sid) : how.fresh()), ...named, ...tail]);
2911
+ '--input', input ?? `Card ${cardLabel(boardKey(board.root, board.slug), cardNumber(board.db, task.id), task.id, readable)}: ${task.title}`, '--', ...(sid ? how.fresh(sid) : how.fresh()), ...named, ...tail]);
2559
2912
  if (replaced) session.replaces = replaced.session_id;
2560
2913
  }
2561
2914
  session.harness = session.harness ?? harness;
@@ -2702,7 +3055,13 @@ function rotateLog(file, max, backups) {
2702
3055
  /** A headless card's directory on this machine, made as Hermes makes it (scratch, dir, or a git worktree). */
2703
3056
  async function resolveLocalWorkspace(board, task) {
2704
3057
  const root = board.root;
2705
- if (task.workspace_kind !== 'scratch') return (await resolveWorkspace({ ...task, handle: cardHandleOf(board, task.id) }, true)).path;
3058
+ if (task.workspace_kind !== 'scratch') {
3059
+ // A branch the board named for the card is claimed until its worktree is made, and the making is recorded once.
3060
+ const facts = view(board, () => workspaceTask(board, task, { claims: true })), claim = facts.branch_claim;
3061
+ const made = await resolveWorkspace(facts, true);
3062
+ if (claim) tx(board, (db) => { if (branchClaim(db, task.id, task.branch_name)) event(db, task.id, 'worktree_made', { branch: task.branch_name, repository: task.workspace_anchor ?? null, path: made.path }); });
3063
+ return made.path;
3064
+ }
2706
3065
  const dir = task.workspace_path || (board.slug === 'default' ? join(root, 'kanban', 'workspaces', task.id) : join(root, 'kanban', 'boards', board.slug, 'workspaces', task.id));
2707
3066
  mkdirSync(dir, { recursive: true });
2708
3067
  return dir;
@@ -2726,10 +3085,46 @@ export function requestAdoption(board, id, who, { workspace = null, assignee = n
2726
3085
  if (boardCopyOf(board)) throw copyRefusal(board, 'adopts no session');
2727
3086
  tx(board, db => {
2728
3087
  if (!getTask(db, id)) throw new Error(`no card ${id}`);
3088
+ // a session named by its address or id is checked now; one named otherwise is checked when it is bound
3089
+ const named = /^sc:[^:]+:[^:]+:(.+)$/.exec(String(who))?.[1] ?? (/^[0-9a-f-]{8,}$/i.test(String(who)) ? String(who) : null);
3090
+ const why = coderAsReviewer(db, board, id, { sessionId: named, assignee, role });
3091
+ if (why) throw new Error(coderAsReviewerRefusal(id, statusOf(db, getTask(db, id)), why));
2729
3092
  event(db, id, 'dispatch_held', { session: who, reason: 'adoption requested', workspace, assignee, role });
2730
3093
  });
2731
3094
  }
2732
3095
 
3096
+ /**
3097
+ * Why adopting a session onto card `id` would make the card's own coder its reviewer, or null. A session adopted onto a
3098
+ * card whose run is its reviewer's (a card in review) is bound as that reviewer, and a reviewer is never the card's own
3099
+ * coder (ADR 0001 rule 8; ADR 0019: an implementer adopted as reviewer is refused). Its coder is a worker-slot session of
3100
+ * any of its runs or one of its side sessions (`sessionId`), a profile any of its worker runs ran as or its implementer
3101
+ * (`assignee`), or a request for another role (`role`). The adoption is refused, never turned into a return: taking a
3102
+ * card out of review is the manager's own act (`return-review`), recorded with its reason.
3103
+ */
3104
+ function coderAsReviewer(db, board, id, { sessionId = null, assignee = null, role = null, assigneeBefore = undefined } = {}) {
3105
+ const card = getTask(db, id);
3106
+ if (!card) return null;
3107
+ // the card's assignee before this adoption wrote its own (a bind writes --assignee first)
3108
+ const held = assigneeBefore === undefined ? card.assignee : assigneeBefore;
3109
+ const entry = workflowFor(board.root);
3110
+ const bound = currentRun(db, id)?.metadata?.supercode;
3111
+ const slot = bound?.slot ?? String(templateValue(runSpec(entry, statusOf(db, card), card)?.session ?? 'worker', scopeOf(db, board, card, { params: entry.params })));
3112
+ if (slot !== 'reviewer') return null;
3113
+ if (role && role !== 'reviewer') return `the adoption asks for its ${role} role`;
3114
+ if (sessionId && ownCoder(db, id, sessionId)) return `${sessionId} is its own coder (a worker or side session of ${id})`;
3115
+ if (assignee) {
3116
+ const coders = new Set(db.prepare(`SELECT DISTINCT profile FROM task_runs WHERE task_id = ? AND profile IS NOT NULL
3117
+ AND COALESCE(json_extract(metadata, '$.supercode.slot'), 'worker') = 'worker'`).all(id).map((r) => r.profile));
3118
+ const implementer = scopeOf(db, board, card, { params: entry.params, lazy: true }).card.implementer;
3119
+ // the implementer a review request recorded (with none recorded, card.implementer is the card's own assignee)
3120
+ if (coders.has(assignee) || (assignee === implementer && implementer !== held)) return `--assignee ${assignee} is its coder's profile`;
3121
+ }
3122
+ return null;
3123
+ }
3124
+ const coderAsReviewerRefusal = (id, status, why) => `${id} is ${status}, where an adopted session is bound as its reviewer, and ${why}. `
3125
+ + `A reviewer is never the card's own coder. To put its coder back to work, return the card first `
3126
+ + `(supercode workflow return-review ${id} --reason "<why>"), then adopt the session.`;
3127
+
2733
3128
  /** How long a requested adoption waits for its session to appear in the fleet before it fails. */
2734
3129
  const ADOPTION_LISTING_WINDOW_S = 180;
2735
3130
 
@@ -2746,7 +3141,7 @@ const latestAnswer = (db, id) => db.prepare(`SELECT id, kind, payload FROM task_
2746
3141
  * of two rounds that read one request, one binds it and the other leaves it; the bind and the failure are each written
2747
3142
  * only while that claim is the card's latest.
2748
3143
  */
2749
- export async function completeAdoptions(board, fleet, settings, report, lanes = null) {
3144
+ export async function completeAdoptions(board, fleet, settings, report) {
2750
3145
  const pending = view(board, db => db.prepare(`SELECT e.id, e.task_id, e.kind, e.payload, e.created_at
2751
3146
  FROM (SELECT DISTINCT task_id FROM task_events) cards CROSS JOIN task_events e ON e.task_id=cards.task_id
2752
3147
  WHERE e.kind IN ('dispatch_held','adoption_claimed')
@@ -2764,7 +3159,7 @@ export async function completeAdoptions(board, fleet, settings, report, lanes =
2764
3159
  const asked = json(request.payload, {}) ?? {};
2765
3160
  const progress = {};
2766
3161
  try {
2767
- const got = await adoptSession(board, row.task_id, asked.session, { request: request.id, open: row.id, progress, workspace: asked.workspace ?? null, assignee: asked.assignee ?? null, role: asked.role ?? null, ttl: settings.ttl, fleet: fleet?.ok ? fleet : null , lanes});
3162
+ const got = await adoptSession(board, row.task_id, asked.session, { request: request.id, open: row.id, progress, workspace: asked.workspace ?? null, assignee: asked.assignee ?? null, role: asked.role ?? null, ttl: settings.ttl, fleet: fleet?.ok ? fleet : null});
2768
3163
  (report.adopted ??= []).push([row.task_id, got.address]);
2769
3164
  } catch (error) {
2770
3165
  const message = String(error.message).slice(-400);
@@ -2781,7 +3176,7 @@ export async function completeAdoptions(board, fleet, settings, report, lanes =
2781
3176
  continue;
2782
3177
  }
2783
3178
  // the failure answers the request only while it is still open, or claimed by this round: an adoption that bound and
2784
- // then failed after (its agent run not recorded) is bound, and a request another round claimed is that round's
3179
+ // then failed after is bound, and a request another round claimed is that round's
2785
3180
  // A failed adoption answers its request and ends the hold the request put on the card: the card is held again
2786
3181
  // only if it was held before the request (an incident's hold, kept for its own release), and is otherwise the
2787
3182
  // dispatcher's again. A hold that outlived its failed request held cards nobody adopted until a person released them.
@@ -2792,7 +3187,7 @@ export async function completeAdoptions(board, fleet, settings, report, lanes =
2792
3187
  return true;
2793
3188
  });
2794
3189
  // A request another round answered or claimed first is that round's, not a failure of this one: it is noted where
2795
- // no round prints it. A failure after this round's own claim (bound, then its agent run not recorded) is one.
3190
+ // no round prints it. A failure after this round's own claim is one.
2796
3191
  if (!answered && !progress.claimed) { (report.adoption_skipped ??= []).push([row.task_id, message]); continue; }
2797
3192
  report.failed.push([row.task_id, `adoption of ${asked.session}${answered ? '' : ' (bound by this round, then failed)'}: ${message}`]);
2798
3193
  }
@@ -2834,25 +3229,19 @@ async function nativeListing(who) {
2834
3229
  /** The verb an adoption with no current run fires: the workflow's own `adopt` where it declares one, else its dispatch start. */
2835
3230
  const adoptionVerb = (entry) => (entry.workflow.verbs?.adopt || !entry.workflow.run ? 'adopt' : entry.workflow.dispatch.start);
2836
3231
 
2837
- /**
2838
- * The role an adopted session works on its card: a rebound run's own, else the role of the status the adoption's verb
2839
- * moves the card to, as its claim names it. A card in review adopts its reviewer, never its implementer.
2840
- */
2841
- function adoptedRole(db, board, entry, task, run) {
2842
- const kept = run?.metadata?.supercode?.role;
2843
- if (kept) return kept;
2844
- const verb = adoptionVerb(entry), by = verb === 'adopt' ? 'manager' : 'platform';
2845
- const status = statusOf(db, task), scope = scopeOf(db, board, task, { params: entry.params });
2846
- const t = transitionsFor(entry.workflow, status, verb).find((x) => !x.by?.length || x.by.includes(by));
2847
- return runSpec(entry, t?.target ? String(templateValue(t.target, scope)) : status, task)?.role ?? null;
2848
- }
2849
-
2850
- async function adoptSession(board, id, who, { request = null, open = request, progress = {}, workspace = null, ttl, assignee = null, role = null, fleet = null, lanes = null } = {}) {
3232
+ async function adoptSession(board, id, who, { request = null, open = request, progress = {}, workspace = null, ttl, assignee = null, role = null, fleet = null } = {}) {
2851
3233
  fleet ??= await observeFleet();
2852
3234
  // a fleet that could not be read says nothing of the session: the request stays open for the next reading
2853
3235
  if (!fleet.ok) throw Object.assign(new Error(`supercode could not read the fleet: ${fleet.error}`), { unread: true });
2854
- const found = fleet.sessions.find((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who) ?? await nativeListing(who);
2855
- if (!found) throw Object.assign(new Error(`the fleet lists no live session ${who}`), { notListed: true });
3236
+ // A session no current machine lists may be one of a machine whose record is not current: the one its address names,
3237
+ // or any, for a bare name. Its native listing is not taken there (it knows no pane, and the session may run in one).
3238
+ const named = /^sc:([^:]+):/.exec(String(who))?.[1], machines = named ? [normalMachine(named)].filter((machine) => fleet.unknown?.has(machine)) : [...(fleet.unknown ?? [])];
3239
+ const found = fleet.sessions.find((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who) ?? (named && machines.length ? null : await nativeListing(who));
3240
+ if (!found) {
3241
+ // said, and waited on as an unlisted session is, within the listing window
3242
+ const unread = machines.map((machine) => `machine ${machine}'s record is not current: ${fleet.errors?.get(machine) ?? 'no current record'}`).join('; ');
3243
+ throw Object.assign(new Error(`the fleet lists no live session ${who}${unread ? ` (${unread})` : ''}`), { notListed: true });
3244
+ }
2856
3245
  const [, host, harness, sessionId] = found.address.split(':');
2857
3246
  if (!HARNESSES[harness]) throw new Error(`${found.address} is ${harness}; the board keeps and resumes ${Object.keys(HARNESSES).join(' and ')} sessions`);
2858
3247
  const native = found.pane ? null : nativeReading(await readNative(host, harness, sessionId), { session_id: sessionId, harness });
@@ -2884,14 +3273,9 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
2884
3273
  const r = await runtimeDoor(normalMachine(host)===localMachine()?null:host,'harness.v1.panes.list',{}, { timeout: 60_000 });
2885
3274
  dir = (json(r.stdout.trim(), []) ?? []).find((p) => p.pane === found.pane)?.cwd ?? null;
2886
3275
  }
2887
- // Rule 2: a session working a card role is that role's agent, however it started; adopted, it joins the agent as a
2888
- // launch does, and its recorded profile is compared with the role's intent (rule 4).
2889
- const agentRole = view(board, (db) => adoptedRole(db, board, entry0, card0, beforeRun)) ?? 'implementer';
2890
- const laneName = view(board, (db) => evaluate(entry0.workflow.roles?.[agentRole] ?? 'card.assignee', scopeOf(db, board, card0, { params: entry0.params }))) ?? assignee ?? card0?.assignee;
2891
- const lane = laneName ? lanes?.[laneName] : null, context = current().facts.SUPERCODE_CONTEXT, team = agentScope(context);
2892
- // The request is claimed before this adoption's first write outside the board (the agent store, below), in
2893
- // a transaction that finds it still open and the card's run the one read: a request answered or claimed meanwhile, or
2894
- // a run that changed, ends it here with nothing written anywhere, and no other round acts on a request claimed here.
3276
+ // Agents are started, not attached (owner, VO-3751). Adoption records card work only: the session
3277
+ // keeps its original identity, profile, hooks, MCP and folder, including when another card's agent owns it.
3278
+ // Claim the request before reading its slot again, so another round cannot bind the same request meanwhile.
2895
3279
  const stale = (db) => {
2896
3280
  if (request !== null && !adoptionOpenTo(db, id, request, open)) return Object.assign(new Error(`${id}'s adoption request was answered or claimed meanwhile`), { superseded: true });
2897
3281
  if ((getTask(db, id)?.current_run_id ?? null) !== (beforeRun?.id ?? null)) return new Error(`${id} changed runs during adoption; retry against its current run`);
@@ -2905,14 +3289,8 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
2905
3289
  if (refused) throw refused;
2906
3290
  event(db, id, 'adoption_claimed', { request, session: who, ...newClaim(ttl) });
2907
3291
  });
3292
+ if (request !== null) noteClaim(board, 'cards', id);
2908
3293
  progress.claimed = request !== null;
2909
- // The adopted session joins the role's agent as a launch does: its identity minted on this host, never asked of Teams.
2910
- const agent = lane ? await localCardAgent({ root: board.root, handle: cardHandleOf(board, id), readableId: readableIdOf(board, id), org: team.org, server: team.server, profiles: entry0.profiles, profile: lane.name, card: id, role: agentRole}) : null;
2911
- if(agent)recordCardAgentAssignment(board,cardAgentAssignments(board.root,id).find(assignment=>assignment.org===team.org&&assignment.id===agent.assignment));
2912
- // A session another agent already owns is refused before anything is recorded: the run would name one agent and the
2913
- // agent store another, and every round after would trip on it.
2914
- const owner = agent ? agentRunForSession(board.root, [`${harness}:${sessionId}`]) : null;
2915
- if (owner && owner.agent_id !== agent.identity.id) throw new Error(`${found.address} already works for agent ${owner.agent_id} (run ${owner.run_id}); it cannot be adopted as ${agent.identity.id}`);
2916
3294
  // Each role owns its own slot. A retained worker does not prevent binding the independent reviewer; only this
2917
3295
  // adoption's slot can conflict with the incoming identity. Independence still reads worker history below.
2918
3296
  await readBefore(fleet, view(board, (db) => lastSession(db, id, adoptionSlot)));
@@ -2934,56 +3312,46 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
2934
3312
  if (dir && !task.workspace_anchor && !(task.workspace_kind === 'dir' && task.workspace_path)) {
2935
3313
  db.prepare("UPDATE tasks SET workspace_kind = 'dir', workspace_path = ? WHERE id = ?").run(dir.replace(/^dir:/, ''), id);
2936
3314
  }
2937
- // Adoption takes the role and slot declared for this card's current stage.
2938
3315
  const entry = workflowFor(board.root);
2939
- const expected = runSpec(entry, statusOf(db, task), task);
2940
- if (role && role !== expected?.role) throw new Error(`${id} needs its ${expected?.role ?? 'declared'} role, not ${role}`);
2941
- if (expected?.role === 'reviewer') {
2942
- // Independence reads history even when its implementer's custody was handed back.
2943
- const implementer = json(db.prepare(`SELECT metadata FROM task_runs WHERE task_id=?
2944
- AND COALESCE(json_extract(metadata,'$.supercode.slot'),'worker')='worker'
2945
- AND json_extract(metadata,'$.supercode.session_id') IS NOT NULL ORDER BY id DESC LIMIT 1`).get(id)?.metadata, {})?.supercode;
2946
- if (implementer?.session_id === sessionId) throw new Error('a reviewer must be independent of the implementer session');
2947
- }
2948
3316
  takeSessionCustody(db, id, found.address);
2949
3317
  event(db, id, 'dispatch_released', { reason: 'verified adoption', session: found.address });
2950
3318
  // the manager's adoption is the workflow's own `adopt` verb where it declares one, sent as the manager; else its
2951
3319
  // dispatch start, which the platform sends for a verified adoption
2952
3320
  if (!beforeRun) { const verb = adoptionVerb(entry);
2953
- fire(db, board, entry, id, verb, { roles: [verb === 'adopt' ? 'manager' : 'platform'], payload: { lock: claimer() },
2954
- noticeSession: { address: found.address, session_id: sessionId, machine: host, harness } }); }
3321
+ fire(db, board, entry, id, verb, { roles: [verb === 'adopt' ? 'manager' : 'platform'], payload: { lock: claimer() }, adopting: true }); }
2955
3322
  const runId = getTask(db, id).current_run_id;
2956
3323
  if (!runId) throw new Error(`${id} waits on parents that are not done`);
2957
- // the agent joined above is the one of the run's own role; a run of another role would name the wrong agent
3324
+ // A ready/todo card has no run role until adoption fires its transition. Validate the actual run's
3325
+ // role here, in the same transaction, so refusal rolls back both the transition and the attachment.
2958
3326
  const runRole = currentRun(db, id)?.metadata?.supercode?.role;
2959
- if (agent && runRole && runRole !== agentRole) throw new Error(`${id}'s run is its ${runRole}'s, and ${found.address} was joined as its ${agentRole}; adopt again`);
2960
- const session = { surface: found.pane ? 'pane' : 'native', session_id: sessionId, harness, machine: host, pane: found.pane ?? null, address: found.address, mode: 'adopt', launched_at: now(), seen_at: now(),
2961
- ...(agent ? { agent_id: agent.identity.id, agent_org: team.org, agent_context: context ?? null,
2962
- agent_profile_intent: { ...structuredClone(agent.intended.resolved), worker: { ...agent.intended.resolved?.worker, harness: lane.harness, model: lane.model ?? null, cwd: dir ?? task.workspace_path ?? null } } } : {}) };
3327
+ if (role && role !== runRole) throw new Error(`${id} needs its ${runRole ?? 'declared'} role, not ${role}`);
3328
+ if (runRole === 'reviewer') {
3329
+ // Independence reads history even when its implementer's custody was handed back: no worker session of any run of
3330
+ // the card, none of its side sessions, and no profile its coder ran as (t_2a2ef475: a side session adopted with
3331
+ // --assignee coder-claude onto the card in review was bound as its reviewer, run 2732).
3332
+ const why = coderAsReviewer(db, board, id, { sessionId, assignee, role, assigneeBefore: task.assignee ?? null });
3333
+ if (why) throw new Error(coderAsReviewerRefusal(id, statusOf(db, task), why));
3334
+ }
3335
+ const session = { surface: found.pane ? 'pane' : 'native', session_id: sessionId, harness, machine: host, pane: found.pane ?? null, address: found.address, mode: 'adopt', launched_at: now(), seen_at: now() };
2963
3336
  // a deferred launch was never issued: the adopted session is the run's, and no later round launches another
2964
3337
  const owned = [beforeSession, was].find(s => s && s.pane === session.pane && (sameIdentity(s) || s === beforeSession && keyedPane));
2965
3338
  setRunSession(db, runId, { ...(currentRun(db, id)?.metadata?.supercode ?? {}), ...session,
3339
+ // A deferred or rebound run may retain launch intent. It must not make the delivery pass bind this
3340
+ // attached session to that intent's agent; the session's existing ownership stays with its starter.
3341
+ agent_id: null, agent_org: null, agent_context: null, agent_profile_intent: null, agent_env: null,
2966
3342
  detached_at: null, detached_to: null, launch_key: owned?.launch_key ?? null, launch_pending: false, launch_error: null,
2967
3343
  launch_deferred: false, launch_deferred_error: null, slot: adoptionSlot, retain_after_run: false,
2968
3344
  close_requested: null, closed_at: null, close_confirmed_at: null, close_error: null, close_failed_at: null, close_claim: null,
2969
- released: false, forgotten: false, monitor_held: false, missing_since: null });
3345
+ released: false, forgotten: false, monitor_held: false, missing_since: null, handed_back_from: null, handed_back_run: null });
2970
3346
  transferRetainedSession(db, id, runId, session);
2971
3347
  event(db, id, 'session_adopted', { session_id: sessionId, machine: host, pane: session.pane, address: found.address, mode: 'adopt', role: currentRun(db, id)?.metadata?.supercode?.role }, runId);
3348
+ // an attached session is told its role's brief, the same one a launched session starts with
3349
+ const card = getTask(db, id), told = roleBrief(db, board, entry, card, statusOf(db, card), 'pane');
3350
+ if (told?.prompt) queueBrief(db, board, card, runId, found.address, told, 'attached');
2972
3351
  replaceWorkerSubscriptions(db, id, found.address);
2973
3352
  for (const p of db.prepare('SELECT parent_id FROM task_links WHERE child_id = ?').all(id)) subscribeIn(db, p.parent_id, found.address, 'dependency');
2974
3353
  return { ...session, run: runId, workspace: dir };
2975
3354
  });
2976
- if (agent) {
2977
- recordAgentRun(board.root, team.org, agent.identity.id, { session: `${harness}:${sessionId}`, machine: host, run: adopted.run });
2978
- // Teams learns whose the session is the way a launch tells it (ADR 0013, "launched or adopted"): the session's machine
2979
- // queues it with the same agent environment a launch carries, and binds it when Teams answers. The adoption stands
2980
- // either way; the card says whether the machine took it.
2981
- const environment = agentEnvironment(agent, { root: board.root, context: normalMachine(host) === localMachine() ? localReaderContext(team.org, agent.identity.id) ?? undefined : undefined });
2982
- const r = await doorOn(host, 'harness.v1.sessions.own', { session: `${harness}:${sessionId}`, environment }, { timeout: 30_000 });
2983
- const answer = r.code === 0 ? lastJson(r.stdout) : null;
2984
- tx(board, (db) => event(db, id, answer?.queued === true ? 'agent_session_queued' : 'agent_session_unqueued',
2985
- { session: found.address, agent: agent.identity.id, ...(answer?.queued === true ? {} : { reason: String(answer?.reason ?? (r.stderr || r.stdout || 'the machine did not answer')).slice(0, 300) }) }, adopted.run));
2986
- }
2987
3355
  return adopted;
2988
3356
  }
2989
3357
 
@@ -3118,7 +3486,8 @@ export async function fileBoardMail(board, report, taskId = null) {
3118
3486
  const first = mail.members[0];
3119
3487
  try {
3120
3488
  const task = view(board, db => getTask(db, first.task_id));
3121
- const subject = numbered(boardKey(board.root, board.slug), mail.requester && !mail.message_id && new Set(mail.members.map((m) => m.task_id)).size > 1 ? null : task?.number, mail.requester ? (mail.message_id ? `State requests · ${mail.members.length} request(s) · ${task?.title || first.task_id}` : `${mail.title ?? mail.requester} · ${mail.members.length} card(s)`) : `${task?.title || first.task_id} · ${mail.message_id ? 'Question' : 'Update'}`);
3489
+ const several = mail.requester && !mail.message_id && new Set(mail.members.map((m) => m.task_id)).size > 1;
3490
+ const subject = numbered(boardKey(board.root, board.slug), several ? null : task?.number, mail.requester ? (mail.message_id ? `State requests · ${mail.members.length} request(s) · ${task?.title || first.task_id}` : `${mail.title ?? mail.requester} · ${mail.members.length} card(s)`) : `${task?.title || first.task_id} · ${mail.message_id ? 'Question' : 'Update'}`, several ? null : noticeName(task));
3122
3491
  const filed = await fileMail(board, mail.to, mail.text, mail.message_id ?? `board-message-${first.id}`, { ask: Boolean(mail.message_id), subject });
3123
3492
  tx(board, db => {
3124
3493
  if (filed.detached) {
@@ -3144,26 +3513,42 @@ const MAX_SEND_FAILURES = 12;
3144
3513
  * What an audience is told of an event: the workflow's `notify[audience][kind]` template over the card, the event's
3145
3514
  * payload and (for a comment) the comment; null when the workflow names no such notice.
3146
3515
  */
3147
- export function noticeOf(entry, audience, kind, task, payload, comment = null) {
3516
+ export function noticeOf(entry, audience, kind, task, payload, comment = null, recipient = null) {
3148
3517
  const template = entry.workflow.notify?.[audience]?.[kind];
3149
3518
  if (template === undefined || template === null) return null;
3150
3519
  const cut = (text, n) => String(text ?? '').slice(0, Number(n));
3151
- const text = render(template, { card: task, event: payload ?? {}, comment, params: entry.params, cut });
3152
- return text.trim() ? numbered(task?.key, task?.number, text) : null;
3520
+ // `recipient`: who is told (its subscription's address) and whether the comment @mentions it, for an audience that
3521
+ // hears only what names it (an empty notice is not filed)
3522
+ const text = render(template, { card: task, event: payload ?? {}, comment, params: entry.params, cut, recipient: recipient ?? { address: null, mentioned: false } });
3523
+ return text.trim() ? numbered(task?.key, task?.number, text, noticeName(task)) : null;
3153
3524
  }
3154
3525
 
3155
3526
  /**
3156
3527
  * Who hears a card without having asked: each running card's session hears its card and its subtasks (a subscription a
3157
3528
  * launch or a missed fleet read lost comes back), and each of the workflow's managers (`params.managers`) hears every
3158
- * open card as the `manager` audience, whoever filed it, when the workflow's notify names one.
3529
+ * open arc (not its subtasks) as the `manager` audience, whoever filed it, when the workflow's notify names one.
3159
3530
  */
3160
3531
  function ensureSubscriptions(board, entry) {
3161
3532
  const managers = Array.isArray(entry.params.managers) ? entry.params.managers.map(String) : [];
3162
3533
  const toManagers = Object.keys(entry.workflow.notify?.manager ?? {}).length > 0;
3163
3534
  tx(board, (db) => {
3164
3535
  reconcileDependencySubscriptions(db);
3165
- for (const t of db.prepare("SELECT id, current_run_id FROM tasks WHERE status NOT IN ('done', 'archived')").all()) {
3166
- if (toManagers) for (const m of managers) subscribeIn(db, t.id, m, 'manager');
3536
+ // A subtask's watchers are its own (its creator, its worker, whoever comments on it or is mentioned there), as an
3537
+ // issue tracker's sub-task's are: the manager follows arcs, and an arc's follower is not copied onto its subtasks.
3538
+ // Such copies made before are removed here.
3539
+ db.prepare(`DELETE FROM kanban_notify_subs WHERE platform = 'supercode' AND thread_id = ''
3540
+ AND task_id IN (SELECT task_id FROM supercode_cards WHERE subtask_of IS NOT NULL)
3541
+ AND ((json_extract(delivery_metadata, '$.role') = 'manager' AND json_extract(delivery_metadata, '$.own') IS NULL)
3542
+ OR (COALESCE(json_extract(delivery_metadata, '$.role'), 'creator') = 'creator'
3543
+ AND json_extract(delivery_metadata, '$.own') IS NULL
3544
+ AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.id = kanban_notify_subs.task_id AND t.created_by IN (kanban_notify_subs.chat_id, 'session:' || kanban_notify_subs.chat_id))
3545
+ AND NOT EXISTS (SELECT 1 FROM task_events e WHERE e.task_id = kanban_notify_subs.task_id AND e.kind = 'created'
3546
+ AND json_extract(e.payload, '$.creator') IN (kanban_notify_subs.chat_id, 'session:' || kanban_notify_subs.chat_id))
3547
+ AND EXISTS (SELECT 1 FROM kanban_notify_subs arc JOIN supercode_cards c ON c.subtask_of = arc.task_id
3548
+ WHERE c.task_id = kanban_notify_subs.task_id AND arc.chat_id = kanban_notify_subs.chat_id AND arc.platform = 'supercode')))`).run();
3549
+ for (const t of db.prepare(`SELECT t.id, t.current_run_id, c.subtask_of FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id
3550
+ WHERE t.status NOT IN ('done', 'archived')`).all()) {
3551
+ if (toManagers && !t.subtask_of) for (const m of managers) subscribeIn(db, t.id, m, 'manager');
3167
3552
  if (!t.current_run_id) continue;
3168
3553
  const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(t.current_run_id)?.metadata, {})?.supercode;
3169
3554
  const address = addressOf(session);
@@ -3216,7 +3601,7 @@ function settleStateRequestAnswers(board, entry, report) {
3216
3601
  const text = `${a.task_id}: your request-state ${a.state ?? ''} (${a.question}) was answered by ${a.answered_by ?? 'the manager'} in ${a.answer}; ${outcome}.\n${String(a.text ?? '').trim()}\nRead the card: supercode workflow show ${a.task_id} --root ${board.root}`;
3217
3602
  const effect = { kind: 'send_to_actor', id: a.task_id, runId: a.run_id, address: to, sourceEvent: id, verb: 'state_request_answered',
3218
3603
  messageKey: `board-state-answer-${a.task_id}-${id}`,
3219
- text: identifyNotice({ id, kind: 'state_request_answered', run_id: a.run_id, created_at: now() }, numbered(boardKey(board.root, board.slug), cardNumber(db, a.task_id), text)) };
3604
+ text: identifyNotice({ id, kind: 'state_request_answered', run_id: a.run_id, created_at: now() }, numbered(boardKey(board.root, board.slug), cardNumber(db, a.task_id), text, cardName(db, a.task_id))) };
3220
3605
  db.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
3221
3606
  }
3222
3607
  });
@@ -3302,81 +3687,204 @@ const closePending = ({ held, latest }, address) => {
3302
3687
  && !closeRefused(session);
3303
3688
  };
3304
3689
 
3690
+ /** A subscription's role on its card: worker, dependency, manager, participant, else creator. */
3691
+ const followerRole = (sub) => ['worker', 'dependency', 'manager', 'participant'].includes(sub.delivery_metadata?.role) ? sub.delivery_metadata.role : 'creator';
3692
+
3305
3693
  /**
3306
- * The worker subscriptions of sessions that have ended, removed in one transaction, with one notice_unsubscribed event
3307
- * per session naming its cards. A session has ended when no unended or current run works in it, the fleet does not list
3308
- * it running, its latest run is not a release (a released session is a person's own, left running), and the board
3309
- * closed it (that run's confirmed close). Everything is read inside the transaction, so a run that took the session
3310
- * meanwhile keeps it. A worker session that ended any other way keeps its subscriptions, and is passed over while the
3311
- * fleet does not list it (`notify`); a run
3312
- * that resumes the session subscribes it again at its launch and each round while it is the card's current run.
3313
- * A creator's or manager's subscription is never ended here: its notices wait for the session to come back.
3694
+ * The subscriptions of sessions that have ended, removed in one transaction, with one notice_unsubscribed event per
3695
+ * session naming its cards, whatever role they follow a card in (a dependency subscription is its arc's, moved with the
3696
+ * arc's session by reconcileDependencySubscriptions). A session has ended when no unended or current run works in it,
3697
+ * the fleet does not list it running, its latest run is not a release (a released session is a person's own, left
3698
+ * running), and the board closed it (that run's confirmed close) or it is gone (no transcript, process or pane, read
3699
+ * whole: subscriberGone). Everything is read inside the transaction, so a run that took the session meanwhile keeps it.
3700
+ * Nothing waits for an ended session: what it was not told is not kept for it, and a session resumed later follows a
3701
+ * card again from then (its run's launch, its own comment, a mention). A session that ended any other way keeps its
3702
+ * subscriptions and is passed over while the fleet does not list it (`notify`), and what it misses meanwhile grows old
3703
+ * there (liveSeconds), never told late.
3314
3704
  */
3315
- async function endWorkerSubscriptions(board, fleet, report) {
3316
- // each worker subscriber's liveness is read before the write
3705
+ async function endEndedSubscriptions(board, fleet, report) {
3706
+ const followers = "platform = 'supercode' AND json_extract(delivery_metadata, '$.dependent_card') IS NULL AND COALESCE(json_extract(delivery_metadata, '$.role'), 'creator') != 'dependency'";
3707
+ // each native subscriber's liveness is read before the write
3317
3708
  const aliveNow = new Map(), goneNow = new Map();
3318
- if (fleet.ok) for (const { chat_id: address } of board.db.prepare("SELECT DISTINCT chat_id FROM kanban_notify_subs WHERE platform = 'supercode' AND json_extract(delivery_metadata, '$.role') = 'worker'").all()) {
3709
+ if (fleet.ok) for (const { chat_id: address } of board.db.prepare(`SELECT DISTINCT chat_id FROM kanban_notify_subs WHERE ${followers}`).all()) {
3710
+ if (!sessionAt(address)) continue;
3319
3711
  aliveNow.set(address, await subscriberAlive(fleet, address));
3320
3712
  if (aliveNow.get(address) === false && subscriberGone(fleet, address)) goneNow.set(address, now());
3321
3713
  }
3322
3714
  tx(board, (db) => {
3323
3715
  const remove = db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?");
3324
- const ended = new Map();
3325
- for (const row of db.prepare("SELECT task_id, chat_id, thread_id FROM kanban_notify_subs WHERE platform = 'supercode' AND json_extract(delivery_metadata, '$.role') = 'worker'").all()) {
3326
- const { held, latest } = sessionRecord(db, row.chat_id);
3327
- const address = row.chat_id, record = latest.get(address);
3716
+ const ended = new Map(), records = new Map();
3717
+ for (const row of db.prepare(`SELECT task_id, chat_id, thread_id, delivery_metadata FROM kanban_notify_subs WHERE ${followers}`).all()) {
3718
+ const address = row.chat_id;
3719
+ if (!sessionAt(address)) continue;
3720
+ if (!records.has(address)) records.set(address, sessionRecord(db, address));
3721
+ const { held, latest } = records.get(address);
3722
+ const record = latest.get(address);
3328
3723
  // a session alive, or whose liveness could not be read (unknown, never taken as ended), keeps its subscription
3329
- if (!record || record.session.released || held.has(address) || (fleet.ok && aliveNow.get(address) !== false)) continue;
3724
+ if (record?.session.released || held.has(address) || (fleet.ok && aliveNow.get(address) !== false)) continue;
3330
3725
  // the board closed it, or it is gone (no transcript, process or pane, read whole: subscriberGone)
3331
- const evidence = record.session.close_confirmed_at ? { by: 'board_close', run: record.run, closed_at: record.session.close_confirmed_at }
3332
- : goneNow.has(address) ? { by: 'gone', run: record.run, read_at: goneNow.get(address), machine: sessionAt(address)?.machine ?? null } : null;
3726
+ const evidence = record?.session.close_confirmed_at ? { by: 'board_close', run: record.run, closed_at: record.session.close_confirmed_at }
3727
+ : goneNow.has(address) ? { by: 'gone', run: record?.run ?? null, read_at: goneNow.get(address), machine: sessionAt(address)?.machine ?? null } : null;
3333
3728
  if (!evidence || !remove.run(row.task_id, address, row.thread_id).changes) continue;
3334
- if (!ended.has(address)) ended.set(address, { record, evidence, cards: [] });
3729
+ if (!ended.has(address)) ended.set(address, { record, evidence, cards: [], roles: new Set() });
3335
3730
  ended.get(address).cards.push(row.task_id);
3731
+ ended.get(address).roles.add(json(row.delivery_metadata, {})?.role ?? 'creator');
3336
3732
  }
3337
- for (const [address, { record, evidence, cards }] of ended) {
3338
- event(db, record.task_id, 'notice_unsubscribed', { address, role: 'worker', reason: 'the session has ended', evidence, cards, count: cards.length }, record.run);
3733
+ for (const [address, { record, evidence, cards, roles }] of ended) {
3734
+ event(db, record?.task_id ?? cards[0], 'notice_unsubscribed', { address, role: roles.size === 1 ? [...roles][0] : [...roles].sort(), reason: 'the session has ended', evidence, cards: cards.slice(0, 50), count: cards.length }, record?.run ?? null);
3339
3735
  for (const card of cards) (report.subscriptions_ended ??= []).push([card, address]);
3340
3736
  }
3341
3737
  });
3342
3738
  }
3343
3739
 
3740
+ /**
3741
+ * One follower, one subscription: a session that is an agent's main session, or was one (its former mains), follows a
3742
+ * card under its agent's address (engine.followerAddress), read from each agent the board knows (its managers, and each
3743
+ * agent address that follows a card). Its subscriptions move to that address, starting from each card's last event
3744
+ * (nothing it was not told is carried over), and where the agent already follows the card its own subscription stands
3745
+ * alone. A worker's subscription is its run's session's and stays where it is.
3746
+ */
3747
+ async function foldAgentSessions(board, entry, report) {
3748
+ const agents = new Set([...[].concat(entry.params.managers ?? []).map(String),
3749
+ ...board.db.prepare("SELECT DISTINCT chat_id FROM kanban_notify_subs WHERE platform = 'supercode' AND chat_id LIKE 'sc:%:agent:%'").all().map((r) => r.chat_id)].filter((a) => agentAt(a)));
3750
+ const agentOf = new Map();
3751
+ for (const agent of agents) {
3752
+ await agentMain(agent);
3753
+ for (const session of agentMains.get(agent)?.sessions ?? []) if (sessionAt(session) && !agentOf.has(session)) agentOf.set(session, agent);
3754
+ }
3755
+ if (!agentOf.size) return;
3756
+ tx(board, (db) => {
3757
+ const sessions = [...agentOf.keys()];
3758
+ const rows = db.prepare(`SELECT task_id, chat_id, thread_id, delivery_metadata FROM kanban_notify_subs WHERE platform = 'supercode' AND chat_id IN (${sessions.map(() => '?').join(', ')})`).all(...sessions);
3759
+ const moved = new Map();
3760
+ for (const row of rows) {
3761
+ const metadata = json(row.delivery_metadata, {}) ?? {};
3762
+ if (['worker', 'dependency'].includes(metadata.role) || metadata.dependent_card) continue;
3763
+ const agent = agentOf.get(row.chat_id);
3764
+ const head = db.prepare('SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?').get(row.task_id).c;
3765
+ const { notice_wait: _wait, failures: _failures, ...kept } = metadata;
3766
+ db.prepare(`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)
3767
+ VALUES (?, 'supercode', ?, ?, 'dm', 'notify+wake', ?, ?, ?)`).run(row.task_id, agent, row.thread_id, JSON.stringify(kept), now(), head);
3768
+ db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?").run(row.task_id, row.chat_id, row.thread_id);
3769
+ if (!moved.has(row.chat_id)) moved.set(row.chat_id, { agent, cards: [] });
3770
+ moved.get(row.chat_id).cards.push(row.task_id);
3771
+ }
3772
+ for (const [address, { agent, cards }] of moved) {
3773
+ event(db, cards[0], 'notice_subscriptions_folded', { address, agent, reason: `${address} is ${agent}'s main session or a former one: it follows cards as ${agent}`, cards: cards.slice(0, 50), count: cards.length });
3774
+ for (const card of cards) (report.subscriptions_folded ??= []).push([card, address, agent]);
3775
+ }
3776
+ });
3777
+ }
3778
+
3779
+ /** The batching window, by default (params.notice_digest_seconds): what happens on one card within it after a notice
3780
+ * of that card is one message to each recipient at the window's end, as an issue tracker batches an issue's changes. */
3781
+ const NOTICE_DIGEST_SECONDS = 60;
3782
+ /** The flood ceiling: at most this many notice messages reach one recipient in a window; past it, the rest are one
3783
+ * summary message naming their cards (params.notice_ceiling). */
3784
+ const NOTICE_CEILING_DEFAULT = 10;
3785
+ /** A card's batch shows at most this many notices in full, each at most this many characters; the card holds the rest. */
3786
+ const CARD_DIGEST_ITEMS = 10;
3787
+ const CARD_DIGEST_TEXT = 2000;
3788
+ /** The role a session hears an event in when it follows the card more than one way: the first here that hears its kind. */
3789
+ const NOTICE_RANK = ['worker', 'manager', 'dependency', 'creator', 'participant'];
3790
+ const digestSeconds = (entry) => Math.max(Number(entry.params.notice_digest_seconds) || NOTICE_DIGEST_SECONDS, 1);
3791
+ /**
3792
+ * How old an event may be and still be told: its batching window and three delivery passes after it (one a dispatch
3793
+ * interval), never past the workflow's notice_max_age_seconds. An older one is history: passed over, never filed, and
3794
+ * read on its card.
3795
+ */
3796
+ function liveSeconds(entry) {
3797
+ const live = digestSeconds(entry) + 3 * Math.max(Number(entry.params.dispatch_interval_seconds) || 60, 1);
3798
+ const max = Number(entry.params.notice_max_age_seconds) || null;
3799
+ return max ? Math.min(max, live) : live;
3800
+ }
3801
+ const cutText = (text, n) => { const t = String(text).trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
3802
+
3344
3803
  /**
3345
3804
  * Each subscriber's notices, filed in its mailbox (D138 step 6, D140): the dispatcher's job ends at the filing, a local
3346
- * write under the notice's fixed key; the mailbox delivers it to the session, on this machine or through Teams on
3347
- * another. Each subscription's notices go in order, and its cursor moves past a notice once it is filed (or passed
3348
- * over); a filing that failed here leaves the cursor before it, and the next pass files it. A session the board's own
3349
- * record says has ended (recordedEnded) is filed nothing, in any role, until it is read running again, and neither is a
3350
- * worker whose close the board has asked and not yet confirmed (closePending): its cursor stays, so a session resumed
3351
- * later is told what it missed. The mail router's current session listing is also positive liveness evidence,
3352
- * including when pane/discovery liveness cannot be read. An unread listing never proves a session absent.
3805
+ * write under its fixed key; the mailbox delivers it to the session, on this machine or through Teams on another.
3806
+ *
3807
+ * - Nothing historical is sent. A subscription's cursor moves past every event of its card it has read, heard or not,
3808
+ * so a kind or a role it hears later never makes the card's earlier events news, and an event older than
3809
+ * liveSeconds is passed over.
3810
+ * - One event reaches one session once, whatever addresses it follows the card under (an agent's address is its main
3811
+ * session's), in the role ranked first in NOTICE_RANK that hears it.
3812
+ * - Batching, per recipient and card: a card's notices are one message; a card told within the last window
3813
+ * (notice_digest_seconds, measured between delivery passes) waits, cursors unmoved, and what happened on it meanwhile
3814
+ * is one message when the window ends.
3815
+ * - The ceiling: at most NOTICE_CEILING messages reach one recipient in a window; past it, the rest are one summary
3816
+ * message naming their cards, recorded as notice_flood_collapsed. No defect upstream can mail one session more.
3817
+ *
3818
+ * A filing that failed leaves its cursors before it, and the next pass files it. A session the board's own record says
3819
+ * has ended (recordedEnded) is filed nothing, and neither is a worker whose close the board has asked and not yet
3820
+ * confirmed (closePending). The mail router's current session listing is also positive liveness evidence, including
3821
+ * when pane/discovery liveness cannot be read. An unread listing never proves a session absent.
3353
3822
  */
3354
3823
  async function notify(board, fleet, report, entry) {
3355
- await endWorkerSubscriptions(board, fleet, report);
3356
- for (const sub of subscriptions(board.db).filter((s) => s.platform === 'supercode')) {
3824
+ await endEndedSubscriptions(board, fleet, report);
3825
+ await foldAgentSessions(board, entry, report);
3826
+ const passAt = now(), window = digestSeconds(entry), live = liveSeconds(entry);
3827
+ const NOTICE_CEILING = Math.max(Number(entry.params.notice_ceiling) || NOTICE_CEILING_DEFAULT, 1);
3828
+ const where = "WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?";
3829
+ const identityOf = (sub) => [sub.task_id, sub.chat_id, sub.thread_id ?? ''];
3830
+ const forward = (db, sub, to) => db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND last_event_id < ?`).run(to, ...identityOf(sub), to);
3831
+ const rank = (sub) => NOTICE_RANK.indexOf(followerRole(sub));
3832
+ // cursors moved past what was read with nothing to tell, written together after the pass
3833
+ const quiet = [];
3834
+ // recipient session → { recipient, to, items, subs: [{ sub, through }], wake }
3835
+ const digests = new Map();
3836
+ // `<recipient> <event id>`: one event reaches one session once
3837
+ const claimed = new Set();
3838
+ // and across passes: an event the board recorded filed to any address of the same session is not told again
3839
+ const filedBefore = (taskId, eventId, aliases) => Boolean(board.db.prepare(`SELECT 1 FROM task_events WHERE task_id = ? AND kind = 'notice_filed'
3840
+ AND json_extract(payload, '$.source_event') = ? AND json_extract(payload, '$.address') IN (${aliases.map(() => '?').join(', ')}) LIMIT 1`).get(taskId, eventId, ...aliases));
3841
+ const read = [];
3842
+ for (const sub of subscriptions(board.db).filter((s) => s.platform === 'supercode').sort((a, b) => rank(a) - rank(b))) {
3357
3843
  await yieldToOwner();
3358
3844
  if (stopping) break;
3359
3845
  if (view(board, db => personCustody(db, sub.chat_id))) continue;
3360
3846
  if (sub.delivery_metadata?.dependent_card && sub.delivery_metadata.waiting_recipient) continue;
3361
- const role = ['worker', 'dependency', 'manager'].includes(sub.delivery_metadata?.role) ? sub.delivery_metadata.role : 'creator';
3362
- const kinds = heardBy(entry, role);
3363
- if (!kinds.length) { noticeWait(board, sub, report); continue; }
3847
+ // an unsubscribed follower is told nothing until a mention or its own comment brings it back (engine.follow)
3848
+ if (sub.delivery_metadata?.unsubscribed) continue;
3849
+ const role = followerRole(sub);
3850
+ // what it hears: its role's kinds, and those of a role it held before taking this one (engine.subscribeIn `also`),
3851
+ // each told in the first of its roles whose workflow names that kind
3852
+ const roles = [role, ...[].concat(sub.delivery_metadata?.also ?? []).filter((r) => typeof r === 'string' && r !== role)];
3853
+ const kinds = [...new Set(roles.flatMap((r) => heardBy(entry, r)))];
3854
+ const toldAs = (kind) => roles.find((r) => heardBy(entry, r).includes(kind)) ?? role;
3855
+ // the card's last event, read before its news: the cursor moves to it past every event, heard or not
3856
+ const head = Number(board.db.prepare('SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?').get(sub.task_id).c);
3857
+ if (!kinds.length) { if (head > Number(sub.last_event_id)) quiet.push({ sub, through: head }); noticeWait(board, sub, report); continue; }
3364
3858
  // Read pending news before liveness: a subscription with nothing to file is not waiting on delivery.
3365
- const events = board.db.prepare(`SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id LIMIT 64`)
3366
- .all(sub.task_id, sub.last_event_id, ...kinds);
3367
- if (!events.length) { noticeWait(board, sub, report); continue; }
3859
+ // the page starts past every event already history (older than liveSeconds), so a long backlog never holds back
3860
+ // what is news now
3861
+ // A follower's news is history past liveSeconds; a worker's or a dependency's (a verdict it must act on, its run
3862
+ // changed) only past notice_max_age_seconds, so a worker whose liveness could not be read for minutes is still told.
3863
+ const horizon = ['worker', 'dependency'].includes(role) ? Math.max(live, Number(entry.params.notice_max_age_seconds) || live) : live;
3864
+ const historyThrough = Number(board.db.prepare('SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ? AND id > ? AND id <= ? AND created_at < ?')
3865
+ .get(sub.task_id, sub.last_event_id, head, passAt - horizon).c);
3866
+ const events = board.db.prepare(`SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? AND id > ? AND id <= ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id LIMIT 64`)
3867
+ .all(sub.task_id, Math.max(Number(sub.last_event_id), historyThrough), head, ...kinds);
3868
+ const through = events.length === 64 ? events[63].id : head;
3869
+ if (!events.length) { if (head > Number(sub.last_event_id)) quiet.push({ sub, through: head }); noticeWait(board, sub, report); continue; }
3368
3870
  const record = view(board, db => sessionRecord(db, sub.chat_id));
3369
3871
  // Events name the acting native session; subscriptions may name its agent.
3370
3872
  // Use the daemon's retained binding, including former mains, for both
3371
3873
  // coalescing and delivery. An unread binding proves no additional alias.
3372
3874
  const recipientActors = new Set([sub.chat_id]);
3875
+ let recipient = sub.chat_id;
3373
3876
  if (agentAt(sub.chat_id)) {
3374
- await agentMain(sub.chat_id);
3877
+ const main = await agentMain(sub.chat_id);
3878
+ if (main && sessionAt(main)) recipient = main;
3375
3879
  for (const address of agentMains.get(sub.chat_id)?.sessions ?? []) recipientActors.add(address);
3376
3880
  }
3377
3881
  const ownAct = payload => Boolean(payload?.by && recipientActors.has(payload.by));
3882
+ // the addresses of the same session: its agent's (or, for a native session, every agent whose main or former main it
3883
+ // is), as this pass read their bindings
3884
+ const aliases = new Set([...recipientActors, recipient]);
3885
+ if (!agentAt(sub.chat_id)) for (const [agent, read] of agentMains) if (agentAt(agent) && (read?.sessions ?? []).includes(sub.chat_id)) aliases.add(agent);
3378
3886
  // a worker the board is closing is filed nothing: its cursor stays, so a close that is refused or a session resumed
3379
- // is told what it missed, and a close that lands ends its subscription (endWorkerSubscriptions)
3887
+ // is told what it missed, and a close that lands ends its subscription (endEndedSubscriptions)
3380
3888
  if (role === 'worker' && closePending(record, sub.chat_id)) {
3381
3889
  noticeWait(board, sub, report, 'its session close is pending; its notices wait'); continue;
3382
3890
  }
@@ -3397,70 +3905,136 @@ async function notify(board, fleet, report, entry) {
3397
3905
  // unknown is not absent: its notices wait, and the wait is said, until its liveness can be read
3398
3906
  if (alive === null) { noticeWait(board, sub, report, 'its liveness could not be read; its notices wait until it can be'); continue; }
3399
3907
  } else if (!sessionAt(sub.chat_id) && ended) continue; // native sessions already required a current live observation
3400
- // the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's)
3401
- const task = view(board, (db) => { const t = getTask(db, sub.task_id); return t ? { ...t, key: boardKey(board.root, board.slug), status: statusOf(db, t), stored_as: t.status } : t; });
3908
+ // the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's;
3909
+ // `person` the person it is assigned to, if any)
3910
+ const task = view(board, (db) => { const t = getTask(db, sub.task_id); return t ? { ...t, key: boardKey(board.root, board.slug), status: statusOf(db, t), stored_as: t.status, person: personOf(t.assignee) } : t; });
3402
3911
  // a worker whose run has ended is not woken by a notice (a second session would start working the card beside
3403
3912
  // the one the next run gets): the notice is filed for its next turn, and the next run's brief carries the card's
3404
3913
  // thread. A subtask has no run of its own: its worker works in its arc's run.
3405
3914
  const working = role !== 'worker' || view(board, (db) => addressOf(currentRun(db, task?.subtask_of ?? sub.task_id)?.metadata?.supercode) === sub.chat_id);
3406
3915
  // a Claude session always starts a turn when mail reaches it, so its mail is always delivered now
3407
- const wake = String(sub.chat_id).includes(':claude-code:') || (working && !(!entry.workflow.run && sub.delivery_mode === 'notify'));
3408
- const where = "WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?";
3409
- const identity = [sub.task_id, sub.chat_id, sub.thread_id ?? ''];
3410
- const forward = (to) => tx(board, (db) => db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND last_event_id < ?`).run(to, ...identity, to));
3411
- // an event older than the workflow's notice_max_age_seconds is history, not news: it is passed over, never filed
3412
- const maxAge = Number(entry.params.notice_max_age_seconds) || null;
3413
- const stale = (ev) => maxAge && now() - Number(ev.created_at) > maxAge;
3916
+ const wake = String(sub.chat_id).includes(':claude-code:') || String(recipient).includes(':claude-code:') || (working && !(!entry.workflow.run && sub.delivery_mode === 'notify'));
3917
+ const stale = (ev) => passAt - Number(ev.created_at) > horizon;
3414
3918
  // A backlog (a restart, a pass that did not run) is told as it stands now: news is coalesced by its subject, not its
3415
3919
  // kind alone (a later `assigned` says what an earlier one did, but each subtask added, dependency linked or unlinked
3416
3920
  // is its own fact, keyed by the card it names); a comment or an answer is always its own message. Only events that
3417
3921
  // would be told count: a stale one or the recipient's own act never supersedes one that would be.
3418
3922
  const subjectOf = (ev) => { const p = json(ev.payload, {}) ?? {}; return ['commented', 'answered'].includes(ev.kind) ? `${ev.kind}:${ev.id}` : `${ev.kind}:${p.subtask ?? p.parent ?? p.child ?? ''}`; };
3419
3923
  const latest = new Map(events.filter((ev) => !stale(ev) && !ownAct(json(ev.payload, {}))).map((ev) => [subjectOf(ev), ev.id]));
3420
- let cursor = Number(sub.last_event_id);
3924
+ const items = [];
3925
+ for (const ev of events) {
3926
+ const p = json(ev.payload, {}) ?? {};
3927
+ if (stale(ev)) { report.stale_notices_passed = (report.stale_notices_passed ?? 0) + 1; continue; }
3928
+ // nobody is told of their own act
3929
+ if (ownAct(p)) continue;
3930
+ if (latest.get(subjectOf(ev)) !== ev.id) { report.superseded_notices_passed = (report.superseded_notices_passed ?? 0) + 1; continue; }
3931
+ // the event is this recipient's in this role (the first that hears it); no other subscription of its tells it again
3932
+ const claim = `${recipient} ${ev.id}`;
3933
+ if (claimed.has(claim) || filedBefore(sub.task_id, ev.id, [...aliases])) { report.duplicate_notices_passed = (report.duplicate_notices_passed ?? 0) + 1; continue; }
3934
+ let text;
3935
+ if (ev.kind === 'commented') {
3936
+ if (recipientActors.has(p.author)) continue;
3937
+ const c = board.db.prepare('SELECT author, body FROM task_comments WHERE task_id = ? AND author = ? AND created_at >= ? ORDER BY id LIMIT 1').get(sub.task_id, p.author, ev.created_at);
3938
+ // whether the comment @mentions this recipient (its address, its agent, a session it is), for a role that hears
3939
+ // only what names it
3940
+ const named = c ? view(board, (db) => mentioned(db, entry, c.body)).some((address) => recipientActors.has(address) || address === recipient) : false;
3941
+ text = c ? noticeOf(entry, toldAs(ev.kind), ev.kind, task, p, c, { address: sub.chat_id, mentioned: named }) : null;
3942
+ } else text = noticeOf(entry, toldAs(ev.kind), ev.kind, task, p, null, { address: sub.chat_id, mentioned: false });
3943
+ // claimed once it says something: a role whose notice of it is empty leaves it to the session's other subscriptions
3944
+ if (text) { claimed.add(claim); items.push({ sub, ev, text, body: ['worker', 'dependency'].includes(role) ? identifyNotice(ev, text) : text, task }); }
3945
+ }
3946
+ read.push({ sub, role, task, kinds });
3947
+ // nothing to tell: its cursor moves past what was read
3948
+ if (!items.length) { quiet.push({ sub, through }); noticeWait(board, sub, report); continue; }
3949
+ const digest = digests.get(recipient) ?? { recipient, to: sub.chat_id, cards: new Map() };
3950
+ // a recipient that follows as its agent is mailed at its agent's address, as it subscribed
3951
+ if (agentAt(sub.chat_id) && !agentAt(digest.to)) digest.to = sub.chat_id;
3952
+ const card = digest.cards.get(sub.task_id) ?? { task_id: sub.task_id, task, items: [], subs: [], wake: false };
3953
+ card.items.push(...items);
3954
+ card.subs.push({ sub, through });
3955
+ card.wake ||= wake;
3956
+ digest.cards.set(sub.task_id, card);
3957
+ digests.set(recipient, digest);
3958
+ }
3959
+ // One filing: its cursors move once it is filed (or passed to a person, or already moved), and the recipient's
3960
+ // window state with it; a filing that failed leaves them where they were, and the next pass files it.
3961
+ const file = async (digest, state, { cards, body, subject, kind }) => {
3962
+ const items = cards.flatMap((c) => c.items), subs = cards.flatMap((c) => c.subs), wake = cards.some((c) => c.wake);
3963
+ const key = `board-${kind}-${createHash('sha256').update(`${digest.to}|${items.map((i) => `${i.sub.task_id}:${i.ev.id}`).join(',')}`).digest('hex').slice(0, 24)}`;
3421
3964
  try {
3422
- for (const ev of events) {
3423
- const p = json(ev.payload, {}) ?? {};
3424
- if (stale(ev)) { cursor = ev.id; report.stale_notices_passed = (report.stale_notices_passed ?? 0) + 1; continue; }
3425
- // nobody is told of their own act
3426
- if (ownAct(p)) { cursor = ev.id; continue; }
3427
- if (latest.get(subjectOf(ev)) !== ev.id) { cursor = ev.id; report.superseded_notices_passed = (report.superseded_notices_passed ?? 0) + 1; continue; }
3428
- let text;
3429
- if (ev.kind === 'commented') {
3430
- if (recipientActors.has(p.author)) { cursor = ev.id; continue; }
3431
- const c = board.db.prepare('SELECT author, body FROM task_comments WHERE task_id = ? AND author = ? AND created_at >= ? ORDER BY id LIMIT 1').get(sub.task_id, p.author, ev.created_at);
3432
- text = c ? noticeOf(entry, role, ev.kind, task, p, c) : null;
3433
- } else text = noticeOf(entry, role, ev.kind, task, p);
3434
- if (text) {
3435
- // A verified binding may have replaced this audience while liveness
3436
- // or an earlier filing awaited a door. Re-read before every filing.
3437
- const currentSub = board.db.prepare(`SELECT last_event_id,delivery_metadata FROM kanban_notify_subs ${where}`).get(...identity);
3438
- const currentRole = json(currentSub?.delivery_metadata, {})?.role ?? 'creator';
3439
- if (!currentSub || currentRole !== role) break;
3440
- if (Number(currentSub.last_event_id) >= ev.id) { cursor = Math.max(cursor, Number(currentSub.last_event_id)); continue; }
3441
- const body = ['worker', 'dependency'].includes(role) ? identifyNotice(ev, text) : text;
3442
- // filed under its fixed key: a second pass filing the same notice files nothing new
3443
- const filed = await fileMail(board, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { wake, subject: numbered(task?.key, task?.number, `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}`),
3444
- eligible: () => view(board, db => {
3445
- const currentSub = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where}`).get(...identity);
3446
- return Boolean(currentSub) && Number(currentSub.last_event_id) < ev.id;
3447
- }) });
3448
- if (filed.new) tx(board, (db) => event(db, sub.task_id, 'notice_filed', { source_event: ev.id, kind: ev.kind, address: sub.chat_id, message_id: filed.message_id, wake }));
3449
- if (filed.filed) report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
3965
+ const filed = await fileMail(board, digest.to, body, key, { wake, subject,
3966
+ eligible: () => view(board, db => subs.some(({ sub, through }) => {
3967
+ const row = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where}`).get(...identityOf(sub));
3968
+ return Boolean(row) && Number(row.last_event_id) < through;
3969
+ })) });
3970
+ tx(board, (db) => {
3971
+ for (const { sub, through } of subs) forward(db, sub, through);
3972
+ if (filed.filed) {
3973
+ state.sent.push(passAt);
3974
+ for (const c of cards) state.cards[c.task_id] = passAt;
3975
+ db.prepare('INSERT INTO supercode_board_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(state.key, JSON.stringify({ sent: state.sent, cards: state.cards }));
3450
3976
  }
3451
- cursor = ev.id;
3452
- forward(cursor);
3453
- }
3454
- noticeWait(board, sub, report);
3977
+ if (filed.new) for (const i of items) event(db, i.sub.task_id, 'notice_filed', { source_event: i.ev.id, kind: i.ev.kind, address: digest.to, message_id: filed.message_id, wake, ...(items.length > 1 ? { batched: items.length } : {}), ...(kind === 'summary' ? { summary: true } : {}) });
3978
+ if (filed.new && kind === 'summary') event(db, cards[0].task_id, 'notice_flood_collapsed', { address: digest.to, recipient: digest.recipient, notices: items.length,
3979
+ cards: cards.map((c) => c.task_id).slice(0, 50), card_count: cards.length, ceiling: NOTICE_CEILING, window_seconds: window, message_id: filed.message_id,
3980
+ reason: `more than ${NOTICE_CEILING} notice messages in ${window} s for one recipient: the rest are one summary` });
3981
+ });
3982
+ if (filed.filed) for (const i of items) report.notified.push([i.sub.task_id, i.ev.kind, digest.to]);
3983
+ if (filed.filed && kind === 'summary') (report.notices_collapsed ??= []).push([digest.to, items.length, cards.length]);
3984
+ for (const { sub } of subs) noticeWait(board, sub, report);
3455
3985
  } catch (error) {
3456
- noticeWait(board, sub, report, String(error.message).slice(-200));
3986
+ for (const { sub } of subs) noticeWait(board, sub, report, String(error.message).slice(-200));
3987
+ }
3988
+ };
3989
+ for (const digest of digests.values()) {
3990
+ if (stopping) break;
3991
+ // the recipient's window: the messages filed to it and the cards it was told of in the last `window` seconds
3992
+ const stateKey = `notice_digest:${digest.recipient}`;
3993
+ const stored = json(board.db.prepare('SELECT value FROM supercode_board_state WHERE key = ?').get(stateKey)?.value, null) ?? {};
3994
+ const state = { key: stateKey, sent: [].concat(stored.sent ?? []).map(Number).filter((at) => passAt - at < window),
3995
+ cards: Object.fromEntries(Object.entries(stored.cards ?? {}).filter(([, at]) => passAt - Number(at) < window)) };
3996
+ const cards = [...digest.cards.values()].sort((a, b) => a.items[0].ev.id - b.items[0].ev.id);
3997
+ // a card told within its window waits: what happens on it meanwhile is one message when the window ends
3998
+ const ready = cards.filter((c) => !(c.task_id in state.cards));
3999
+ const hold = (list) => {
4000
+ if (!list.length) return;
4001
+ report.notices_held = (report.notices_held ?? 0) + list.reduce((n, c) => n + c.items.length, 0);
4002
+ const ends = list.map((c) => Number(state.cards[c.task_id] ?? state.sent[0] ?? passAt) + window);
4003
+ report.next_notice_at = Math.min(report.next_notice_at ?? Infinity, ...ends);
4004
+ };
4005
+ hold(cards.filter((c) => c.task_id in state.cards));
4006
+ // the ceiling: at most NOTICE_CEILING messages a window; past it, the rest are one summary message
4007
+ const budget = NOTICE_CEILING - state.sent.length;
4008
+ if (budget <= 0) { hold(ready); continue; }
4009
+ const each = ready.length <= budget ? ready : ready.slice(0, budget - 1);
4010
+ const rest = ready.length <= budget ? [] : ready.slice(budget - 1);
4011
+ for (const c of each) {
4012
+ if (stopping) break;
4013
+ const first = c.items[0], label = numbered(c.task?.key, c.task?.number, c.task?.title || c.task_id, noticeName(c.task));
4014
+ const shown = c.items.slice(0, CARD_DIGEST_ITEMS);
4015
+ const body = c.items.length === 1 ? first.body
4016
+ : `${c.items.length} updates on ${label}:\n\n${shown.map((i) => cutText(i.body, CARD_DIGEST_TEXT)).join('\n\n')}`
4017
+ + `${c.items.length > shown.length ? `\n\n…and ${c.items.length - shown.length} more` : ''}\n\nThe card: supercode workflow show ${c.task_id} --root ${board.root}`;
4018
+ const subject = c.items.length === 1
4019
+ ? numbered(c.task?.key, c.task?.number, `${first.ev.kind.replaceAll('_', ' ')} · run ${first.ev.run_id ?? 'none'} · event ${first.ev.id} · ${c.task?.title || c.task_id}`, noticeName(c.task))
4020
+ : numbered(c.task?.key, c.task?.number, `${c.items.length} updates · ${c.task?.title || c.task_id}`, noticeName(c.task));
4021
+ await file(digest, state, { cards: [c], body, subject, kind: 'notice' });
3457
4022
  }
3458
- forward(cursor);
3459
- // a subscription past its card's last event ends with the card: a worker's when the card is done or archived (its
3460
- // work is over, and its session may never run again), anyone's when it is archived
4023
+ if (rest.length && !stopping) {
4024
+ const count = rest.reduce((n, c) => n + c.items.length, 0);
4025
+ const names = rest.map((c) => numbered(c.task?.key, c.task?.number, c.task_id, noticeName(c.task)));
4026
+ const body = `${count} updates on ${rest.length} card(s): ${names.slice(0, 20).join(', ')}${names.length > 20 ? `, and ${names.length - 20} more` : ''}. `
4027
+ + `Read them on the board: supercode workflow show <card> --root ${board.root}. More than ${NOTICE_CEILING} notice messages in ${window} s would have reached this session, so the rest are this one summary (board event notice_flood_collapsed).`;
4028
+ await file(digest, state, { cards: rest, body, subject: numbered(boardKey(board.root, board.slug), null, `${count} updates on ${rest.length} cards`), kind: 'summary' });
4029
+ }
4030
+ }
4031
+ if (quiet.length) tx(board, (db) => { for (const { sub, through } of quiet) forward(db, sub, through); });
4032
+ // a subscription past its card's last event ends with the card: a worker's when the card is done or archived (its
4033
+ // work is over, and its session may never run again), anyone's when it is archived
4034
+ for (const { sub, role, task, kinds } of read) {
3461
4035
  const over = task && (task.stored_as === 'archived' || (role === 'worker' && task.stored_as === 'done'));
3462
4036
  if (over) tx(board, (db) => {
3463
- const durable = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where}`).get(...identity);
4037
+ const durable = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where}`).get(...identityOf(sub));
3464
4038
  if (!durable) return;
3465
4039
  // A page's final event is not the end of the backlog. Check past the persisted cursor in the same
3466
4040
  // transaction as retirement, so another page (including the card's completion) is never abandoned.
@@ -3468,7 +4042,7 @@ async function notify(board, fleet, report, entry) {
3468
4042
  .get(sub.task_id, durable.last_event_id, ...kinds);
3469
4043
  const current = getTask(db, sub.task_id);
3470
4044
  if (!remaining && current && (current.status === 'archived' || (role === 'worker' && current.status === 'done')))
3471
- db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity);
4045
+ db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identityOf(sub));
3472
4046
  });
3473
4047
  }
3474
4048
  }
@@ -3647,10 +4221,68 @@ export { boardLabel };
3647
4221
 
3648
4222
  // A dispatcher told to stop (its serve's SIGTERM, SIGINT or SIGHUP) starts nothing more in the round it is in: no further
3649
4223
  // board, card start, board message or notice. What it leaves unfiled is not a failure; the next dispatcher files it.
3650
- let stopping = false;
3651
- export function stopDispatching() { stopping = true; }
4224
+ let stopping = false, park = new AbortController();
4225
+ const parkedError = () => Object.assign(new Error('the board generation parked before this read answered; it is read next round'), { code: 'board_parked' });
4226
+ export function stopDispatching() { stopping = true; park.abort(parkedError()); }
4227
+ /** `work`, or `parkedValue()` as soon as a park lands (the fleet's read, which only observes: the successor's round reads
4228
+ * the fleet again). The park's listener goes with the work, so a long-lived generation keeps none. */
4229
+ function untilParked(work, parkedValue) {
4230
+ if (stopping) return Promise.resolve(parkedValue());
4231
+ const signal = park.signal;
4232
+ return new Promise((resolve, reject) => {
4233
+ const onPark = () => resolve(parkedValue());
4234
+ signal.addEventListener('abort', onPark, { once: true });
4235
+ work.then((value) => { signal.removeEventListener('abort', onPark); resolve(value); }, (error) => { signal.removeEventListener('abort', onPark); reject(error); });
4236
+ });
4237
+ }
4238
+ /**
4239
+ * A parking generation's claims end with its park (cli.mjs, while it still writes). A parked generation stays alive after
4240
+ * its successor serves, and a claim whose process is alive holds until its TTL (store.mjs claimHolds), so the successor
4241
+ * could not close a session or bind an adoption this generation had claimed (VO-3578: run 2408's close, claimed by the
4242
+ * predecessor 61278 at 01:19:26Z, held generation 46402 off it until 01:29:50Z). A close claim is cleared; an adoption
4243
+ * claim is recorded again already expired, which every round reads as a dead round's claim, open again.
4244
+ * Only the rows this process claimed are read (`claimsMade`, by primary key and the card's latest answer), never a scan of
4245
+ * every run's metadata and every adoption event: on bb8821620's first park those two scans read about 5,000 pages the
4246
+ * system no longer held, 5.8 s of the park's 6.27 s (slow.jsonl, pid 78550, 2026-10-24T01:50:12.415Z), and the park is
4247
+ * the window card verbs wait out.
4248
+ */
4249
+ export function releaseClaims(root) {
4250
+ const lock = claimer();
4251
+ let released = 0;
4252
+ for (const slug of listBoards(root)) {
4253
+ const board = openBoard(root, slug);
4254
+ try {
4255
+ const made = claimsMade.get(board.key);
4256
+ if (!made) continue;
4257
+ released += tx(board, (db) => {
4258
+ let n = 0;
4259
+ for (const runId of made.runs) {
4260
+ const claim = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(runId)?.metadata, {})?.supercode?.close_claim;
4261
+ if (claim?.lock === lock) { setRunSession(db, runId, { close_claim: null }); n++; }
4262
+ }
4263
+ for (const id of made.cards) {
4264
+ const last = latestAnswer(db, id);
4265
+ const claim = last?.kind === 'adoption_claimed' ? json(last.payload, {}) ?? {} : null;
4266
+ if (claim?.lock === lock) { event(db, id, 'adoption_claimed', { ...claim, expires: 0 }); n++; }
4267
+ }
4268
+ return n;
4269
+ });
4270
+ claimsMade.delete(board.key);
4271
+ } finally { board.close(); }
4272
+ }
4273
+ return released;
4274
+ }
4275
+ /** The close claims (by run) and adoption claims (by card) this process has written, per board: what its park releases. */
4276
+ const claimsMade = new Map();
4277
+ function noteClaim(board, kind, id) {
4278
+ let made = claimsMade.get(board.key);
4279
+ if (!made) claimsMade.set(board.key, made = { runs: new Set(), cards: new Set() });
4280
+ made[kind].add(id);
4281
+ }
4282
+ /** A round a park ended early: its reports are marked `cut`, so it is never read as a clean round (cli.mjs serving). */
4283
+ const cutRound = (reports) => Object.defineProperty(reports, 'cut', { value: true });
3652
4284
  // Only the stable owner's parked generation is resumed; it takes a fresh fencing epoch first.
3653
- export function resumeDispatching() { stopping = false; }
4285
+ export function resumeDispatching() { stopping = false; park = new AbortController(); }
3654
4286
 
3655
4287
  /** A board file's fingerprint: a corrupt board is skipped until the file changes (Hermes retries it then). */
3656
4288
  const fingerprint = (path) => { try { const st = statSync(path); return `${st.size}:${st.mtimeMs}`; } catch { return 'missing'; } };
@@ -3713,8 +4345,9 @@ function noCodeReviewDue(board, id) {
3713
4345
  const filed = db.prepare('SELECT 1 FROM supercode_cards WHERE subtask_of = ? LIMIT 1').get(id);
3714
4346
  if (!filed && !completedWork(db, id) && !(db.prepare("SELECT 1 FROM task_runs WHERE task_id = ? AND outcome = 'completed' AND ended_at IS NOT NULL LIMIT 1").get(id) &&
3715
4347
  outcomeTicked(db.prepare('SELECT body FROM tasks WHERE id = ?').get(id)?.body))) return false;
3716
- const last = db.prepare("SELECT kind FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass','review_voided') ORDER BY id DESC LIMIT 1").get(id);
3717
- return !last || ['changes_requested','review_voided'].includes(last.kind);
4348
+ const last = db.prepare("SELECT kind FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass','review_voided','review_returned') ORDER BY id DESC LIMIT 1").get(id);
4349
+ // a review the manager returned is due again once the disposition is completed again (a finish after the return)
4350
+ return !last || ['changes_requested','review_voided'].includes(last.kind) || (last.kind === 'review_returned' && Boolean(completedWork(db, id)));
3718
4351
  });
3719
4352
  }
3720
4353
 
@@ -3774,6 +4407,8 @@ function reviewDueKey(board, id, payload) {
3774
4407
  return `${source}|${closed ?? ''}`;
3775
4408
  }
3776
4409
 
4410
+ const PASSED_WAITS = 'its review passed and no PR head is unjudged since: no review is due; the arc waits on `complete`';
4411
+
3777
4412
  /** Review selection shared by rounds and read-only inspection (`dry: true`). */
3778
4413
  export async function activatePullRequestReviews(board, entry, report, effects, { only = null, dry = false, fleet = null } = {}) {
3779
4414
  const candidates = view(board, db => db.prepare(`SELECT t.id FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
@@ -3789,13 +4424,26 @@ export async function activatePullRequestReviews(board, entry, report, effects,
3789
4424
  if (!transitionsFor(entry.workflow, statusOf(db, task), 'request_review').some(t => !t.by || t.by.includes('platform'))) return false;
3790
4425
  const scope = scopeOf(db, board, task, { params: entry.params, lazy: true });
3791
4426
  if (scope.card.dispatch_hold || scope.card.paused || scope.card.blocked_by.length) return false;
3792
- // The card's Tasks are its subtasks; an open one holds the review, and says so.
3793
- const open = scope.card.subtasks.filter(t => !['done', 'cancelled'].includes(t.status));
4427
+ // A person's card needs no review: it closes on that person's completion.
4428
+ if (scope.card.person) return false;
4429
+ // The card's Tasks are its subtasks; an open one holds the review, and says so. A paused one holds nothing.
4430
+ const open = scope.card.subtasks.filter(t => !['done', 'cancelled'].includes(t.status) && !t.paused);
3794
4431
  return open.length ? open : true;
3795
4432
  };
3796
4433
  const ready = view(board, eligible);
3797
4434
  if (!ready) { settled.push(id); continue; }
3798
- if (ready !== true) { settled.push(id); report.queued.push([id, `review waits: ${ready.length} open task(s) in the card's Tasks: ${ready[0].id} ${ready[0].title}`]); continue; }
4435
+ if (ready !== true) {
4436
+ settled.push(id);
4437
+ // what holds the final review is said by name, on the round and once on the card each time the set changes
4438
+ const holds = ready.map((t) => `${t.id} "${t.title}"`).join(', ');
4439
+ report.queued.push([id, `review waits on ${ready.length} open task(s): ${holds}`]);
4440
+ if (!dry) tx(board, (db) => {
4441
+ const key = ready.map((t) => t.id).sort().join(',');
4442
+ const last = latestEvent(db, id, ['review_waits_on_tasks']);
4443
+ if (json(last?.payload, {})?.key !== key) event(db, id, 'review_waits_on_tasks', { key, tasks: ready.map((t) => ({ id: t.id, title: t.title, status: t.status })), reason: `the final review waits on ${ready.length} open task(s): ${holds}` });
4444
+ });
4445
+ continue;
4446
+ }
3799
4447
  let payload = null;
3800
4448
  try {
3801
4449
  payload = await openReviewPullRequests(board, id);
@@ -3804,21 +4452,31 @@ export async function activatePullRequestReviews(board, entry, report, effects,
3804
4452
  // source (reviewDue).
3805
4453
  // An arc that closed its tasks having changed no code gets its one review of the act itself (ADR 0003).
3806
4454
  if (!payload && noCodeReviewDue(board, id)) payload = await noCodeChange(board, id);
3807
- // An arc whose last verdict is a pass, with nothing unjudged since, waits on no review: what is left is its
3808
- // session's done request and the manager's or creator's `complete`, and the round says that (volter-org
3809
- // t_b499d0cf and t_a2bd2a5f were logged as waiting for a review every round for hours after theirs passed).
3810
- if (!payload) { report.queued.push([id, passedReview(board, id) ? 'its review passed and no PR head is unjudged since: no review is due; the arc waits on `complete`' : 'review waits: no open or unjudged merged PR on its declared branches, and no completed disposition']); continue; }
4455
+ if (!payload) { report.queued.push([id, passedReview(board, id) ? PASSED_WAITS : 'review waits: no open or merged PR on its declared branches, and no completed disposition']); continue; }
3811
4456
  if (payload.waits) {
3812
4457
  report.queued.push([id, `review waits: ${payload.waits}`]);
3813
4458
  stalls.push({ id, key: `no_work_branch:${lastReviewEvent(board, id)}`, why: `its review waits: ${payload.waits}` });
3814
4459
  payload = null; continue;
3815
4460
  }
4461
+ // An arc whose every PR merged at the head its PR review passed (`pr_reviewed`) still gets its final review, of its
4462
+ // Acceptance against them, once that is due (settledReviewDue).
4463
+ const settledOnly = !payload.no_code && !payload.pr.length && !payload.stacked?.length && Boolean(payload.pr_reviewed?.length);
4464
+ if (settledOnly && !settledReviewDue(board, id)) { report.queued.push([id, passedReview(board, id) ? PASSED_WAITS : `review waits: every PR of the arc (${payload.pr_reviewed.map((p) => p.url).join(', ')}) merged at the head its PR review passed, and no final review is due since its last one`]); continue; }
3816
4465
  // A PR of the arc proposed to a branch other than main is refused below, whatever the others' verdicts.
3817
- if (!payload.no_code && !payload.stacked?.length && !await reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
3818
- const why = `every open PR head (${payload.pr.join(', ')}) has a verdict, carries the source tree its verdict judged, or predates it; push a commit that changes the source, or close the tasks that own the last verdict's findings`;
4466
+ if (!settledOnly && !payload.no_code && !payload.stacked?.length && !await reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
4467
+ // An arc whose last verdict is a pass, with nothing unjudged since, waits on no review: what is left is its
4468
+ // session's done request and the manager's or creator's `complete`, and the round says that (volter-org
4469
+ // t_b499d0cf and t_a2bd2a5f were logged as waiting for a review every round for hours after theirs passed).
4470
+ if (passedReview(board, id)) { report.queued.push([id, PASSED_WAITS]); continue; }
4471
+ // a review the manager returned waits on its coder's next pushed head, which the coder is due to push
4472
+ if (returnedReview(board, id)) { report.queued.push([id, 'review waits: the manager returned it from review with no verdict; the next review starts once a pushed PR head changes']); continue; }
4473
+ // a PR whose PR review passed at its head waits on its coder's merge at that head, which settles it
4474
+ const passedOpen = payload.pr.filter((url, index) => prReviewedHead(board, id, payload.pr_head[index]));
4475
+ const why = passedOpen.length ? `${passedOpen.join(', ')} passed its PR review and waits on its merge at its reviewed head; every other PR head has a verdict, carries the source tree its verdict judged, or predates it`
4476
+ : `every PR head (${payload.pr.join(', ')}) has a verdict, carries the source tree its verdict judged, or predates it; push a commit that changes the source, or close the tasks that own the last verdict's findings`;
3819
4477
  report.queued.push([id, `review waits: ${why}`]);
3820
- // After a pass the arc's own session merges and asks for done; any other verdict nothing answered waits on an act
3821
- if (!passedReview(board, id)) stalls.push({ id, key: `verdict:${lastReviewEvent(board, id)}`, why: `its review waits: ${why}. Every task is closed, so no session of the arc is due to act on it.` });
4478
+ // A verdict other than a pass that nothing answered waits on an act
4479
+ stalls.push({ id, key: `verdict:${lastReviewEvent(board, id)}`, why: `its review waits: ${why}. Every task is closed, so no session of the arc is due to act on it.` });
3822
4480
  continue;
3823
4481
  }
3824
4482
  // A native turn holds the advisory proposal before authoritative source reads. Retain the proposal on the run
@@ -3857,14 +4515,14 @@ export async function activatePullRequestReviews(board, entry, report, effects,
3857
4515
  const evidence = observed.evidence;
3858
4516
  if (!evidence.ready) throw Object.assign(new Error(evidence.reason), { code: evidence.code, refusals: evidence.refusals });
3859
4517
  // A dry run names the complete validated review a tick would request and writes nothing.
3860
- if (dry) { (report.would_review ??= []).push({ id, prs: payload.pr, heads: payload.pr_head, no_code: payload.no_code ?? null }); continue; }
4518
+ if (dry) { (report.would_review ??= []).push({ id, prs: payload.pr, heads: payload.pr_head, no_code: payload.no_code ?? null, pr_reviewed: payload.pr_reviewed ?? null }); continue; }
3861
4519
  const moved = tx(board, db => {
3862
4520
  const task = getTask(db, id);
3863
4521
  if (eligible(db) !== true) return null;
3864
4522
  return withCompletionEvidence(board, id, evidence, () => fire(db, board, entry, id, 'request_review', {
3865
4523
  roles: ['platform'], payload: { ...payload, run_id: task.current_run_id, ci: evidence, summary: payload.no_code
3866
4524
  ? `${payload.no_code.kind === 'disposition' ? 'Scratch operational disposition.' : `No code changed: ${payload.no_code.repository} ${payload.branch} holds no commit beyond ${payload.no_code.base} and no PR was opened from it.`} Review the arc's act from the card: its comments, its tasks' results and what they point at.`
3867
- : `Source ${payload.pr.map((url, index) => `${url} at ${payload.pr_head[index]}`).join(', ')}` }
4525
+ : `${payload.pr.length ? `Source ${payload.pr.map((url, index) => `${url} at ${payload.pr_head[index]}`).join(', ')}` : 'No PR left to review'}${payload.pr_reviewed?.length ? `${payload.pr.length ? '; settled' : ': every PR merged'} at the head its PR review passed: ${payload.pr_reviewed.map((p) => `${p.url} at ${p.head}`).join(', ')}. Review the arc's Acceptance against them.` : ''}` }
3868
4526
  }));
3869
4527
  });
3870
4528
  if (moved) {
@@ -3884,9 +4542,9 @@ export async function activatePullRequestReviews(board, entry, report, effects,
3884
4542
  if (!dry) surfaceReviewStalls(board, entry, stalls, settled, report);
3885
4543
  }
3886
4544
 
3887
- /** The id of the arc's last review event (requested, changes requested, passed, voided), or 0: a review wait's key
4545
+ /** The id of the arc's last review event (requested, changes requested, passed, voided, returned), or 0: a review wait's key
3888
4546
  * carries it, so the wait a new verdict or request leaves is a new wait. */
3889
- const lastReviewEvent = (board, id) => board.db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass', 'review_voided')").get(id)?.id ?? 0;
4547
+ const lastReviewEvent = (board, id) => board.db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass', 'review_voided', 'review_returned')").get(id)?.id ?? 0;
3890
4548
 
3891
4549
  /** A review refusal's wait key: the arc's last review event and the sorted kinds and PRs it refused (ci.mjs refusalOf),
3892
4550
  * never a count or a path, so the same refusal is the same wait however its detail or read order changes. */
@@ -3936,7 +4594,7 @@ function surfaceReviewStalls(board, entry, stalls, settled, report) {
3936
4594
  for (const stall of plan.tell) {
3937
4595
  if (told(db, stall.id) === stall.key) continue;
3938
4596
  const card = getTask(db, stall.id);
3939
- event(db, stall.id, 'board_message', { to, review_wait: stall.key, text: `${numbered(boardKey(board.root, board.slug), card?.number, stall.id)} ${card?.title ?? ''}: ${stall.why}`,
4597
+ event(db, stall.id, 'board_message', { to, review_wait: stall.key, text: `${numbered(boardKey(board.root, board.slug), card?.number, stall.id, noticeName(card))} ${card?.title ?? ''}: ${stall.why}`,
3940
4598
  batch: { requester: 'board review waits', title: 'Review waits only an act clears', window_seconds: 0 } }, null);
3941
4599
  (report.review_waits_told ??= []).push([stall.id, stall.key]);
3942
4600
  }
@@ -3964,14 +4622,14 @@ export function startDelivery(root, { slugs = null, log = () => {} } = {}) {
3964
4622
  // the controller's delivery pass, run here by the owner (no process of its own), never two at once for a home; the
3965
4623
  // round never waits for it
3966
4624
  if (deliveries.has(root)) return false;
3967
- const pass = ownCall(() => deliver({ root, slugs, controller: true })).then((reports) => {
4625
+ const pass = ownCall(() => deliver({ root, slugs, controller: true }), 'a delivery pass').then((reports) => {
3968
4626
  for (const r of reports) {
3969
4627
  if (r.error) log('warn', `delivery on board ${r.board} failed: ${r.error}`);
3970
4628
  for (const [task, to, why] of r.undelivered ?? []) log('warn', `board ${r.board}: a delivery for ${task} to ${to} waits: ${why}`);
3971
4629
  for (const [task, to] of r.notices_resumed ?? []) log('info', `board ${r.board}: notices for ${task} to ${to} resumed`);
3972
4630
  log('info', `delivery on board ${r.board}: ${r.effects_waiting ?? 0} effect(s) waiting, ${r.effects_failed ?? 0} failed for good; took ${Object.entries(r.ms ?? {}).map(([k, v]) => `${k} ${(v / 1000).toFixed(1)}s`).join(', ')}`);
3973
4631
  }
3974
- }, (error) => log('warn', `delivery pass failed: ${error?.message ?? error}`)).finally(() => deliveries.delete(root));
4632
+ }, (error) => error?.code === GENERATION_STOPPED ? log('info', `delivery pass not run: ${error.message}`) : log('warn', `delivery pass failed: ${error?.message ?? error}`)).finally(() => deliveries.delete(root));
3975
4633
  deliveries.set(root, pass);
3976
4634
  return true;
3977
4635
  }
@@ -4016,7 +4674,7 @@ export async function deliver({ root, slugs = null, controller = false }) {
4016
4674
  board.label = boardLabel(root, slug);
4017
4675
  setActor(boardAddress(board.label));
4018
4676
  let mark = Date.now();
4019
- const phase = (name) => { const t = Date.now(); report.ms[name] = t - mark; mark = t; };
4677
+ const phase = (name) => { const t = Date.now(); report.ms[name] = t - mark; mark = t; roundStep(`${slug} ${name}`); };
4020
4678
  // board mail and notices are filed in their receivers' mailboxes (local writes); the mailbox delivers them
4021
4679
  // a controller's pass reads the board's switch before each phase and each effect: switched off meanwhile, what had
4022
4680
  // not begun is left (an operator's `workflow deliver` delivers as asked)
@@ -4024,7 +4682,7 @@ export async function deliver({ root, slugs = null, controller = false }) {
4024
4682
  if (on()) { await fileBoardMail(board, report); phase('mail'); }
4025
4683
  if (on()) { report.effects = await runEffects(board, [], { pending: true, eligible: controller ? on : null }); phase('effects'); }
4026
4684
  if (on()) { await notify(board, fleet, report, entry); phase('notify'); }
4027
- if (on()) { await synchronizeAgents(board, report); phase('agents'); }
4685
+ if (on()) { await synchronizeAgents(board, report, fleet); phase('agents'); }
4028
4686
  report.effects_failed = Number(board.db.prepare('SELECT COUNT(*) AS n FROM supercode_effects WHERE failed_at IS NOT NULL').get().n);
4029
4687
  report.effects_waiting = Number(board.db.prepare('SELECT COUNT(*) AS n FROM supercode_effects WHERE failed_at IS NULL').get().n);
4030
4688
  } catch (error) { report.error = error.message; }
@@ -4034,6 +4692,40 @@ export async function deliver({ root, slugs = null, controller = false }) {
4034
4692
  return out;
4035
4693
  }
4036
4694
 
4695
+ /**
4696
+ * The round's close pass alone (closeRequested, its supervise phase's last act), for the boards in `slugs`: the owner
4697
+ * runs it each interval for every board whose dispatching is off, so an ended run's session closes as it does with
4698
+ * dispatching on. Switching dispatching off stops launches, not closes: VO-3578's reviewer run r2612 ended 12 s after
4699
+ * its board was switched off, and its pane stayed open. Nothing else of a round runs here: no mailbox, adoption,
4700
+ * reconnection, launch, supervision, review, start, effect or delivery. Answers one report per board.
4701
+ */
4702
+ export async function closeEnded({ root, slugs, settings }) {
4703
+ fleetRoot = root;
4704
+ if (stopping || !slugs.length || boardCopyOf({ root })) return [];
4705
+ // a session's native discovery is read once a pass, as once a round (readNative)
4706
+ discoveries.clear();
4707
+ // the fleet reading settles a launch that never bound a pane; one unread leaves that close waiting, as a round's does
4708
+ const fleet = await untilParked(observeFleet(), () => null).catch((error) => ({ ok: false, error: error.message }));
4709
+ if (!fleet) return [];
4710
+ const out = [];
4711
+ for (const slug of slugs) {
4712
+ if (stopping) break;
4713
+ const report = { board: slug, closed: [], undelivered: [] };
4714
+ const path = boardPath(root, slug);
4715
+ if (existsSync(`${path}.corrupt-seen`) && readFileSync(`${path}.corrupt-seen`, 'utf8').trim() === fingerprint(path)) continue;
4716
+ let board;
4717
+ try {
4718
+ board = openBoard(root, slug);
4719
+ board.label = boardLabel(root, slug);
4720
+ setActor(boardAddress(board.label));
4721
+ await closeRequested(board, report, { ttl: settings.ttl, fleet });
4722
+ } catch (error) { report.error = error.message; }
4723
+ finally { board?.close(); }
4724
+ out.push(report);
4725
+ }
4726
+ return out;
4727
+ }
4728
+
4037
4729
  /**
4038
4730
  * One tick over every board of the home at `root`. `lanes` from `lanesOf`, `settings` from `boardSettings`.
4039
4731
  * `only` limits starting to one card (a create that answers whether its card started). No dispatcher file lock: two
@@ -4052,7 +4744,16 @@ export async function deliver({ root, slugs = null, controller = false }) {
4052
4744
  * effect and delivers nothing: it records effects (`queueEffects`) and the delivery pass (`deliver`) carries them out.
4053
4745
  * Answers one report per board.
4054
4746
  */
4055
- export async function tick({ root, lanes, settings, only = null, slugs = null, dispatch = true, params = {}, deploymentReceipt = false }) {
4747
+ // A round's progress, said to whoever asked for it (`tick`'s `progress`; the board generation's first round, which its
4748
+ // owner bounds by progress, never by the round's length): each phase a board's round ends and each door call that
4749
+ // settles, answered or not. A round slow only because another machine answers slowly is progressing; one in which
4750
+ // nothing settles is not. It is the round's own: a card verb this process answers meanwhile says nothing of it.
4751
+ const roundProgress = new AsyncLocalStorage();
4752
+ const roundStep = (step) => { try { roundProgress.getStore()?.(step); } catch { /* the round goes on unsaid */ } };
4753
+ export function tick({ progress = null, ...options }) {
4754
+ return roundProgress.run(progress, () => boardRound(options));
4755
+ }
4756
+ async function boardRound({ root, lanes, settings, only = null, slugs = null, dispatch = true, params = {}, deploymentReceipt = false }) {
4056
4757
  fleetRoot = root;
4057
4758
  discoveries.clear();
4058
4759
  // a verb's flags (dispatch --max, --failure-limit) are params of this tick, read by the workflow as its own
@@ -4061,13 +4762,21 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
4061
4762
  const entry = { ...loaded, params: { ...loaded.params, ...params } };
4062
4763
  const needFleet = Object.values(lanes).some((l) => l.surface === 'pane');
4063
4764
  const fleetAt = Date.now();
4064
- const fleet = needFleet ? await observeFleet() : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
4765
+ const fleet = needFleet ? await untilParked(observeFleet(), () => null) : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
4065
4766
  const fleetMs = Date.now() - fleetAt;
4767
+ roundStep('the fleet read');
4768
+ // A park (stopDispatching) ends the round at its next step, not at its end: the successor's first round does the rest,
4769
+ // and every act re-reads its basis in its own transaction, so a round cut between steps leaves nothing half done. A
4770
+ // round that only read has nothing to keep; one that moved cards keeps the effects those moves asked for (queueEffects
4771
+ // below runs whatever was skipped). A park that waited out a whole round (11-15 s of other machines' doors) missed the
4772
+ // owner's 8 s wait for 'parked', and the handover rolled back (VO-3578, pin V, 21:53Z).
4773
+ if (stopping) return cutRound([]);
4066
4774
  await readMemoryPressure();
4067
4775
  const boards = slugs ?? listBoards(root);
4068
4776
  // what every board of the home holds, for caps whose scope is the whole home (kanban.max_in_progress counts so): every
4069
4777
  // board, including one whose dispatching is off, whose live workers still hold their places
4070
4778
  const held = dispatch ? await homeHoldings(root, listBoards(root), lanes, fleet, entry) : [];
4779
+ if (stopping) return cutRound([]);
4071
4780
  const out = [];
4072
4781
  for (const slug of boards) {
4073
4782
  if (stopping) break;
@@ -4090,17 +4799,20 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
4090
4799
  let mark = Date.now();
4091
4800
  const phase = (name) => { const t = Date.now(); report.ms[name] = t - mark; mark = t; };
4092
4801
 
4093
- await readMailbox(board, boardAddress(board.label), report); phase('mailbox');
4094
- await refreshRelationAnswers(board); settleStateRequestAnswers(board, entry, report); phase('answers');
4802
+ // each step after a park is skipped (above); the effects of what moved are still recorded (queueEffects)
4803
+ const going = () => !stopping;
4804
+ if (going()) await readMailbox(board, boardAddress(board.label), report); phase('mailbox');
4805
+ if (going()) { await refreshRelationAnswers(board); settleStateRequestAnswers(board, entry, report); } phase('answers');
4095
4806
  const effects = [];
4096
- await completeAdoptions(board, fleet, settings, report, lanes); await reconnectContinuedSessions(board, fleet, entry, report); await reconcileLaunches(board, fleet, report, entry, effects, settings, lanes); await recordBounces(board, fleet, entry, report); await supervise(board, fleet, settings, report, entry, effects); queueEffects(board, effects.splice(0)); await closeRequested(board, report, { ttl: settings.ttl, fleet }); phase('supervise');
4807
+ if (going()) await completeAdoptions(board, fleet, settings, report); if (going()) await reconnectContinuedSessions(board, fleet, entry, report); if (going()) await reconcileLaunches(board, fleet, report, entry, effects, settings, lanes); if (going()) await recordBounces(board, fleet, entry, report); if (going()) await supervise(board, fleet, settings, report, entry, effects); queueEffects(board, effects.splice(0)); if (going()) await closeRequested(board, report, { ttl: settings.ttl, fleet }); phase('supervise');
4097
4808
  report.unsettled = [];
4098
4809
  report.promoted = recomputeReady(board, effects, report.unsettled);
4099
4810
  tx(board,db=>auditEndedRuns(db,board,entry));
4100
- if (dispatch) await activatePullRequestReviews(board, entry, report, effects, { only, fleet });
4101
- if (dispatch && !stopping) await startCards(board, fleet, lanes, settings, report, entry, effects, { only, held });
4811
+ if (dispatch && going()) await activatePullRequestReviews(board, entry, report, effects, { only, fleet });
4812
+ if (dispatch && going()) await startCards(board, fleet, lanes, settings, report, entry, effects, { only, held });
4102
4813
  phase('start');
4103
- if (dispatch && !only && !report.paused) await observeIdle(board, fleet, settings, report, entry, effects);
4814
+ if (dispatch && !only && !report.paused && going()) await observeIdle(board, fleet, settings, report, entry, effects);
4815
+ if (dispatch && !only && !report.paused && going()) retellBriefs(board, entry, report);
4104
4816
  // what the moves asked for is recorded with them; the delivery pass (`deliver`) carries it out, outside the round
4105
4817
  queueEffects(board, effects);
4106
4818
  phase('idle');
@@ -4119,7 +4831,8 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
4119
4831
  }
4120
4832
  out.push(report);
4121
4833
  }
4122
- if (deployment && out.every(report => !report.error)) {
4834
+ if (stopping) cutRound(out);
4835
+ if (deployment && !out.cut && out.every(report => !report.error)) {
4123
4836
  const receipt = await retainWorkflowTick(root, deployment);
4124
4837
  if (receipt && out[0]) out[0].deployment = receipt;
4125
4838
  }