@volter/supercode-orchestrator 0.5.206 → 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,12 +50,12 @@ 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';
53
57
  import { evaluate, holds, render } from './expr.mjs';
54
- import { identifyNotice } from './notices.mjs';
58
+ import { identifyNotice, numbered } from './notices.mjs';
55
59
  import { noticeReceiptsInTurns, noticeKey, requireNoticeBasis, noticeWriterBasis } from './notice-receipts.mjs';
56
60
  import { pendingRequestMail } from './request-mail.mjs';
57
61
  import {
@@ -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)
@@ -1800,7 +1845,9 @@ export async function sessionNotice(board, e) {
1800
1845
  }
1801
1846
  const address = e.address ?? addressOf(prior);
1802
1847
  if (!address) throw new Error('lifecycle notice awaits the owned launch session identity');
1803
- const filed = await fileMail(board, address, e.text, effectMessageKey(e));
1848
+ const card = view(board, (db) => getTask(db, 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}`);
1850
+ const filed = await fileMail(board, address, e.text, effectMessageKey(e), { subject });
1804
1851
  tx(board, (db) => {
1805
1852
  const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
1806
1853
  if (session?.lifecycle_notice_pending === e.durableId) setRunSession(db, e.runId, { lifecycle_notice_pending: null });
@@ -1819,7 +1866,8 @@ export async function sessionEffect(board, e) {
1819
1866
  const address = addressOf(session);
1820
1867
  if (!address) throw new Error('actor session identity unavailable');
1821
1868
  // filed in the actor's mailbox, which delivers it
1822
- const filed = await fileMail(board, address, e.text, effectMessageKey(e));
1869
+ const card = view(board, (db) => getTask(db, 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}`) });
1823
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));
1824
1872
  return true;
1825
1873
  }
@@ -1894,7 +1942,7 @@ function launcherCheck(db, task, lane, fleet) {
1894
1942
  if (!task.machine) return 'the card names no machine; a pane session runs on the machine its card names';
1895
1943
  const machine = executionHost(lane, task.machine, localMachine(), normalMachine);
1896
1944
  if (!fleet.ok) return `supercode could not read the fleet: ${fleet.error}`;
1897
- 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`;
1898
1946
  // a machine is scheduled only when it can take a board launch: its board process guard runs this harness, and its
1899
1947
  // daemon keeps keyed launches (it reconciles them itself). One without them is not scheduled, and says why.
1900
1948
  const can = fleet.capabilities?.get(machine);
@@ -1984,9 +2032,9 @@ function launchReading(fleet, session) {
1984
2032
  }
1985
2033
 
1986
2034
  /** How a keyed launch ended, as its machine recorded it; read only where the machine has the door (supervise). */
1987
- async function launchReceipt(board, session) {
2035
+ async function launchReceipt(board, session, timeout = 30_000) {
1988
2036
  const r = await doorOn(session.machine, 'harness.v1.launches.get',{key:session.launch_key},
1989
- { timeout: 30_000, as: boardAddress(board.label) });
2037
+ { timeout, as: boardAddress(board.label) });
1990
2038
  if (r.code !== 0) throw unavailable(`launch receipt unavailable on ${session.machine}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
1991
2039
  if (r.stdout.trim() === 'null') return null;
1992
2040
  const receipt = lastJson(r.stdout);
@@ -2102,7 +2150,10 @@ async function synchronizeAgents(board, report) {
2102
2150
  await yieldToOwner();
2103
2151
  const task=getTaskNow(board,assignment.target);
2104
2152
  if(task&&(assignment.state==='ended'||task.archived||['done','cancelled'].includes(task.status)))finishCardAgentAssignment(board,assignment,`card ${assignment.target} ${task.archived?'archived':task.status}`);
2105
- 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
+ }
2106
2157
  }
2107
2158
  // What a creating or closing verb could not hand on (no daemon running then) is handed on here.
2108
2159
  try{for(const pending of await handOffAgents(board.root))report.queued.push([pending.agent,pending.error]);}
@@ -2142,8 +2193,7 @@ async function reconcileLaunches(board, fleet, report, entry, effects, settings,
2142
2193
  const rows = view(board, (db) => db.prepare(`SELECT id, task_id, started_at, ended_at, metadata FROM task_runs
2143
2194
  WHERE json_extract(metadata, '$.supercode.launch_pending') = 1
2144
2195
  AND json_extract(metadata, '$.supercode.close_confirmed_at') IS NULL`).all());
2145
- // A keyed launch whose session is not bound yet is bound from this round's fleet reading: its pane, and the session
2146
- // 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
2147
2197
  // no pane waits for its launch window (supervise surfaces it then); a pane read absent on its online machine after
2148
2198
  // the grace ended before its session was bound.
2149
2199
  for (const row of rows) {
@@ -2154,6 +2204,16 @@ async function reconcileLaunches(board, fleet, report, entry, effects, settings,
2154
2204
  && ((s.pane && f.pane === s.pane) || (s.session_id && f.session_id === s.session_id)));
2155
2205
  const sid = s.session_id ?? found?.address.split(':')[3];
2156
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
+ }
2157
2217
  // An ended run's launch that bound no session within its window awaits nothing: the run is over, and its pane's
2158
2218
  // close (closeRequested) needs no session. It was reported as awaiting its session every round, for good, once
2159
2219
  // its machine refused that close (t_7ec85b6b run 1203, 2026-10-09).
@@ -2183,7 +2243,7 @@ async function reconcileLaunches(board, fleet, report, entry, effects, settings,
2183
2243
  }
2184
2244
  }
2185
2245
 
2186
- 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 } = {}) {
2187
2247
  const machine = executionHost(lane, task.machine, localMachine(), normalMachine);
2188
2248
  // Whether a board session opens a terminal window on its machine is the workflow's choice, stated on every launch;
2189
2249
  // no machine default decides it.
@@ -2243,6 +2303,43 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2243
2303
  // The pane id is chosen from the launch key and recorded before open runs, so a launch whose answer is lost (a
2244
2304
  // timeout, a closed stream) is still found by its pane in a later round's fleet reading, and by its session id.
2245
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
+ }
2246
2343
  const chosen = `p_${createHash('sha256').update(key).digest('hex').slice(0, 16)}`;
2247
2344
  tx(board, (db) => {
2248
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 });
@@ -2274,12 +2371,12 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2274
2371
  throw Object.assign(new Error(reason), { launchPending: true });
2275
2372
  };
2276
2373
  let session = null;
2277
- let replaced = null;
2374
+ let replaced = null, predecessor = null;
2278
2375
  // A session in no pane is read on its machine before anything replaces it. One the board released (an adopted
2279
2376
  // session it did not launch, left running at its run's end) is never resumed beside itself: while it runs it is kept,
2280
2377
  // and the card's next run is delivered into it, as into any kept session.
2281
2378
  if (prev?.surface === 'native' && !prev.pane && (!prev.close_confirmed_at || prev.released)) {
2282
- 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);
2283
2380
  if (reading.alive == null) throw unavailable(`cannot establish native continuation liveness for ${addressOf(prev)}`);
2284
2381
  fleet.native ??= new Map(); fleet.native.set(addressOf(prev), reading);
2285
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`);
@@ -2288,6 +2385,32 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2288
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`);
2289
2386
  replaced = { session_id: prev.session_id, why: 'the workflow let it go' };
2290
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
+ }
2291
2414
  else if (prev && (prev.harness ?? 'claude-code') !== harness) {
2292
2415
  // the card was reassigned to a lane of another harness: that harness starts it fresh (the card's text and thread
2293
2416
  // are its context), and the last session, if still open, is closed so two harnesses never work one card
@@ -2300,7 +2423,7 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2300
2423
  if (next === 'refuse') throw new Error(`session ${prev.session_id} still has live pane ${prev.pane}; cannot resume or replace it`);
2301
2424
  if (next === 'keep') {
2302
2425
  // the session is kept; the heartbeat's count belongs to the run that ended, so the new run starts it again
2303
- 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;
2304
2427
  session = { ...kept, mode: 'adopt', launched_at: now(), seen_at: now() };
2305
2428
  // a kept session is told what this run is for (its card came back to it, with what happened since), only while
2306
2429
  // this run is still the card's current one (its pane and native reads were awaited)
@@ -2340,7 +2463,8 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2340
2463
  letGo: recoversRuns(workflowFor(board.root)) && Boolean(prev.resume_failed_before_live) && Boolean(prev.close_confirmed_at) };
2341
2464
  } else if (prev) replaced = { session_id: prev.session_id, why: `the card moved from ${prev.machine} to ${machine}` };
2342
2465
  // a card keeps its context: a slot's session that cannot be kept or resumed stops the launch, and only a session the
2343
- // 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
2344
2468
  if (replaced && !prev?.forgotten && !replaced.letGo) {
2345
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`);
2346
2470
  failed.resumeFailed = { session_id: replaced.session_id, slot, why: replaced.why };
@@ -2349,8 +2473,15 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2349
2473
  if (!session) {
2350
2474
  const sid = how.namesItself ? null : randomUUID();
2351
2475
  const launchKey = `board-${task.id}-r${runId}-${(sid ?? randomUUID()).slice(0,8)}`;
2352
- session = await openOwned(launchKey, sid, 'fresh', [...base, '--key', launchKey,
2353
- '--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]);
2354
2485
  if (replaced) session.replaces = replaced.session_id;
2355
2486
  }
2356
2487
  session.harness = session.harness ?? harness;
@@ -2381,6 +2512,16 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2381
2512
  return session;
2382
2513
  }
2383
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
+
2384
2525
  /**
2385
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
2386
2527
  * its native process): an open session that is live is kept, and the run is delivered into it; a live pane holding a
@@ -2469,7 +2610,7 @@ function rotateLog(file, max, backups) {
2469
2610
  /** A headless card's directory on this machine, made as Hermes makes it (scratch, dir, or a git worktree). */
2470
2611
  async function resolveLocalWorkspace(board, task) {
2471
2612
  const root = board.root;
2472
- 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;
2473
2614
  const dir = task.workspace_path || (board.slug === 'default' ? join(root, 'kanban', 'workspaces', task.id) : join(root, 'kanban', 'boards', board.slug, 'workspaces', task.id));
2474
2615
  mkdirSync(dir, { recursive: true });
2475
2616
  return dir;
@@ -2661,7 +2802,7 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
2661
2802
  });
2662
2803
  progress.claimed = request !== null;
2663
2804
  // The adopted session joins the role's agent as a launch does: its identity minted on this host, never asked of Teams.
2664
- 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;
2665
2806
  if(agent)recordCardAgentAssignment(board,cardAgentAssignments(board.root,id).find(assignment=>assignment.org===team.org&&assignment.id===agent.assignment));
2666
2807
  // A session another agent already owns is refused before anything is recorded: the run would name one agent and the
2667
2808
  // agent store another, and every round after would trip on it.
@@ -2862,7 +3003,7 @@ export async function fileBoardMail(board, report, taskId = null) {
2862
3003
  const first = mail.members[0];
2863
3004
  try {
2864
3005
  const task = view(board, db => getTask(db, first.task_id));
2865
- const subject = 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'}`);
2866
3007
  const filed = await fileMail(board, mail.to, mail.text, mail.message_id ?? `board-message-${first.id}`, { ask: Boolean(mail.message_id), subject });
2867
3008
  tx(board, db => {
2868
3009
  if (filed.detached) {
@@ -2893,7 +3034,7 @@ export function noticeOf(entry, audience, kind, task, payload, comment = null) {
2893
3034
  if (template === undefined || template === null) return null;
2894
3035
  const cut = (text, n) => String(text ?? '').slice(0, Number(n));
2895
3036
  const text = render(template, { card: task, event: payload ?? {}, comment, params: entry.params, cut });
2896
- return text.trim() ? text : null;
3037
+ return text.trim() ? numbered(task?.key, task?.number, text) : null;
2897
3038
  }
2898
3039
 
2899
3040
  /**
@@ -2920,6 +3061,42 @@ function ensureSubscriptions(board, entry) {
2920
3061
  /** The event kinds an audience hears (the workflow's notify). */
2921
3062
  const heardBy = (entry, audience) => Object.keys(entry.workflow.notify?.[audience] ?? {});
2922
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
+
2923
3100
  /**
2924
3101
  * The board's record of each session address: `held`, every address an unended or current run works in, and `latest`,
2925
3102
  * the latest run that names it. A Claude or Codex session resumed by id keeps its address, so a run that resumes it
@@ -2942,6 +3119,21 @@ function sessionRecord(db) {
2942
3119
  */
2943
3120
  const recordedEnded = ({ held, latest }, address) => Boolean(sessionAt(address)) && !held.has(address) && Boolean(latest.get(address)?.session.close_confirmed_at);
2944
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
+
2945
3137
  /**
2946
3138
  * The worker subscriptions of sessions that have ended, removed in one transaction, with one notice_unsubscribed event
2947
3139
  * per session naming its cards. A session has ended when no unended or current run works in it, the fleet does not list
@@ -2986,8 +3178,10 @@ async function endWorkerSubscriptions(board, fleet, report) {
2986
3178
  * write under the notice's fixed key; the mailbox delivers it to the session, on this machine or through Teams on
2987
3179
  * another. Each subscription's notices go in order, and its cursor moves past a notice once it is filed (or passed
2988
3180
  * over); a filing that failed here leaves the cursor before it, and the next pass files it. A session the board's own
2989
- * record says has ended (recordedEnded) is filed nothing, in any role, until it is read running again: its cursor
2990
- * 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.
2991
3185
  */
2992
3186
  async function notify(board, fleet, report, entry) {
2993
3187
  await endWorkerSubscriptions(board, fleet, report);
@@ -2998,34 +3192,45 @@ async function notify(board, fleet, report, entry) {
2998
3192
  if (view(board, db => personCustody(db, sub.chat_id))) continue;
2999
3193
  if (sub.delivery_metadata?.dependent_card && sub.delivery_metadata.waiting_recipient) continue;
3000
3194
  const role = ['worker', 'dependency', 'manager'].includes(sub.delivery_metadata?.role) ? sub.delivery_metadata.role : 'creator';
3001
- // Native notices wait with their cursor intact until their own door proves
3002
- // 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.
3003
3216
  // Agent, board and operator mailboxes route independently of a native PID.
3004
3217
  if (sessionAt(sub.chat_id)) {
3005
- 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;
3006
3219
  if (alive !== true) {
3007
- 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');
3008
3221
  continue;
3009
3222
  }
3010
3223
  }
3011
- // a session the fleet does not list this pass is told next pass: one reading is not a loss (a loaded box drops
3012
- // sessions from a read, and a dropped subscription never came back), and a session that is replaced on its card
3013
- // 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.
3014
3225
  const ended = recordedEnded(record, sub.chat_id);
3015
3226
  if (!sessionAt(sub.chat_id) && (role !== 'creator' || ended) && fleet.ok) {
3016
3227
  const alive = await subscriberAlive(fleet, sub.chat_id);
3017
- if (alive === false) continue;
3228
+ if (alive === false) { noticeWait(board, sub, report, 'its session is not running; its notices wait'); continue; }
3018
3229
  // unknown is not absent: its notices wait, and the wait is said, until its liveness can be read
3019
- 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; }
3020
3231
  } else if (!sessionAt(sub.chat_id) && ended) continue; // native sessions already required a current live observation
3021
- const kinds = heardBy(entry, role);
3022
- if (!kinds.length) continue;
3023
- // The retained cursor resumes the next page; one native read never materializes the whole unread history.
3024
- 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`)
3025
- .all(sub.task_id, sub.last_event_id, ...kinds);
3026
- if (!events.length) continue;
3027
3232
  // the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's)
3028
- 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; });
3029
3234
  // a worker whose run has ended is not woken by a notice (a second session would start working the card beside
3030
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
3031
3236
  // thread. A subtask has no run of its own: its worker works in its arc's run.
@@ -3043,18 +3248,18 @@ async function notify(board, fleet, report, entry) {
3043
3248
  // is its own fact, keyed by the card it names); a comment or an answer is always its own message. Only events that
3044
3249
  // would be told count: a stale one or the recipient's own act never supersedes one that would be.
3045
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 ?? ''}`; };
3046
- 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]));
3047
3252
  let cursor = Number(sub.last_event_id);
3048
3253
  try {
3049
3254
  for (const ev of events) {
3050
3255
  const p = json(ev.payload, {}) ?? {};
3051
3256
  if (stale(ev)) { cursor = ev.id; report.stale_notices_passed = (report.stale_notices_passed ?? 0) + 1; continue; }
3052
3257
  // nobody is told of their own act
3053
- if (p.by && p.by === sub.chat_id) { cursor = ev.id; continue; }
3258
+ if (ownAct(p)) { cursor = ev.id; continue; }
3054
3259
  if (latest.get(subjectOf(ev)) !== ev.id) { cursor = ev.id; report.superseded_notices_passed = (report.superseded_notices_passed ?? 0) + 1; continue; }
3055
3260
  let text;
3056
3261
  if (ev.kind === 'commented') {
3057
- if (p.author === sub.chat_id) { cursor = ev.id; continue; }
3262
+ if (recipientActors.has(p.author)) { cursor = ev.id; continue; }
3058
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);
3059
3264
  text = c ? noticeOf(entry, role, ev.kind, task, p, c) : null;
3060
3265
  } else text = noticeOf(entry, role, ev.kind, task, p);
@@ -3067,7 +3272,7 @@ async function notify(board, fleet, report, entry) {
3067
3272
  if (Number(currentSub.last_event_id) >= ev.id) { cursor = Math.max(cursor, Number(currentSub.last_event_id)); continue; }
3068
3273
  const body = ['worker', 'dependency'].includes(role) ? identifyNotice(ev, text) : text;
3069
3274
  // filed under its fixed key: a second pass filing the same notice files nothing new
3070
- const filed = await fileMail(board, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { wake, subject: `${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}`),
3071
3276
  eligible: () => view(board, db => {
3072
3277
  const currentSub = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where}`).get(...identity);
3073
3278
  return Boolean(currentSub) && Number(currentSub.last_event_id) < ev.id;
@@ -3078,8 +3283,9 @@ async function notify(board, fleet, report, entry) {
3078
3283
  cursor = ev.id;
3079
3284
  forward(cursor);
3080
3285
  }
3286
+ noticeWait(board, sub, report);
3081
3287
  } catch (error) {
3082
- report.undelivered.push([sub.task_id, sub.chat_id, String(error.message).slice(-200)]);
3288
+ noticeWait(board, sub, report, String(error.message).slice(-200));
3083
3289
  }
3084
3290
  forward(cursor);
3085
3291
  // a subscription past its card's last event ends with the card: a worker's when the card is done or archived (its
@@ -3120,7 +3326,7 @@ export async function notifyChats(root, adapters, log = () => {}, wake = null) {
3120
3326
  if (sub.platform === 'supercode' || !adapter) continue;
3121
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);
3122
3328
  if (!events.length) continue;
3123
- 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; });
3124
3330
  let cursor = Number(sub.last_event_id);
3125
3331
  let failed = false;
3126
3332
  for (const ev of events) {
@@ -3354,7 +3560,7 @@ async function turnInFlight(board, id, fleet, heldSince = null) {
3354
3560
  if (!session?.session_id || session.surface === 'headless' || session.close_requested) return null;
3355
3561
  let live = fleet.sessions?.find((x) => x.address === addressOf(session)) ?? null;
3356
3562
  if (live?.turn == null && session.surface === 'native') try {
3357
- 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;
3358
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;
3359
3565
  } catch { /* unread: waits on nothing */ }
3360
3566
  const turn = live?.turn ?? null;
@@ -3379,6 +3585,8 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
3379
3585
  const candidates = view(board, db => db.prepare(`SELECT t.id FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
3380
3586
  WHERE c.subtask_of IS NULL AND c.every IS NULL
3381
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 = [];
3382
3590
  for (const { id } of candidates) {
3383
3591
  const eligible = db => {
3384
3592
  const task = getTask(db, id);
@@ -3392,22 +3600,30 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
3392
3600
  return open.length ? open : true;
3393
3601
  };
3394
3602
  const ready = view(board, eligible);
3395
- if (!ready) continue;
3396
- 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; }
3397
3605
  let payload = null;
3398
3606
  try {
3399
3607
  payload = await openReviewPullRequests(board, id);
3400
- // The arc's open PRs ask for one review only while some head has no verdict: after changes are
3401
- // 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).
3402
3611
  // An arc that closed its tasks having changed no code gets its one review of the act itself (ADR 0003).
3403
3612
  if (!payload && noCodeReviewDue(board, id)) payload = await noCodeChange(board, id);
3404
3613
  // An arc whose last verdict is a pass, with nothing unjudged since, waits on no review: what is left is its
3405
3614
  // session's done request and the manager's or creator's `complete`, and the round says that (volter-org
3406
3615
  // t_b499d0cf and t_a2bd2a5f were logged as waiting for a review every round for hours after theirs passed).
3407
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; }
3408
- 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
+ }
3409
3622
  if (!payload.no_code && !await reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
3410
- 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.` });
3411
3627
  continue;
3412
3628
  }
3413
3629
  // A native turn holds the advisory proposal before authoritative source reads. Retain the proposal on the run
@@ -3446,11 +3662,66 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
3446
3662
  }));
3447
3663
  });
3448
3664
  if (moved) {
3665
+ settled.push(id);
3449
3666
  effects.push(...moved.effects);
3450
3667
  (report.review_requested ??= []).push({ id, prs: payload.pr, heads: payload.pr_head });
3451
3668
  }
3452
3669
  } catch (error) { report.queued.push([id, `PR review waits${payload ? ` (${payload.pr.join(', ')})` : ''}: ${error.message}`]); }
3453
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
+ });
3454
3725
  }
3455
3726
 
3456
3727
  /**
@@ -3478,6 +3749,7 @@ export function startDelivery(root, { slugs = null, log = () => {} } = {}) {
3478
3749
  for (const r of reports) {
3479
3750
  if (r.error) log('warn', `delivery on board ${r.board} failed: ${r.error}`);
3480
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`);
3481
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(', ')}`);
3482
3754
  }
3483
3755
  }, (error) => log('warn', `delivery pass failed: ${error?.message ?? error}`)).finally(() => deliveries.delete(root));
@@ -3514,6 +3786,7 @@ export async function deliver({ root, slugs = null, controller = false }) {
3514
3786
  const boards = (slugs ?? listBoards(root)).filter((slug) => !controller || dispatchingOf(root, slug).on);
3515
3787
  if (!boards.length) return [];
3516
3788
  const fleet = await observeFleet();
3789
+ fleet.noticeAlive = await noticeSessions();
3517
3790
  const out = [];
3518
3791
  for (const slug of boards) {
3519
3792
  if (controller && !dispatchingOf(root, slug).on) continue;