@volter/supercode-orchestrator 0.5.207 → 0.5.208

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.
@@ -33,12 +33,16 @@ let fleetRoot = null;
33
33
  import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, withFreshReviewReads, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
34
34
  import { setImmediate as yieldToOwner } from 'node:timers/promises';
35
35
  import { runtimeDoor } from './runtime-door.mjs';
36
- import { agentScope, localCardAgent, localReaderContext, localAgentSessions, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, recordUsageUnread, agentPreamble, agentEnvironment, recordAgentRun, cardAgentAssignments,handOffAgents, agentRunForSession } from '../agents.mjs';
36
+ import { agentScope, localCardAgent, localReaderContext, localAgentSessions, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, recordUsageUnread, agentPreamble, agentEnvironment, recordAgentRun, cardAgentAssignments,renameCardAgent,handOffAgents, agentRunForSession } from '../agents.mjs';
37
37
  import { finishCardAgentAssignment, recordCardAgentAssignment } from './agent-declarations.mjs';
38
38
  import { materializeAgentHome } from '../agent-home.mjs';
39
39
  import { memorySnapshot } from '../hermes-cli.mjs';
40
- import { hermesModel } from '../worker-route.mjs';
41
- import { MODEL_ARGUMENTS } from '../activation.mjs';
40
+ import { hermesModel, codexPaneRoute } from '../worker-route.mjs';
41
+ import { refreshVault } from '../orchestration.mjs';
42
+ import { projectCodexPaneResume } from '../codex-pane-resume.mjs';
43
+ import { codexPaneInputs } from './codex-pane-inputs.mjs';
44
+ import { declareWorkerHome } from '../activation.mjs';
45
+ import { MODEL_ARGUMENTS, NAME_ARGUMENTS } from '../activation.mjs';
42
46
  import { createRequire } from 'node:module';
43
47
  import { spawn as spawnChild, execFile } from 'node:child_process';
44
48
  import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
@@ -46,7 +50,7 @@ import { homedir, hostname, tmpdir, totalmem } from 'node:os';
46
50
  import { basename, dirname, join } from 'node:path';
47
51
  import { createHash, randomUUID } from 'node:crypto';
48
52
  import { fileURLToPath } from 'node:url';
49
- import { view, boardCopyOf, boardPath, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, openBoard, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
53
+ import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, openBoard, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
50
54
  import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
51
55
  import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
52
56
  import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
@@ -153,7 +157,7 @@ export function callerSession() {
153
157
  export async function observeFleet(root = fleetRoot) {
154
158
  const machines = await homeFleetMachines(root, normalMachine);
155
159
  const fleet = await readMachineFleet(machines, localMachine());
156
- presenceReading = { known: fleet.presenceKnown === true, online: new Set([localMachine(), ...fleet.online]), unknown: fleet.unknown ?? new Set() };
160
+ 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() };
157
161
  notePresence(presenceReading);
158
162
  return fleet;
159
163
  }
@@ -199,10 +203,11 @@ async function agentMain(address) {
199
203
  if (held && Date.now() - held.at <= READING_MS) return held.main;
200
204
  const { machine, name } = agentAt(address);
201
205
  const local = machine === localMachine();
202
- const r = await doorOn(machine, 'harness.v1.agent.show', { name, ...(local ? { claim: { pid: process.pid, ppid: process.ppid, session: null, named: null, owner: null } } : {}) }, { timeout: 5_000 });
206
+ const r = await doorOn(machine, 'harness.v1.agent.show', { name, records_only: true, ...(local ? { claim: { pid: process.pid, ppid: process.ppid, session: null, named: null, owner: null } } : {}) }, { timeout: 5_000 });
203
207
  const answer = r.code === 0 ? lastJson(r.stdout) : null;
204
- const main = answer?.agents?.[0]?.main_session;
205
- const read = { at: Date.now(), main: typeof main === 'string' && main ? main : answer && Number.isInteger(answer.code) && answer.code !== 0 ? false : null };
208
+ const agent = answer?.agents?.[0], main = agent?.main_session;
209
+ const read = { at: Date.now(), main: typeof main === 'string' && main ? main : answer && Number.isInteger(answer.code) && answer.code !== 0 ? false : null,
210
+ sessions: [main, ...(agent?.former_mains ?? [])].filter(value => typeof value === 'string' && value) };
206
211
  agentMains.set(address, read);
207
212
  return read.main;
208
213
  }
@@ -211,6 +216,8 @@ async function agentMain(address) {
211
216
  * An agent's address is as live as its main session.
212
217
  */
213
218
  async function subscriberAlive(fleet, address) {
219
+ // The mail router's current inventory includes native sessions outside the pane/discovery reading.
220
+ if (fleet.noticeAlive?.has(address)) return true;
214
221
  if (fleet.alive.has(address)) return true;
215
222
  if (agentAt(address)) {
216
223
  const main = await agentMain(address);
@@ -230,7 +237,7 @@ async function subscriberAlive(fleet, address) {
230
237
  * daemon's index dropped a parent session that ran a subagent, so its absence there is never read as gone.
231
238
  */
232
239
  function subscriberGone(fleet, address) {
233
- if (fleet.alive.has(address) || fleet.native?.has(address)) return false;
240
+ if (fleet.noticeAlive?.has(address) || fleet.alive.has(address) || fleet.native?.has(address)) return false;
234
241
  const session = sessionAt(address);
235
242
  const seen = session && discoveries.get(nativeKey(session.machine, session.harness, session.session_id));
236
243
  if (!seen?.complete) return false;
@@ -275,7 +282,12 @@ const txRead = (board, fn) => tx(board, fn);
275
282
  /** Take the readings a decision or a board write about `session` will need, before it. */
276
283
  async function readBefore(fleet, session) {
277
284
  if (!session?.machine || session.surface === 'headless') return;
278
- if (session.session_id) try { await readNative(session.machine, session.harness ?? 'claude-code', session.session_id); } catch { /* unread: unknown */ }
285
+ // A pane may use a different Codex home than its daemon. Its native activity
286
+ // locator names the source to discover; the daemon's default can miss it.
287
+ const locator = fleet?.sessions?.find(row => row.address === addressOf(session))?.locator;
288
+ const codexHome = session.codex_home ?? (session.harness === 'codex' && locator?.storage?.kind === 'file'
289
+ ? /^(.*)[\\/]sessions[\\/]/.exec(locator.storage.path)?.[1] : null);
290
+ if (session.session_id) try { await readNative(session.machine, session.harness ?? 'claude-code', session.session_id, codexHome); } catch { /* unread: unknown */ }
279
291
  if (session.pane && !fleet?.native?.has(addressOf(session))) await readPaneList(session.machine);
280
292
  }
281
293
  /** A session's native discovery as this round read it (readNative); throws (unavailable) when it was not read. */
@@ -285,26 +297,29 @@ function nativeSessions(machine, harness, sessionId) {
285
297
  throw unavailable('native liveness is read before it is used (readBefore), and was not');
286
298
  }
287
299
  /** Read a session's native discovery through its machine's door, once a round. */
288
- async function readNative(machine, harness, sessionId) {
300
+ async function readNative(machine, harness, sessionId, codexHome = null) {
289
301
  const key = nativeKey(machine, harness, sessionId), seen = discoveries.get(key);
290
- if (seen) return seen.sessions;
291
- const { sessions, complete } = await discoverNative(machine, harness, sessionId);
292
- discoveries.set(key, { at: Date.now(), sessions, complete });
302
+ const home = harness === 'codex' ? codexHome ?? seen?.codex_home ?? null : null;
303
+ if (seen && (seen.codex_home ?? null) === home) return seen.sessions;
304
+ const { sessions, complete } = await discoverNative(machine, harness, sessionId, home);
305
+ discoveries.set(key, { at: Date.now(), sessions, complete, codex_home: home });
293
306
  return sessions;
294
307
  }
295
308
  // A native discovery is bounded as the fleet reading is: one that does not answer in time is unknown, read next round.
296
309
  const NATIVE_DISCOVERY_MS = 15_000;
297
310
  const nativeKey = (machine, harness, sessionId) => `${normalMachine(machine)}\u0000${harness}\u0000${sessionId}`;
298
- async function discoverNative(machine, harness, sessionId) {
311
+ async function discoverNative(machine, harness, sessionId, codexHome = null) {
299
312
  if (offlineMachine(machine)) throw unavailable(`native liveness unavailable: machine ${normalMachine(machine)} is offline`);
300
313
  // the machine's own harness door, this one's or another's, as every other harness read here is
301
314
  // Liveness uses the native activity receipt, never transcript previews. A name miss can match many retained
302
315
  // rows; opening every one for its messages delays the board's liveness decision for data it never reads.
303
- const result = await doorOn(machine, 'harness.v1.sessions.discover', { harnesses: [harness], query: sessionId, limit: 2000, include_latest_candidates: false }, { timeout: NATIVE_DISCOVERY_MS });
316
+ const result = await doorOn(machine, 'harness.v1.sessions.discover', { harnesses: [harness], query: sessionId, limit: 2000, include_latest_candidates: false, ...(harness === 'codex' && codexHome ? { homes: { codex: join(codexHome, 'sessions') } } : {}) }, { timeout: NATIVE_DISCOVERY_MS });
304
317
  const page = result.code === 0 ? lastJson(result.stdout) : null;
305
318
  if (!Array.isArray(page?.sessions)) throw unavailable(`native liveness unavailable on ${machine}: ${(result.stderr || result.stdout).trim().slice(-300)}`);
306
- // whole: every source read, nothing truncated, from an index that keeps every session's own row (subscriberGone)
307
- const complete = page.subagent_rows === 'own' && !(Array.isArray(page.failed) && page.failed.length) && !page.receipt?.truncated && page.next_cursor == null;
319
+ // whole: every source read and none still being reconciled, nothing truncated, from an index that keeps every
320
+ // session's own row (subscriberGone); a source still being read answers from the rows it held before
321
+ const complete = page.subagent_rows === 'own' && !(Array.isArray(page.failed) && page.failed.length) && !page.reconciling?.length
322
+ && !page.receipt?.truncated && page.next_cursor == null;
308
323
  return { sessions: page.sessions, complete };
309
324
  }
310
325
  /**
@@ -317,7 +332,7 @@ async function nativeAbsent(session) {
317
332
  if (!session?.session_id || !session.machine) return false;
318
333
  const harness = session.harness ?? 'claude-code';
319
334
  try {
320
- const rows = await readNative(session.machine, harness, session.session_id);
335
+ const rows = await readNative(session.machine, harness, session.session_id, session.codex_home ?? null);
321
336
  return discoveries.get(nativeKey(session.machine, harness, session.session_id))?.complete === true
322
337
  && !rows.some((r) => r.locator?.session_id === session.session_id);
323
338
  } catch { return false; }
@@ -399,17 +414,16 @@ function runLane(db, board, entry, task, lanes) {
399
414
  return lanes[profile] ?? {};
400
415
  }
401
416
 
402
- // The latest presence reading (observeFleet). A machine that did not answer it (offline, or unread this round) is not
403
- // asked again until the next reading: each remote call would wait out its timeout, and one silent machine held every
404
- // close and launch on this one. Its door answers `machine_offline`, which every caller tries again next round.
417
+ // Only a current explicit unlink establishes offline. A missing or unread record is unknown and cannot suppress
418
+ // a bounded direct door call: the record stream and that machine's channel are independent paths.
405
419
  let presenceReading = null;
406
420
  const offlineMachine = (machine) => {
407
421
  const m = normalMachine(machine ?? localMachine());
408
- return m !== localMachine() && presenceReading?.known === true && !presenceReading.online.has(m);
422
+ return m !== localMachine() && presenceReading?.known === true && presenceReading.offline.has(m);
409
423
  };
410
424
  /** A machine's public door (`harness.v1.*`), answered at once when the machine reads as offline. */
411
425
  const doorOn = async (machine, method, params, options) => offlineMachine(machine)
412
- ? { code: 1, stdout: '', stderr: JSON.stringify({ code: 'machine_offline', message: `machine ${normalMachine(machine)} did not answer the fleet reading` }) }
426
+ ? { 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'}` }) }
413
427
  : await runtimeDoor(normalMachine(machine ?? localMachine()) === localMachine() ? null : machine, method, params, options);
414
428
  const unavailable = message => Object.assign(new Error(message), { launchDeferred: true });
415
429
 
@@ -517,7 +531,7 @@ async function supervise(board, fleet, settings, report, entry, effects) {
517
531
  let locator;
518
532
  try { locator = nativeReading(nativeSessions(session.machine, session.harness, session.session_id), session).row?.locator; } catch { continue; }
519
533
  if (!locator) continue;
520
- const answer = await doorOn(session.machine, 'harness.v1.sessions.load', { locator, options: { include_subagents: false, message_limit: 0, inline_media: 'metadata' } }, { timeout: NATIVE_DISCOVERY_MS });
534
+ const answer = await doorOn(session.machine, 'harness.v1.sessions.load', { locator, view: { first_response_only: true }, options: { include_subagents: false, message_limit: 0, inline_media: 'metadata' } }, { timeout: NATIVE_DISCOVERY_MS });
521
535
  const loaded = answer.code === 0 ? lastJson(answer.stdout) : null;
522
536
  const response = loaded?.session?.first_response;
523
537
  if (loaded?.session?.session_id !== session.session_id || !['response', 'error'].includes(response?.outcome)
@@ -786,7 +800,7 @@ async function stopOwnedSession(board, session) {
786
800
  if (r.code !== 0) throw new Error(`${doorTransient(r) ? 'close deferred: ' : ''}closing native session ${addressOf(session)}: ${(r.stderr || r.stdout).trim().slice(-500)}`);
787
801
  // the close's receipt is a discovery taken after it, never the round's cached one from before
788
802
  discoveries.delete(nativeKey(session.machine, session.harness, session.session_id));
789
- const reading = nativeReading(await readNative(session.machine, session.harness, session.session_id), session);
803
+ const reading = nativeReading(await readNative(session.machine, session.harness, session.session_id, session.codex_home ?? null), session);
790
804
  if (reading.alive !== false) throw new Error(`native close has no confirmed stopped receipt for ${addressOf(session)}`);
791
805
  return;
792
806
  }
@@ -1070,8 +1084,8 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
1070
1084
  const scope = view(board, (db) => scopeOf(db, board, task, { params: entry.params }));
1071
1085
  const where = whereOf(board, task);
1072
1086
  // a subtask's run also sees its arc: the arc's text, tasks and thread (where its owner's rulings are)
1073
- const arcContext = task.subtask_of ? view(board, (db) => cardContext(db, task.subtask_of)) : '';
1074
- let prompt = render(spec.prompt, { ...scope, where, context: view(board, (db) => cardContext(db, id)), arc_context: arcContext, doors: doors(entry.workflow, status, spec.role, id, where, surface) });
1087
+ const arcContext = task.subtask_of ? view(board, (db) => cardContext(db, task.subtask_of, { nativeHolds: Boolean(entry.workflow.flags?.paused) })) : '';
1088
+ 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) });
1075
1089
  // the run's session record knows its status, role and slot (a reviewer's slot is never the implementer's); the slot
1076
1090
  // is a template, so a step can ask for isolation by naming a new one
1077
1091
  const slot = String(templateValue(spec.session ?? 'worker', scope) ?? 'worker');
@@ -1113,7 +1127,7 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
1113
1127
  // and its sessions are joined to it off this path, after the launch. No Teams answer starts, stops or holds a card.
1114
1128
  const context=current().facts.SUPERCODE_CONTEXT,team=agentScope(context);
1115
1129
  const agent=await localCardAgent({root:board.root,org:team.org,server:team.server,profiles:entry.profiles,
1116
- profile:lane.name,card:id,role:spec.role??slot,sponsor:task.sponsor??null});
1130
+ profile:lane.name,card:id,handle:cardHandleOf(board,id),readableId:readableIdOf(board,id),role:spec.role??slot});
1117
1131
  assertRunCurrent(board,id,runId);
1118
1132
  recordCardAgentAssignment(board,cardAgentAssignments(board.root,id).find(assignment=>assignment.org===team.org&&assignment.id===agent.assignment));
1119
1133
  // G3: a run of an agent over its budget does not start; the reason waits on the card until the window passes
@@ -1132,7 +1146,7 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
1132
1146
  if(surface==='headless')meta.agent_env.HERMES_HOME=agentHome;
1133
1147
  else{const memory=await memorySnapshot(agentHome).catch(()=>'');assertRunCurrent(board,id,runId);if(memory)prompt=`${prompt}\n\n${memory}`;}
1134
1148
  const result = surface === 'pane'
1135
- ? await launchPane(board, task, runId, lane, fleet, { input: briefByReference(board, task, runId, spec.role ?? slot, where, prompt), slot, meta })
1149
+ ? await launchPane(board, task, runId, lane, fleet, { input: briefByReference(board, task, runId, spec.role ?? slot, where, prompt), slot, meta, orchestration: entry.orchestration ?? { profiles: entry.profiles } })
1136
1150
  : await launchHeadless(board, task, runId, lane, settings, { prompt, skills: spec.skills ?? [], meta });
1137
1151
  tx(board,db=>{
1138
1152
  if(!runCurrent(db,id,runId))return; // replaced once launched: its session is the ended run's, closed as one
@@ -1179,7 +1193,8 @@ export function briefByReference(board, task, runId, role, where, prompt) {
1179
1193
  const local = !task.machine || normalMachine(task.machine) === localMachine();
1180
1194
  const from = local ? `in the file ${file}: read the whole file now`
1181
1195
  : `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`;
1182
- return `The ${board.label} board started run ${runId} of card ${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.`;
1196
+ const ref = cardHandleOf(board, task.id)?.toUpperCase();
1197
+ 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.`;
1183
1198
  }
1184
1199
 
1185
1200
  /** Start one card now by a person's word (a triage card's session, a manager's start): the workflow's start event. */
@@ -1499,7 +1514,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1499
1514
  try {
1500
1515
  const machine = session.machine ?? localMachine();
1501
1516
  if (session.surface === 'native' && !session.pane) {
1502
- const reading = nativeReading(await readNative(machine, session.harness, session.session_id), session);
1517
+ const reading = nativeReading(await readNative(machine, session.harness, session.session_id, session.codex_home ?? null), session);
1503
1518
  if (reading.alive == null) throw new Error(`close deferred: native liveness unknown for ${addressOf(session)}`);
1504
1519
  if (reading.alive) {
1505
1520
  const protectedNow = active.some(r => addressOf(json(r.metadata, {})?.supercode) === addressOf(session));
@@ -1627,10 +1642,15 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1627
1642
  staged = []; watched = null;
1628
1643
  const task = getTask(db, id);
1629
1644
  const status = statusOf(db, task);
1630
- if (!(entry.workflow.statuses[status]?.on?.session_idle || entry.workflow.on?.session_idle || entry.workflow.run?.on?.session_idle)) return;
1645
+ const handles = name => entry.workflow.statuses[status]?.on?.[name] || entry.workflow.on?.[name] || entry.workflow.run?.on?.[name];
1646
+ if (!handles('session_idle') && !handles('session_stalled')) return;
1631
1647
  const runRow = currentRun(db, id);
1632
1648
  let session = runRow?.metadata?.supercode;
1633
1649
  if (!session?.session_id || !['pane', 'native'].includes(session.surface) || session.offline_since != null) return;
1650
+ if (task.paused || task.archived || task.detached || runRow.ended_at != null) {
1651
+ if (session.idle_since != null || session.idle_notice) setRunSession(db, runRow.id, { idle_since: null, idle_notice: null, watch_since: now() });
1652
+ return;
1653
+ }
1634
1654
  // a machine this round did not read as online (offline, or its door silent) shows nothing of its session: no
1635
1655
  // clock of the run moves on a round that could not see it
1636
1656
  if (session.machine && !fleet.online.has(session.machine)) return;
@@ -1696,6 +1716,7 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1696
1716
  // a reading that shows the session no longer waiting ends the prompt; a round that did not read it does not
1697
1717
  if (session.held_notice && live && !asked) setRunSession(db, runRow.id, { held_notice: null });
1698
1718
  if (asked) {
1719
+ if (session.idle_since != null || session.idle_notice) setRunSession(db, runRow.id, { idle_since: null, idle_notice: null });
1699
1720
  // One prompt is one notice: the same wait (what it waits for, since when its harness says) on the same
1700
1721
  // session, whichever of its cards a round reads first and however long it stands.
1701
1722
  const key = [address, live.waiting_for ?? '', live.turn_since_ms ?? ''].join('|');
@@ -1715,7 +1736,7 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1715
1736
  return;
1716
1737
  }
1717
1738
  if (live?.turn === 'working' || delegated.length) {
1718
- if (session.idle_since != null) setRunSession(db, runRow.id, { idle_since: null });
1739
+ if (session.idle_since != null || session.idle_notice) setRunSession(db, runRow.id, { idle_since: null, idle_notice: null });
1719
1740
  retain(delegated.length ? 'delegated_work' : 'working', { delegated_sessions: delegated });
1720
1741
  return;
1721
1742
  }
@@ -1723,6 +1744,7 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1723
1744
  // indefinitely. Bound the observed interval independently of transcript
1724
1745
  // writes, and tell the manager once with the native facts we actually read.
1725
1746
  if (live?.turn !== 'idle') {
1747
+ if (session.idle_since != null || session.idle_notice) setRunSession(db, runRow.id, { idle_since: null, idle_notice: null });
1726
1748
  const since = Number(session.unknown_since ?? observedAt);
1727
1749
  reading.unknown_since = since;
1728
1750
  reading.quiet_seconds = Math.max(0, observedAt - since);
@@ -1747,6 +1769,29 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1747
1769
  const interval = idleInterval(session, live, observedAt);
1748
1770
  reading.idle_since = interval.since; reading.quiet_seconds = interval.quiet;
1749
1771
  if (session.idle_since !== interval.since) setRunSession(db, runRow.id, { idle_since: interval.since });
1772
+ // The workflow declares the threshold, repeat interval, eligibility and
1773
+ // notice. The observer only supplies a watched native idle interval. This
1774
+ // path precedes heartbeat/nudge cooldown and unread checkout handling.
1775
+ if (handles('session_stalled')) {
1776
+ const limits = limitsOf(db, board, entry, task, session);
1777
+ const first = Number(limits.idle_notice), repeat = Number(limits.idle_notice_repeat);
1778
+ const nativeIdle = live?.turn_since_ms ?? null;
1779
+ const prior = session.idle_notice?.native_since_ms === nativeIdle ? session.idle_notice : null;
1780
+ const due = first > 0 && interval.quiet >= first && (!prior || (repeat > 0 && observedAt - prior.at >= repeat));
1781
+ if (due) try {
1782
+ const turnAt = Number(live?.turn_since_ms) || (lastAt > 0 ? Number(lastAtMs) : null);
1783
+ const payload = { ...reading, session: address, run_id: runRow.id, last_turn_at_ms: turnAt,
1784
+ last_turn_at: turnAt ? new Date(turnAt).toISOString() : null,
1785
+ last_turn_time_source: Number(live?.turn_since_ms) > 0 ? 'native_idle_since' : turnAt ? 'last_session_update' : null, repeat: Boolean(prior) };
1786
+ const result = fire(db, board, entry, id, 'session_stalled', { roles: ['platform'], payload });
1787
+ staged.push(...result.effects);
1788
+ setRunSession(db, runRow.id, { idle_notice: { native_since_ms: nativeIdle, since: interval.since, at: observedAt } });
1789
+ report.notified.push([id, 'session_stalled', address]);
1790
+ } catch (error) {
1791
+ if (!error.noTransition) report.failed.push([id, `session_stalled: ${String(error.message).slice(-200)}`]);
1792
+ }
1793
+ }
1794
+ if (!handles('session_idle')) return;
1750
1795
  if (reading.quiet_seconds < settings.idleQuiet) { retain('below_threshold'); return; }
1751
1796
  if (session.goal_nudged_at && observedAt - session.goal_nudged_at < settings.idleQuiet) { retain('nudge_cooldown'); return; }
1752
1797
  // a session whose first input bounced was never told its work: its quiet is not idleness (recordBounces told its manager)
@@ -1801,7 +1846,7 @@ export async function sessionNotice(board, e) {
1801
1846
  const address = e.address ?? addressOf(prior);
1802
1847
  if (!address) throw new Error('lifecycle notice awaits the owned launch session identity');
1803
1848
  const card = view(board, (db) => getTask(db, e.id));
1804
- const subject = numbered(card?.number, `${String(e.verb ?? 'run_changed').replaceAll('_', ' ')} · run ${e.runId} · event ${e.sourceEvent ?? 'none'} · ${card?.title || e.id}`);
1849
+ 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}`);
1805
1850
  const filed = await fileMail(board, address, e.text, effectMessageKey(e), { subject });
1806
1851
  tx(board, (db) => {
1807
1852
  const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
@@ -1822,7 +1867,7 @@ export async function sessionEffect(board, e) {
1822
1867
  if (!address) throw new Error('actor session identity unavailable');
1823
1868
  // filed in the actor's mailbox, which delivers it
1824
1869
  const card = view(board, (db) => getTask(db, e.id));
1825
- const filed = await fileMail(board, address, e.text, effectMessageKey(e), { subject: numbered(card?.number, `actor notice · run ${e.runId ?? 'none'} · ${card?.title || e.id}`) });
1870
+ 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}`) });
1826
1871
  if (filed.new) tx(board, db => event(db, e.id, 'actor_notice_filed', { notice: e.durableId, address, message_id: filed.message_id }, e.runId));
1827
1872
  return true;
1828
1873
  }
@@ -1897,7 +1942,7 @@ function launcherCheck(db, task, lane, fleet) {
1897
1942
  if (!task.machine) return 'the card names no machine; a pane session runs on the machine its card names';
1898
1943
  const machine = executionHost(lane, task.machine, localMachine(), normalMachine);
1899
1944
  if (!fleet.ok) return `supercode could not read the fleet: ${fleet.error}`;
1900
- if (fleet.unknown?.has(machine)) return `machine ${machine}'s door did not answer; the card starts when it does`;
1945
+ if (fleet.unknown?.has(machine)) return `machine ${machine}'s fleet record is not current: ${fleet.errors?.get(machine) ?? 'no current record'}; the card starts when it is`;
1901
1946
  // a machine is scheduled only when it can take a board launch: its board process guard runs this harness, and its
1902
1947
  // daemon keeps keyed launches (it reconciles them itself). One without them is not scheduled, and says why.
1903
1948
  const can = fleet.capabilities?.get(machine);
@@ -1987,9 +2032,9 @@ function launchReading(fleet, session) {
1987
2032
  }
1988
2033
 
1989
2034
  /** How a keyed launch ended, as its machine recorded it; read only where the machine has the door (supervise). */
1990
- async function launchReceipt(board, session) {
2035
+ async function launchReceipt(board, session, timeout = 30_000) {
1991
2036
  const r = await doorOn(session.machine, 'harness.v1.launches.get',{key:session.launch_key},
1992
- { timeout: 30_000, as: boardAddress(board.label) });
2037
+ { timeout, as: boardAddress(board.label) });
1993
2038
  if (r.code !== 0) throw unavailable(`launch receipt unavailable on ${session.machine}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
1994
2039
  if (r.stdout.trim() === 'null') return null;
1995
2040
  const receipt = lastJson(r.stdout);
@@ -2105,7 +2150,10 @@ async function synchronizeAgents(board, report) {
2105
2150
  await yieldToOwner();
2106
2151
  const task=getTaskNow(board,assignment.target);
2107
2152
  if(task&&(assignment.state==='ended'||task.archived||['done','cancelled'].includes(task.status)))finishCardAgentAssignment(board,assignment,`card ${assignment.target} ${task.archived?'archived':task.status}`);
2108
- else if(task)recordCardAgentAssignment(board,assignment);
2153
+ else if(task){
2154
+ if(task.readable_id)renameCardAgent(board.root,assignment,task.readable_id);
2155
+ recordCardAgentAssignment(board,assignment);
2156
+ }
2109
2157
  }
2110
2158
  // What a creating or closing verb could not hand on (no daemon running then) is handed on here.
2111
2159
  try{for(const pending of await handOffAgents(board.root))report.queued.push([pending.agent,pending.error]);}
@@ -2145,8 +2193,7 @@ async function reconcileLaunches(board, fleet, report, entry, effects, settings,
2145
2193
  const rows = view(board, (db) => db.prepare(`SELECT id, task_id, started_at, ended_at, metadata FROM task_runs
2146
2194
  WHERE json_extract(metadata, '$.supercode.launch_pending') = 1
2147
2195
  AND json_extract(metadata, '$.supercode.close_confirmed_at') IS NULL`).all());
2148
- // A keyed launch whose session is not bound yet is bound from this round's fleet reading: its pane, and the session
2149
- // the reading finds in it. Its machine's daemon reconciles the key itself (D137); no door is asked here. A launch with
2196
+ // A keyed launch is bound from the fleet reading, or its machine's exact native first-input receipt. A launch with
2150
2197
  // no pane waits for its launch window (supervise surfaces it then); a pane read absent on its online machine after
2151
2198
  // the grace ended before its session was bound.
2152
2199
  for (const row of rows) {
@@ -2157,6 +2204,16 @@ async function reconcileLaunches(board, fleet, report, entry, effects, settings,
2157
2204
  && ((s.pane && f.pane === s.pane) || (s.session_id && f.session_id === s.session_id)));
2158
2205
  const sid = s.session_id ?? found?.address.split(':')[3];
2159
2206
  if (found && sid) { bindLaunch(board, row, s, found.pane ?? s.pane, sid); continue; }
2207
+ // An app-server-owned Codex thread can confirm its first input before process discovery knows that thread.
2208
+ // Bind only this board's live issuance, its exact pane and the native user-message receipt, never a title match.
2209
+ if (!row.ended_at && s.launch_key && launchReading(fleet, s) === 'present') {
2210
+ const receipt = await launchReceipt(board, s, 5000);
2211
+ if (receipt?.by === boardAddress(board.label) && receipt.pane === s.pane && receipt.live === true
2212
+ && receipt.inputDelivered === true && receipt.inputReceipt === 'native-user-message'
2213
+ && typeof receipt.native_session === 'string' && receipt.native_session) {
2214
+ bindLaunch(board, row, s, s.pane, receipt.native_session); continue;
2215
+ }
2216
+ }
2160
2217
  // An ended run's launch that bound no session within its window awaits nothing: the run is over, and its pane's
2161
2218
  // close (closeRequested) needs no session. It was reported as awaiting its session every round, for good, once
2162
2219
  // its machine refused that close (t_7ec85b6b run 1203, 2026-10-09).
@@ -2186,7 +2243,7 @@ async function reconcileLaunches(board, fleet, report, entry, effects, settings,
2186
2243
  }
2187
2244
  }
2188
2245
 
2189
- async function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'worker', meta = {} } = {}) {
2246
+ async function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'worker', meta = {}, orchestration } = {}) {
2190
2247
  const machine = executionHost(lane, task.machine, localMachine(), normalMachine);
2191
2248
  // Whether a board session opens a terminal window on its machine is the workflow's choice, stated on every launch;
2192
2249
  // no machine default decides it.
@@ -2246,6 +2303,43 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2246
2303
  // The pane id is chosen from the launch key and recorded before open runs, so a launch whose answer is lost (a
2247
2304
  // timeout, a closed stream) is still found by its pane in a later round's fleet reading, and by its session id.
2248
2305
  const openOwned = async (key, sid, mode, args) => {
2306
+ // Keeping a live pane never prepares or mutates a different route/home.
2307
+ if (harness === 'codex') {
2308
+ const profile = meta.agent_profile_intent;
2309
+ if (!profile || !orchestration) throw unavailable('headful Codex has no retained profile route; no shared-login pane was launched');
2310
+ const vault = new Map();
2311
+ refreshVault(orchestration, vault);
2312
+ const selected = { ...profile, name: lane.name, dir: lane.dir };
2313
+ const worker = { ...profile.worker, harness: 'codex', model: model ?? undefined };
2314
+ let resume = null;
2315
+ if (mode === 'resume') {
2316
+ const rows = await readNative(machine, 'codex', sid, prev?.codex_home ?? null);
2317
+ const paths = [...new Set(rows.filter(row => row.locator?.harness === 'codex' && row.locator?.session_id === sid
2318
+ && row.locator.storage?.kind === 'file').map(row => row.locator.storage.path))];
2319
+ if (paths.length !== 1) throw unavailable('the resumed Codex has no unique physical transcript source; no pane was launched');
2320
+ resume = { source: paths[0], id: sid };
2321
+ }
2322
+ let route;
2323
+ if (machine === localMachine()) {
2324
+ route = codexPaneRoute(selected, worker, vault);
2325
+ if (resume) projectCodexPaneResume(route.env.CODEX_HOME, resume.source, resume.id);
2326
+ } else {
2327
+ const prepared = await doorOn(machine, 'harness.v1.codex.pane.prepare', {
2328
+ scope: `${localMachine()}:${board.root}:${lane.name}`, machine: localMachine(),
2329
+ cwd: task.workspace_path,
2330
+ ...codexPaneInputs(selected, worker, vault), ...(resume ? { resume } : {}),
2331
+ }, { timeout: 30_000, as: boardAddress(board.label) });
2332
+ route = prepared.code === 0 ? json(prepared.stdout.trim(), null) : null;
2333
+ if (!route?.env?.CODEX_HOME || !Array.isArray(route.args))
2334
+ throw unavailable(`the destination did not prepare the login-free Codex route: ${prepared.stderr.trim() || 'invalid route receipt'}`);
2335
+ }
2336
+ meta.codex_home = route.env.CODEX_HOME;
2337
+ const separator = args.indexOf('--');
2338
+ if (separator < 0) throw unavailable('the headful Codex launch has no native argument boundary');
2339
+ args = [...args.slice(0, separator), ...Object.entries(route.env).flatMap(([name, value]) => ['--env', `${name}=${value}`]),
2340
+ ...args.slice(separator), ...route.args];
2341
+ if (machine === localMachine()) declareWorkerHome({ harness: 'codex', env: route.env, cwd: task.workspace_path });
2342
+ }
2249
2343
  const chosen = `p_${createHash('sha256').update(key).digest('hex').slice(0, 16)}`;
2250
2344
  tx(board, (db) => {
2251
2345
  if (!runCurrent(db, task.id, runId)) throw Object.assign(new Error(`run ${runId} of ${task.id} ended or was replaced while its launch was prepared; nothing was started`), { launchSuperseded: true });
@@ -2277,12 +2371,12 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2277
2371
  throw Object.assign(new Error(reason), { launchPending: true });
2278
2372
  };
2279
2373
  let session = null;
2280
- let replaced = null;
2374
+ let replaced = null, predecessor = null;
2281
2375
  // A session in no pane is read on its machine before anything replaces it. One the board released (an adopted
2282
2376
  // session it did not launch, left running at its run's end) is never resumed beside itself: while it runs it is kept,
2283
2377
  // and the card's next run is delivered into it, as into any kept session.
2284
2378
  if (prev?.surface === 'native' && !prev.pane && (!prev.close_confirmed_at || prev.released)) {
2285
- const reading = nativeReading(await readNative(prev.machine, prev.harness, prev.session_id), prev);
2379
+ const reading = nativeReading(await readNative(prev.machine, prev.harness, prev.session_id, prev.codex_home ?? null), prev);
2286
2380
  if (reading.alive == null) throw unavailable(`cannot establish native continuation liveness for ${addressOf(prev)}`);
2287
2381
  fleet.native ??= new Map(); fleet.native.set(addressOf(prev), reading);
2288
2382
  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`);
@@ -2291,6 +2385,32 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2291
2385
  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`);
2292
2386
  replaced = { session_id: prev.session_id, why: 'the workflow let it go' };
2293
2387
  }
2388
+ else if (prev && meta.agent_id && (predecessor = otherAgent(board, prev, meta))) {
2389
+ // A session is continued only as the agent it belongs to: the daemon refuses any other ("session … is agent …'s;
2390
+ // nothing resumes it as agent …"), and keeping it would deliver this run into another agent's session. A card's role
2391
+ // agent ends with the card (finishCardAgentAssignment) and an ended agent is never started again (agents.mjs
2392
+ // localAgent: the key's next generation is minted in its place, with fresh memory), so a reopened card's run is a new
2393
+ // agent, as is one the manager reassigned to another agent. Its predecessor's session is let go and the run starts
2394
+ // fresh from the card's text and thread, as a lane of another harness does (VO-2858 t_cc590655: reopened at 22:57Z on
2395
+ // 2026-10-17, runs 1545 and 1546 ended spawn_failed resuming codex 01a14bac, agent_21e274c9's, as agent_00246aaa).
2396
+ // Nothing is closed here: every ended run's pane was confirmed closed above (`pending`), so a session still open at
2397
+ // this point is one the board does not close (a close refused for good, which runs on; a pane a person continued by
2398
+ // hand, swapped in above), and is released as an adopted one is (closeRequested), never closed. The release is
2399
+ // written to the runs that hold it before the fresh session starts, so the slot's next launch, which reads the fresh
2400
+ // session as the slot's last, finds no pane of them still owed a close (`pending`); a Codex launch binds from a later
2401
+ // round's reading, so the release cannot wait for this launch's own record.
2402
+ tx(board, (db) => {
2403
+ if (!runCurrent(db, task.id, runId)) throw Object.assign(new Error(`run ${runId} of ${task.id} ended or was replaced while its launch was prepared; nothing was started`), { launchSuperseded: true });
2404
+ for (const r of db.prepare('SELECT id, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId)) {
2405
+ const held = json(r.metadata, {})?.supercode;
2406
+ if (held?.session_id !== prev.session_id || (held.slot ?? 'worker') !== slot || held.close_confirmed_at || held.close_transferred_to) continue;
2407
+ const at = now();
2408
+ setRunSession(db, r.id, { ...held, close_requested: held.close_requested ?? at, closed_at: held.closed_at ?? at, close_confirmed_at: at, close_error: null, released: true });
2409
+ event(db, task.id, 'session_released', { session_id: held.session_id, pane: held.pane ?? null, machine: held.machine ?? null, reason: `it is agent ${predecessor}'s; run ${runId} is agent ${meta.agent_id}'s and starts fresh, and it is left running` }, r.id);
2410
+ }
2411
+ });
2412
+ replaced = { session_id: prev.session_id, why: `it is agent ${predecessor}'s, and this run is agent ${meta.agent_id}'s`, letGo: true };
2413
+ }
2294
2414
  else if (prev && (prev.harness ?? 'claude-code') !== harness) {
2295
2415
  // the card was reassigned to a lane of another harness: that harness starts it fresh (the card's text and thread
2296
2416
  // are its context), and the last session, if still open, is closed so two harnesses never work one card
@@ -2303,7 +2423,7 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2303
2423
  if (next === 'refuse') throw new Error(`session ${prev.session_id} still has live pane ${prev.pane}; cannot resume or replace it`);
2304
2424
  if (next === 'keep') {
2305
2425
  // the session is kept; the heartbeat's count belongs to the run that ended, so the new run starts it again
2306
- const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _is, 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, ...kept } = prev;
2426
+ 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, ...kept } = prev;
2307
2427
  session = { ...kept, mode: 'adopt', launched_at: now(), seen_at: now() };
2308
2428
  // a kept session is told what this run is for (its card came back to it, with what happened since), only while
2309
2429
  // this run is still the card's current one (its pane and native reads were awaited)
@@ -2343,7 +2463,8 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2343
2463
  letGo: recoversRuns(workflowFor(board.root)) && Boolean(prev.resume_failed_before_live) && Boolean(prev.close_confirmed_at) };
2344
2464
  } else if (prev) replaced = { session_id: prev.session_id, why: `the card moved from ${prev.machine} to ${machine}` };
2345
2465
  // a card keeps its context: a slot's session that cannot be kept or resumed stops the launch, and only a session the
2346
- // workflow let go (`forget_session`) or its manager did (a reassignment to another harness's lane) is replaced
2466
+ // workflow let go (`forget_session`), its manager did (a reassignment to another harness's lane), or that belongs to
2467
+ // another agent than the run's (otherAgent) is replaced
2347
2468
  if (replaced && !prev?.forgotten && !replaced.letGo) {
2348
2469
  const failed = new Error(`session ${replaced.session_id} (${slot}) could not be continued: ${replaced.why}. The board keeps a card's context and starts a new session only when the workflow lets this one go`);
2349
2470
  failed.resumeFailed = { session_id: replaced.session_id, slot, why: replaced.why };
@@ -2352,8 +2473,15 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2352
2473
  if (!session) {
2353
2474
  const sid = how.namesItself ? null : randomUUID();
2354
2475
  const launchKey = `board-${task.id}-r${runId}-${(sid ?? randomUUID()).slice(0,8)}`;
2355
- session = await openOwned(launchKey, sid, 'fresh', [...base, '--key', launchKey,
2356
- '--input', input ?? `Card ${task.id}: ${task.title}`, '--', ...(sid ? how.fresh(sid) : how.fresh()), ...tail]);
2476
+ // A fresh session is named for its card (`vo-856-3f`; a reviewer's `vo-856-reviewer-3f`), where its harness takes a
2477
+ // name, with the start of its id after it as the name's own suffix; a kept or resumed session keeps its name.
2478
+ const handle = cardHandleOf(board, task.id), ref = handle?.toUpperCase();
2479
+ const name = handle ? `${handle}${slot === 'worker' ? '' : `-${slot}`}-${(sid ?? randomUUID()).slice(0, 2)}` : null;
2480
+ const named = name && NAME_ARGUMENTS[harness] ? NAME_ARGUMENTS[harness](name) : [];
2481
+ // Codex names the thread after its native first-input receipt identifies it; Teams uses thread/name/set on
2482
+ // this launch's own app-server, then reads the name back. Continuations receive no naming hint.
2483
+ session = await openOwned(launchKey, sid, 'fresh', [...base, ...(name && harness === 'codex' ? ['--env', `SUPERCODE_SESSION_NAME=${name}`] : []), '--key', launchKey,
2484
+ '--input', input ?? `Card ${ref ?? task.id}${ref ? ` (${task.id})` : ''}: ${task.title}`, '--', ...(sid ? how.fresh(sid) : how.fresh()), ...named, ...tail]);
2357
2485
  if (replaced) session.replaces = replaced.session_id;
2358
2486
  }
2359
2487
  session.harness = session.harness ?? harness;
@@ -2384,6 +2512,16 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2384
2512
  return session;
2385
2513
  }
2386
2514
 
2515
+ /** The agent a slot's last session belongs to, when it is not the agent this run's `meta` names: the agent store's
2516
+ * owner of the session where it records one in this run's org, else the agent its own run ran as. Null when it is
2517
+ * this run's agent, or no agent is known for it (a session from before card agents). */
2518
+ function otherAgent(board, prev, meta) {
2519
+ let owner = null;
2520
+ try { owner = agentRunForSession(board.root, [`${prev.harness ?? 'claude-code'}:${prev.session_id}`]); } catch { /* conflicting owners: its run's record */ }
2521
+ const agent = owner && owner.org === meta.agent_org ? owner.agent_id : prev.agent_id ?? null;
2522
+ return agent && agent !== meta.agent_id ? agent : null;
2523
+ }
2524
+
2387
2525
  /**
2388
2526
  * What a slot's last session on the card's machine is to the card's next run, given whether it is live (its pane, else
2389
2527
  * its native process): an open session that is live is kept, and the run is delivered into it; a live pane holding a
@@ -2472,7 +2610,7 @@ function rotateLog(file, max, backups) {
2472
2610
  /** A headless card's directory on this machine, made as Hermes makes it (scratch, dir, or a git worktree). */
2473
2611
  async function resolveLocalWorkspace(board, task) {
2474
2612
  const root = board.root;
2475
- if (task.workspace_kind !== 'scratch') return (await resolveWorkspace(task, true)).path;
2613
+ if (task.workspace_kind !== 'scratch') return (await resolveWorkspace({ ...task, handle: cardHandleOf(board, task.id) }, true)).path;
2476
2614
  const dir = task.workspace_path || (board.slug === 'default' ? join(root, 'kanban', 'workspaces', task.id) : join(root, 'kanban', 'boards', board.slug, 'workspaces', task.id));
2477
2615
  mkdirSync(dir, { recursive: true });
2478
2616
  return dir;
@@ -2664,7 +2802,7 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
2664
2802
  });
2665
2803
  progress.claimed = request !== null;
2666
2804
  // The adopted session joins the role's agent as a launch does: its identity minted on this host, never asked of Teams.
2667
- const agent = lane ? await localCardAgent({ root: board.root, org: team.org, server: team.server, profiles: entry0.profiles, profile: lane.name, card: id, role: agentRole, sponsor: card0?.sponsor ?? null }) : null;
2805
+ 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;
2668
2806
  if(agent)recordCardAgentAssignment(board,cardAgentAssignments(board.root,id).find(assignment=>assignment.org===team.org&&assignment.id===agent.assignment));
2669
2807
  // A session another agent already owns is refused before anything is recorded: the run would name one agent and the
2670
2808
  // agent store another, and every round after would trip on it.
@@ -2865,7 +3003,7 @@ export async function fileBoardMail(board, report, taskId = null) {
2865
3003
  const first = mail.members[0];
2866
3004
  try {
2867
3005
  const task = view(board, db => getTask(db, first.task_id));
2868
- const subject = numbered(task?.number, mail.requester ? `State requests · ${mail.members.length} request(s) · ${task?.title || first.task_id}` : `${task?.title || first.task_id} · ${mail.message_id ? 'Question' : 'Update'}`);
3006
+ 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'}`);
2869
3007
  const filed = await fileMail(board, mail.to, mail.text, mail.message_id ?? `board-message-${first.id}`, { ask: Boolean(mail.message_id), subject });
2870
3008
  tx(board, db => {
2871
3009
  if (filed.detached) {
@@ -2896,7 +3034,7 @@ export function noticeOf(entry, audience, kind, task, payload, comment = null) {
2896
3034
  if (template === undefined || template === null) return null;
2897
3035
  const cut = (text, n) => String(text ?? '').slice(0, Number(n));
2898
3036
  const text = render(template, { card: task, event: payload ?? {}, comment, params: entry.params, cut });
2899
- return text.trim() ? numbered(task?.number, text) : null;
3037
+ return text.trim() ? numbered(task?.key, task?.number, text) : null;
2900
3038
  }
2901
3039
 
2902
3040
  /**
@@ -2923,6 +3061,42 @@ function ensureSubscriptions(board, entry) {
2923
3061
  /** The event kinds an audience hears (the workflow's notify). */
2924
3062
  const heardBy = (entry, audience) => Object.keys(entry.workflow.notify?.[audience] ?? {});
2925
3063
 
3064
+ /** Positive liveness from the same machine door as `message list`, once per delivery pass. Stale remote rows prove nothing. */
3065
+ async function noticeSessions() {
3066
+ const r = await runtimeDoor(null, 'harness.v1.mail.list', {}, { timeout: 5_000 });
3067
+ const rows = r.code === 0 ? lastJson(r.stdout)?.rows : null;
3068
+ return new Set((Array.isArray(rows) ? rows : [])
3069
+ .filter(row => sessionAt(row.address) && row.stale === undefined && !row.error && row.delivery
3070
+ && !['stale', 'unavailable', 'unknown'].includes(row.status))
3071
+ .map(row => row.address));
3072
+ }
3073
+
3074
+ /** Retain a subscription's wait across passes and owner restarts; only transitions enter the log report. */
3075
+ function noticeWait(board, sub, report, reason = null) {
3076
+ if (reason === null && !sub.delivery_metadata?.notice_wait) return;
3077
+ const changed = tx(board, db => {
3078
+ const where = "WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=?";
3079
+ const identity = [sub.task_id, sub.chat_id, sub.thread_id ?? ''];
3080
+ const row = db.prepare(`SELECT delivery_metadata FROM kanban_notify_subs ${where}`).get(...identity);
3081
+ if (!row) return false;
3082
+ const metadata = json(row.delivery_metadata, {}) ?? {};
3083
+ if ((metadata.role ?? 'creator') !== (sub.delivery_metadata?.role ?? 'creator')) return false;
3084
+ const previous = metadata.notice_wait;
3085
+ if ((previous?.reason ?? null) === reason) return false;
3086
+ if (reason === null) delete metadata.notice_wait;
3087
+ else metadata.notice_wait = { reason, since: now() };
3088
+ db.prepare(`UPDATE kanban_notify_subs SET delivery_metadata=? ${where}`).run(JSON.stringify(metadata), ...identity);
3089
+ event(db, sub.task_id, reason === null ? 'notice_resumed' : 'notice_waiting', {
3090
+ address: sub.chat_id, thread_id: sub.thread_id ?? '', reason, previous: previous?.reason ?? null,
3091
+ });
3092
+ return true;
3093
+ });
3094
+ if (changed) {
3095
+ if (reason !== null) report.undelivered.push([sub.task_id, sub.chat_id, reason]);
3096
+ else (report.notices_resumed ??= []).push([sub.task_id, sub.chat_id]);
3097
+ }
3098
+ }
3099
+
2926
3100
  /**
2927
3101
  * The board's record of each session address: `held`, every address an unended or current run works in, and `latest`,
2928
3102
  * the latest run that names it. A Claude or Codex session resumed by id keeps its address, so a run that resumes it
@@ -2945,6 +3119,21 @@ function sessionRecord(db) {
2945
3119
  */
2946
3120
  const recordedEnded = ({ held, latest }, address) => Boolean(sessionAt(address)) && !held.has(address) && Boolean(latest.get(address)?.session.close_confirmed_at);
2947
3121
 
3122
+ /**
3123
+ * Whether the board has asked to close the session at `address` and the close has not landed: the latest run that names
3124
+ * it asked the close, nothing confirmed it, it was not released to a person, no unended or current run works in it, and
3125
+ * the close was not refused for good (closeRefused: the board stops trying, the session lives on and is told as before).
3126
+ * A close waits for the session's turn to end (turnAtClose), and a notice filed in that wait is left unread in its
3127
+ * mailbox once the close lands, where the machine's health reads it as main work waiting on a session that will never
3128
+ * answer (VO-1221 t_4007dd5b: its machine_set notice m-f91ba984 was filed to fad0018e at 20:00:05Z on 2026-10-17, the
3129
+ * second the deferred close of that session landed, and raised aarons-mac-mini's CRIT agent-main-advances).
3130
+ */
3131
+ const closePending = ({ held, latest }, address) => {
3132
+ const session = latest.get(address)?.session;
3133
+ return Boolean(sessionAt(address)) && !held.has(address) && Boolean(session?.close_requested) && !session.close_confirmed_at && !session.released
3134
+ && !closeRefused(session);
3135
+ };
3136
+
2948
3137
  /**
2949
3138
  * The worker subscriptions of sessions that have ended, removed in one transaction, with one notice_unsubscribed event
2950
3139
  * per session naming its cards. A session has ended when no unended or current run works in it, the fleet does not list
@@ -2989,8 +3178,10 @@ async function endWorkerSubscriptions(board, fleet, report) {
2989
3178
  * write under the notice's fixed key; the mailbox delivers it to the session, on this machine or through Teams on
2990
3179
  * another. Each subscription's notices go in order, and its cursor moves past a notice once it is filed (or passed
2991
3180
  * over); a filing that failed here leaves the cursor before it, and the next pass files it. A session the board's own
2992
- * record says has ended (recordedEnded) is filed nothing, in any role, until it is read running again: its cursor
2993
- * stays, so a session resumed later is told what it missed. A pass with no fleet reading files it nothing either.
3181
+ * record says has ended (recordedEnded) is filed nothing, in any role, until it is read running again, and neither is a
3182
+ * worker whose close the board has asked and not yet confirmed (closePending): its cursor stays, so a session resumed
3183
+ * later is told what it missed. The mail router's current session listing is also positive liveness evidence,
3184
+ * including when pane/discovery liveness cannot be read. An unread listing never proves a session absent.
2994
3185
  */
2995
3186
  async function notify(board, fleet, report, entry) {
2996
3187
  await endWorkerSubscriptions(board, fleet, report);
@@ -3001,34 +3192,45 @@ async function notify(board, fleet, report, entry) {
3001
3192
  if (view(board, db => personCustody(db, sub.chat_id))) continue;
3002
3193
  if (sub.delivery_metadata?.dependent_card && sub.delivery_metadata.waiting_recipient) continue;
3003
3194
  const role = ['worker', 'dependency', 'manager'].includes(sub.delivery_metadata?.role) ? sub.delivery_metadata.role : 'creator';
3004
- // Native notices wait with their cursor intact until their own door proves
3005
- // liveness. A failed fleet reading supplies no permission to file them.
3195
+ const kinds = heardBy(entry, role);
3196
+ if (!kinds.length) { noticeWait(board, sub, report); continue; }
3197
+ // Read pending news before liveness: a subscription with nothing to file is not waiting on delivery.
3198
+ 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`)
3199
+ .all(sub.task_id, sub.last_event_id, ...kinds);
3200
+ if (!events.length) { noticeWait(board, sub, report); continue; }
3201
+ // Events name the acting native session; subscriptions may name its agent.
3202
+ // Use the daemon's retained binding, including former mains, for both
3203
+ // coalescing and delivery. An unread binding proves no additional alias.
3204
+ const recipientActors = new Set([sub.chat_id]);
3205
+ if (agentAt(sub.chat_id)) {
3206
+ await agentMain(sub.chat_id);
3207
+ for (const address of agentMains.get(sub.chat_id)?.sessions ?? []) recipientActors.add(address);
3208
+ }
3209
+ const ownAct = payload => Boolean(payload?.by && recipientActors.has(payload.by));
3210
+ // a worker the board is closing is filed nothing: its cursor stays, so a close that is refused or a session resumed
3211
+ // is told what it missed, and a close that lands ends its subscription (endWorkerSubscriptions)
3212
+ if (role === 'worker' && closePending(record, sub.chat_id)) {
3213
+ noticeWait(board, sub, report, 'its session close is pending; its notices wait'); continue;
3214
+ }
3215
+ // Native notices wait with their cursor intact until the fleet, discovery or mail inventory proves liveness.
3006
3216
  // Agent, board and operator mailboxes route independently of a native PID.
3007
3217
  if (sessionAt(sub.chat_id)) {
3008
- const alive = fleet.ok ? await subscriberAlive(fleet, sub.chat_id) : null;
3218
+ const alive = fleet.noticeAlive?.has(sub.chat_id) ? true : fleet.ok ? await subscriberAlive(fleet, sub.chat_id) : null;
3009
3219
  if (alive !== true) {
3010
- report.undelivered.push([sub.task_id, sub.chat_id, alive === false ? 'its session is not running; its notices wait' : 'its liveness could not be read; its notices wait until it can be']);
3220
+ noticeWait(board, sub, report, alive === false ? 'its session is not running; its notices wait' : 'its liveness could not be read; its notices wait until it can be');
3011
3221
  continue;
3012
3222
  }
3013
3223
  }
3014
- // a session the fleet does not list this pass is told next pass: one reading is not a loss (a loaded box drops
3015
- // sessions from a read, and a dropped subscription never came back), and a session that is replaced on its card
3016
- // has its subscription moved when the new one starts
3224
+ // A missing or unread session keeps its cursor and subscription until a positive reading returns.
3017
3225
  const ended = recordedEnded(record, sub.chat_id);
3018
3226
  if (!sessionAt(sub.chat_id) && (role !== 'creator' || ended) && fleet.ok) {
3019
3227
  const alive = await subscriberAlive(fleet, sub.chat_id);
3020
- if (alive === false) continue;
3228
+ if (alive === false) { noticeWait(board, sub, report, 'its session is not running; its notices wait'); continue; }
3021
3229
  // unknown is not absent: its notices wait, and the wait is said, until its liveness can be read
3022
- if (alive === null) { report.undelivered.push([sub.task_id, sub.chat_id, 'its liveness could not be read; its notices wait until it can be']); continue; }
3230
+ if (alive === null) { noticeWait(board, sub, report, 'its liveness could not be read; its notices wait until it can be'); continue; }
3023
3231
  } else if (!sessionAt(sub.chat_id) && ended) continue; // native sessions already required a current live observation
3024
- const kinds = heardBy(entry, role);
3025
- if (!kinds.length) continue;
3026
- // The retained cursor resumes the next page; one native read never materializes the whole unread history.
3027
- 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`)
3028
- .all(sub.task_id, sub.last_event_id, ...kinds);
3029
- if (!events.length) continue;
3030
3232
  // the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's)
3031
- const task = view(board, (db) => { const t = getTask(db, sub.task_id); return t ? { ...t, status: statusOf(db, t), stored_as: t.status } : t; });
3233
+ 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; });
3032
3234
  // a worker whose run has ended is not woken by a notice (a second session would start working the card beside
3033
3235
  // the one the next run gets): the notice is filed for its next turn, and the next run's brief carries the card's
3034
3236
  // thread. A subtask has no run of its own: its worker works in its arc's run.
@@ -3046,18 +3248,18 @@ async function notify(board, fleet, report, entry) {
3046
3248
  // is its own fact, keyed by the card it names); a comment or an answer is always its own message. Only events that
3047
3249
  // would be told count: a stale one or the recipient's own act never supersedes one that would be.
3048
3250
  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 ?? ''}`; };
3049
- const latest = new Map(events.filter((ev) => !stale(ev) && !((json(ev.payload, {}) ?? {}).by === sub.chat_id)).map((ev) => [subjectOf(ev), ev.id]));
3251
+ const latest = new Map(events.filter((ev) => !stale(ev) && !ownAct(json(ev.payload, {}))).map((ev) => [subjectOf(ev), ev.id]));
3050
3252
  let cursor = Number(sub.last_event_id);
3051
3253
  try {
3052
3254
  for (const ev of events) {
3053
3255
  const p = json(ev.payload, {}) ?? {};
3054
3256
  if (stale(ev)) { cursor = ev.id; report.stale_notices_passed = (report.stale_notices_passed ?? 0) + 1; continue; }
3055
3257
  // nobody is told of their own act
3056
- if (p.by && p.by === sub.chat_id) { cursor = ev.id; continue; }
3258
+ if (ownAct(p)) { cursor = ev.id; continue; }
3057
3259
  if (latest.get(subjectOf(ev)) !== ev.id) { cursor = ev.id; report.superseded_notices_passed = (report.superseded_notices_passed ?? 0) + 1; continue; }
3058
3260
  let text;
3059
3261
  if (ev.kind === 'commented') {
3060
- if (p.author === sub.chat_id) { cursor = ev.id; continue; }
3262
+ if (recipientActors.has(p.author)) { cursor = ev.id; continue; }
3061
3263
  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);
3062
3264
  text = c ? noticeOf(entry, role, ev.kind, task, p, c) : null;
3063
3265
  } else text = noticeOf(entry, role, ev.kind, task, p);
@@ -3070,7 +3272,7 @@ async function notify(board, fleet, report, entry) {
3070
3272
  if (Number(currentSub.last_event_id) >= ev.id) { cursor = Math.max(cursor, Number(currentSub.last_event_id)); continue; }
3071
3273
  const body = ['worker', 'dependency'].includes(role) ? identifyNotice(ev, text) : text;
3072
3274
  // filed under its fixed key: a second pass filing the same notice files nothing new
3073
- const filed = await fileMail(board, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { wake, subject: numbered(task?.number, `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}`),
3275
+ 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}`),
3074
3276
  eligible: () => view(board, db => {
3075
3277
  const currentSub = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where}`).get(...identity);
3076
3278
  return Boolean(currentSub) && Number(currentSub.last_event_id) < ev.id;
@@ -3081,8 +3283,9 @@ async function notify(board, fleet, report, entry) {
3081
3283
  cursor = ev.id;
3082
3284
  forward(cursor);
3083
3285
  }
3286
+ noticeWait(board, sub, report);
3084
3287
  } catch (error) {
3085
- report.undelivered.push([sub.task_id, sub.chat_id, String(error.message).slice(-200)]);
3288
+ noticeWait(board, sub, report, String(error.message).slice(-200));
3086
3289
  }
3087
3290
  forward(cursor);
3088
3291
  // a subscription past its card's last event ends with the card: a worker's when the card is done or archived (its
@@ -3123,7 +3326,7 @@ export async function notifyChats(root, adapters, log = () => {}, wake = null) {
3123
3326
  if (sub.platform === 'supercode' || !adapter) continue;
3124
3327
  const events = board.db.prepare(`SELECT id, kind, payload FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id`).all(sub.task_id, sub.last_event_id, ...kinds);
3125
3328
  if (!events.length) continue;
3126
- const task = view(board, (db) => getTask(db, sub.task_id));
3329
+ const task = view(board, (db) => { const t = getTask(db, sub.task_id); return t ? { ...t, key: boardKey(board.root, board.slug) } : t; });
3127
3330
  let cursor = Number(sub.last_event_id);
3128
3331
  let failed = false;
3129
3332
  for (const ev of events) {
@@ -3357,7 +3560,7 @@ async function turnInFlight(board, id, fleet, heldSince = null) {
3357
3560
  if (!session?.session_id || session.surface === 'headless' || session.close_requested) return null;
3358
3561
  let live = fleet.sessions?.find((x) => x.address === addressOf(session)) ?? null;
3359
3562
  if (live?.turn == null && session.surface === 'native') try {
3360
- const activity = nativeReading(await readNative(session.machine, session.harness ?? 'claude-code', session.session_id), session).row?.activity;
3563
+ const activity = nativeReading(await readNative(session.machine, session.harness ?? 'claude-code', session.session_id, session.codex_home ?? null), session).row?.activity;
3361
3564
  live = activity ? { turn: activity.turn ?? null, turn_trigger: activity.evidence?.turn_trigger ?? null, turn_since_ms: activity.evidence?.native_state_since_ms ?? null } : live;
3362
3565
  } catch { /* unread: waits on nothing */ }
3363
3566
  const turn = live?.turn ?? null;
@@ -3382,6 +3585,8 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
3382
3585
  const candidates = view(board, db => db.prepare(`SELECT t.id FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
3383
3586
  WHERE c.subtask_of IS NULL AND c.every IS NULL
3384
3587
  ${only ? 'AND t.id=?' : ''}`).all(...(only ? [only] : [])));
3588
+ // Review waits only someone's act clears, and the cards read this round in a state that is no such wait (surfaceReviewStalls)
3589
+ const stalls = [], settled = [];
3385
3590
  for (const { id } of candidates) {
3386
3591
  const eligible = db => {
3387
3592
  const task = getTask(db, id);
@@ -3395,22 +3600,30 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
3395
3600
  return open.length ? open : true;
3396
3601
  };
3397
3602
  const ready = view(board, eligible);
3398
- if (!ready) continue;
3399
- if (ready !== true) { report.queued.push([id, `review waits: ${ready.length} open task(s) in the card's Tasks: ${ready[0].id} ${ready[0].title}`]); continue; }
3603
+ if (!ready) { settled.push(id); continue; }
3604
+ 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; }
3400
3605
  let payload = null;
3401
3606
  try {
3402
3607
  payload = await openReviewPullRequests(board, id);
3403
- // The arc's open PRs ask for one review only while some head has no verdict: after changes are
3404
- // requested the next run is the implementer's, until a new head is pushed.
3608
+ // The arc's open PRs ask for one review only while the last verdict is answered or some head is new work: after
3609
+ // changes are requested the next run is the implementer's, until it closes the verdict's tasks or changes the
3610
+ // source (reviewDue).
3405
3611
  // An arc that closed its tasks having changed no code gets its one review of the act itself (ADR 0003).
3406
3612
  if (!payload && noCodeReviewDue(board, id)) payload = await noCodeChange(board, id);
3407
3613
  // An arc whose last verdict is a pass, with nothing unjudged since, waits on no review: what is left is its
3408
3614
  // session's done request and the manager's or creator's `complete`, and the round says that (volter-org
3409
3615
  // t_b499d0cf and t_a2bd2a5f were logged as waiting for a review every round for hours after theirs passed).
3410
3616
  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; }
3411
- if (payload.waits) { report.queued.push([id, `review waits: ${payload.waits}`]); payload = null; continue; }
3617
+ if (payload.waits) {
3618
+ report.queued.push([id, `review waits: ${payload.waits}`]);
3619
+ stalls.push({ id, key: `no_work_branch:${lastReviewEvent(board, id)}`, why: `its review waits: ${payload.waits}` });
3620
+ payload = null; continue;
3621
+ }
3412
3622
  if (!payload.no_code && !await reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
3413
- report.queued.push([id, `review waits: every open PR head (${payload.pr.join(', ')}) has a verdict or predates the last one; push a commit that answers it`]);
3623
+ 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`;
3624
+ report.queued.push([id, `review waits: ${why}`]);
3625
+ // After a pass the arc's own session merges and asks for done; any other verdict nothing answered waits on an act
3626
+ 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.` });
3414
3627
  continue;
3415
3628
  }
3416
3629
  // A native turn holds the advisory proposal before authoritative source reads. Retain the proposal on the run
@@ -3449,11 +3662,66 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
3449
3662
  }));
3450
3663
  });
3451
3664
  if (moved) {
3665
+ settled.push(id);
3452
3666
  effects.push(...moved.effects);
3453
3667
  (report.review_requested ??= []).push({ id, prs: payload.pr, heads: payload.pr_head });
3454
3668
  }
3455
3669
  } catch (error) { report.queued.push([id, `PR review waits${payload ? ` (${payload.pr.join(', ')})` : ''}: ${error.message}`]); }
3456
3670
  }
3671
+ if (!dry) surfaceReviewStalls(board, entry, stalls, settled, report);
3672
+ }
3673
+
3674
+ /** The id of the arc's last review event (requested, changes requested, passed, voided), or 0: a review wait's key
3675
+ * carries it, so the wait a new verdict or request leaves is a new wait. */
3676
+ 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;
3677
+
3678
+ /** At most this many review waits are told per round; the rest are told in the rounds after, in candidate order. */
3679
+ const REVIEW_WAITS_TOLD_PER_ROUND = 8;
3680
+ /**
3681
+ * A review wait that only someone's act clears (the last verdict asked for changes and nothing since answers it; the
3682
+ * card has no work branch to review) reaches the board's manager through the board's own mail, as an incident does
3683
+ * (surfaceIncident's `board_message`, filed by the delivery pass's fileBoardMail): the dispatcher reads these waits every
3684
+ * round, and before this only its log heard them (VO-2858 t_cc590655, 2026-10-17).
3685
+ *
3686
+ * Each is told once. The board_message is the record: it carries the wait's key (its kind and the arc's last review
3687
+ * event), and while the card's last told key is the same and no `review_wait_cleared` follows it, nothing is told
3688
+ * again, across rounds and restarts. A card read this round in a state that is no such wait (no longer reviewable from
3689
+ * where it stands, an open task holding it, or its review requested: `settled`) clears its told wait once, so the same
3690
+ * wait coming back is told again. An unread round (a GitHub error, a session in a turn) neither tells nor clears.
3691
+ * One round tells at most REVIEW_WAITS_TOLD_PER_ROUND, and the waits told together share one batch, so the manager
3692
+ * gets one mail listing them (pendingRequestMail groups a batch by recipient and requester).
3693
+ */
3694
+ function surfaceReviewStalls(board, entry, stalls, settled, report) {
3695
+ const to = [].concat(entry.params.managers ?? []).find(Boolean);
3696
+ if (!to || (!stalls.length && !settled.length)) return;
3697
+ // the key of the card's told review wait not cleared since, or null
3698
+ const told = (db, id) => {
3699
+ const last = db.prepare("SELECT id, json_extract(payload, '$.review_wait') AS key FROM task_events WHERE task_id = ? AND kind = 'board_message' AND json_extract(payload, '$.review_wait') IS NOT NULL ORDER BY id DESC LIMIT 1").get(id);
3700
+ if (!last) return null;
3701
+ const cleared = db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind = 'review_wait_cleared'").get(id)?.id ?? 0;
3702
+ return cleared > last.id ? null : last.key;
3703
+ };
3704
+ // read first: a round with nothing to tell or clear opens no write
3705
+ const plan = view(board, (db) => ({
3706
+ clear: settled.filter((id) => told(db, id)),
3707
+ tell: stalls.filter((stall) => told(db, stall.id) !== stall.key),
3708
+ }));
3709
+ if (plan.tell.length > REVIEW_WAITS_TOLD_PER_ROUND) report.review_waits_deferred = plan.tell.length - REVIEW_WAITS_TOLD_PER_ROUND;
3710
+ plan.tell = plan.tell.slice(0, REVIEW_WAITS_TOLD_PER_ROUND);
3711
+ if (!plan.clear.length && !plan.tell.length) return;
3712
+ tx(board, (db) => {
3713
+ for (const id of plan.clear) {
3714
+ const key = told(db, id);
3715
+ if (key) event(db, id, 'review_wait_cleared', { key }, null);
3716
+ }
3717
+ for (const stall of plan.tell) {
3718
+ if (told(db, stall.id) === stall.key) continue;
3719
+ const card = getTask(db, stall.id);
3720
+ 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}`,
3721
+ batch: { requester: 'board review waits', title: 'Review waits only an act clears', window_seconds: 0 } }, null);
3722
+ (report.review_waits_told ??= []).push([stall.id, stall.key]);
3723
+ }
3724
+ });
3457
3725
  }
3458
3726
 
3459
3727
  /**
@@ -3481,6 +3749,7 @@ export function startDelivery(root, { slugs = null, log = () => {} } = {}) {
3481
3749
  for (const r of reports) {
3482
3750
  if (r.error) log('warn', `delivery on board ${r.board} failed: ${r.error}`);
3483
3751
  for (const [task, to, why] of r.undelivered ?? []) log('warn', `board ${r.board}: a delivery for ${task} to ${to} waits: ${why}`);
3752
+ for (const [task, to] of r.notices_resumed ?? []) log('info', `board ${r.board}: notices for ${task} to ${to} resumed`);
3484
3753
  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(', ')}`);
3485
3754
  }
3486
3755
  }, (error) => log('warn', `delivery pass failed: ${error?.message ?? error}`)).finally(() => deliveries.delete(root));
@@ -3517,6 +3786,7 @@ export async function deliver({ root, slugs = null, controller = false }) {
3517
3786
  const boards = (slugs ?? listBoards(root)).filter((slug) => !controller || dispatchingOf(root, slug).on);
3518
3787
  if (!boards.length) return [];
3519
3788
  const fleet = await observeFleet();
3789
+ fleet.noticeAlive = await noticeSessions();
3520
3790
  const out = [];
3521
3791
  for (const slug of boards) {
3522
3792
  if (controller && !dispatchingOf(root, slug).on) continue;