@volter/supercode-orchestrator 0.5.227 → 0.5.229

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +18 -2
  2. package/activation.mjs +1 -1
  3. package/adapters/agent-mailbox.mjs +10 -0
  4. package/adapters/api-server.mjs +6 -0
  5. package/agents.mjs +149 -40
  6. package/apply/doors.mjs +3 -1
  7. package/bin/agent-channel.mjs +7 -2
  8. package/bin/connection-runner.mjs +60 -19
  9. package/bin/hermes-worker.mjs +2 -1
  10. package/bin/orchestrator.mjs +3 -3
  11. package/bin/runtime.mjs +3 -2
  12. package/board/agent-current.mjs +82 -0
  13. package/board/agent-declarations.mjs +10 -3
  14. package/board/args.mjs +137 -6
  15. package/board/call.mjs +16 -1
  16. package/board/caller-session.mjs +3 -1
  17. package/board/capacity.mjs +59 -45
  18. package/board/card-state.mjs +14 -5
  19. package/board/ci.mjs +521 -178
  20. package/board/cli.mjs +614 -209
  21. package/board/client.mjs +64 -21
  22. package/board/close-current.mjs +36 -0
  23. package/board/current-records.mjs +44 -0
  24. package/board/custody.mjs +2 -1
  25. package/board/dispatch.mjs +1973 -846
  26. package/board/doors.mjs +2 -2
  27. package/board/engine.mjs +623 -85
  28. package/board/event-stream.mjs +10 -5
  29. package/board/fence.mjs +2 -0
  30. package/board/files.mjs +93 -62
  31. package/board/generation-owner.mjs +188 -53
  32. package/board/git-document.mjs +3 -1
  33. package/board/github-rest.mjs +29 -7
  34. package/board/incidents.mjs +5 -17
  35. package/board/listing.mjs +14 -8
  36. package/board/local-admission.mjs +59 -0
  37. package/board/lock-holder.mjs +18 -6
  38. package/board/machine-fleet.mjs +66 -13
  39. package/board/owner-door-worker.mjs +3 -3
  40. package/board/owner-door.mjs +17 -7
  41. package/board/owner-health.mjs +12 -15
  42. package/board/owner-interface.mjs +6 -0
  43. package/board/publication-reader.mjs +50 -0
  44. package/board/published.mjs +40 -12
  45. package/board/queries.mjs +24 -4
  46. package/board/record-view.mjs +30 -3
  47. package/board/request-mail.mjs +37 -6
  48. package/board/resting-admission.mjs +318 -0
  49. package/board/resting-codec-qualifications.json +33 -0
  50. package/board/resting-protocol.mjs +71 -0
  51. package/board/resting-state-protocol.json +120 -0
  52. package/board/resting-state.mjs +113 -0
  53. package/board/runtime-door.mjs +2 -2
  54. package/board/session-continuity.mjs +127 -0
  55. package/board/session-work.mjs +25 -13
  56. package/board/standalone-declaration.mjs +24 -0
  57. package/board/storage-contract.mjs +129 -7
  58. package/board/store.mjs +73 -11
  59. package/board/tools.mjs +56 -13
  60. package/board/wake.mjs +18 -5
  61. package/board/workflow.mjs +284 -82
  62. package/board/workspace.mjs +86 -9
  63. package/board/ztrack.mjs +344 -91
  64. package/connection.mjs +72 -17
  65. package/gates.mjs +2 -1
  66. package/hand-off.mjs +4 -3
  67. package/hermes-cli.mjs +5 -3
  68. package/hermes-locks.mjs +5 -2
  69. package/hosted.mjs +2 -1
  70. package/index.d.ts +2 -0
  71. package/index.mjs +1 -1
  72. package/kanban-worker.mjs +63 -13
  73. package/loop.mjs +33 -0
  74. package/mcp/hermes-kanban-tools.json +109 -50
  75. package/mcp/server.mjs +7 -1
  76. package/ontology.d.ts +26 -0
  77. package/orchestration.mjs +15 -4
  78. package/package.json +19 -12
  79. package/session-recovery.mjs +2 -1
  80. package/worker-route.mjs +37 -19
@@ -1,3 +1,5 @@
1
+ import { registerWorkOperations, measureWork, stopWorkChild } from "@volter/supercode-harness-sdk/work-ledger";
2
+ registerWorkOperations([{"kind":"pass","operation":"board.round"}]);
1
3
  // The board's dispatcher (docs/architecture/orchestrator.md §2.9): one tick reads the fleet through supercode,
2
4
  // supervises every running card, promotes cards whose parents are done, starts what capacity allows, and tells
3
5
  // subscribers what happened. Hermes's dispatcher is the behaviour it keeps parity with (claims, dependencies,
@@ -22,49 +24,54 @@ import { machineCall } from './doors.mjs';
22
24
  import { detachedCard, detachedSession, personCustody, personOf, sessionOperation, takeSessionCustody } from './custody.mjs';
23
25
  import { processSession } from './caller-session.mjs';
24
26
  import { callerOf, current, isCallersCall, ownCall } from './call.mjs';
25
- import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
27
+ import { noteStep, recordSlow, timed } from '@volter/supercode-harness-sdk/slow-log';
26
28
  import { monitorsRuns, recoversRuns, retainRecovery, failedObservation, surfaceIncident, auditEndedRuns, originOf } from './incidents.mjs';
27
- import { readMachineFleet } from './machine-fleet.mjs';
29
+ import { heldPanes, readMachineFleet } from './machine-fleet.mjs';
30
+ import { currentRecordChanges } from './current-records.mjs';
31
+ import { listRevision } from './listing.mjs';
28
32
  import { homeFleetMachines } from './fleet-targets.mjs';
29
33
  import { delegatedWork, idleInterval, questionText, resumedWatch } from './session-work.mjs';
30
- import { localSessionDelegations } from '../agents.mjs';
31
- import { cardSectionHeading, creatorOf, sessionProgress } from './workflow.mjs';
34
+ import { cardRoleAssignment, localSessionDelegations } from '../agents.mjs';
35
+ import { cardSectionHeading, closeKeptReviewer, creatorOf, queueCleanup, sessionProgress } from './workflow.mjs';
32
36
  import { boardLabel, boardName } from '@volter/teams/board-name';
33
37
  let fleetRoot = null;
34
- import { inspectCompletionEvidence, returnedReview, noCodeChange, openReviewPullRequests, reviewDue, settledReviewDue, prReviewedHead, withCompletionEvidence, withFreshReviewReads, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
38
+ import { discoverPullRequest, discoverQueuedPullRequest, openListObservedAt, redeclaredSince, inspectCompletionEvidence, returnedReview, noCodeChange, openReviewPullRequests, reviewDue, settledReviewDue, prReviewedHead, prDisposition, prIdentity, withCompletionEvidence, withFreshReviewReads, reviewCandidate, pendingReviewRequests, workflowRevision, retainWorkflowTick } from './ci.mjs';
35
39
  import { setImmediate as yieldToOwner } from 'node:timers/promises';
36
- import { ACTIVE_RUN_IDS, latestEvent, readRunPages } from './queries.mjs';
40
+ import { ACTIVE_RUN_IDS, dispatchWait, latestEvent, latestEventIdSql } from './queries.mjs';
37
41
  import { runtimeDoor } from './runtime-door.mjs';
38
- import { agentScope, localCardAgent, localReaderContext, localAgentSessions, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, recordUsageUnread, agentPreamble, agentEnvironment, recordAgentRun, cardAgentAssignments,renameCardAgent,handOffAgents, agentRunForSession } from '../agents.mjs';
42
+ import { agentScope, localCardAgent, localReaderContext, localAgentSessions, runBudgetRefusal, runsAwaitingUsage, pendingUsageOrgs, recordRunUsage, recordUsageUnread, agentPreamble, agentEnvironment, recordAgentRun, cardAgentAssignments,renameCardAgent,handOffAgents, agentRunForSession } from '../agents.mjs';
39
43
  import { finishCardAgentAssignment, recordCardAgentAssignment } from './agent-declarations.mjs';
44
+ import { establishedSlot, replacementFor, issueReplacement, requireReplacementTeardown, continuationRefusal, closeRefused, slotGeneration } from './session-continuity.mjs';
45
+ import { resolveAgentProfile } from '../agent-profiles.mjs';
40
46
  import { materializeAgentHome } from '../agent-home.mjs';
41
47
  import { installedAgentRecords } from '../installed-apps.mjs';
42
48
  import { memorySnapshot } from '../hermes-cli.mjs';
43
- import { hermesModel, codexPaneRoute } from '../worker-route.mjs';
49
+ import { hermesModel, codexPaneRoute, codexPaneRouteArguments } from '../worker-route.mjs';
44
50
  import { refreshVault } from '../orchestration.mjs';
45
- import { projectCodexPaneResume } from '../codex-pane-resume.mjs';
46
51
  import { codexPaneInputs } from './codex-pane-inputs.mjs';
47
52
  import { declareWorkerHome } from '../activation.mjs';
48
53
  import { MODEL_ARGUMENTS, NAME_ARGUMENTS } from '../activation.mjs';
49
54
  import { createRequire } from 'node:module';
50
55
  import { spawn as spawnChild, execFile } from 'node:child_process';
51
- import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
56
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
52
57
  import { homedir, hostname, tmpdir, totalmem } from 'node:os';
53
- import { basename, dirname, join } from 'node:path';
58
+ import { basename, dirname, join, win32 } from 'node:path';
54
59
  import { createHash, randomUUID } from 'node:crypto';
55
60
  import { fileURLToPath } from 'node:url';
56
61
  import { AsyncLocalStorage } from 'node:async_hooks';
57
- import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, NOTICE_RUN_IDS, openBoard, openBoardSnapshot, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
58
- import { doors, fire, ownCoder, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, unrelayedStateAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
59
- import { branchClaim, checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace, workspaceTask } from './workspace.mjs';
62
+ import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, CLOSE_OPEN_RUNS, runField, readableIdOf, briefPath, actorOf, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, NOTICE_RUN_IDS, openBoard, openBoardSnapshot, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
63
+ import { doors, fire, ownCoder, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, unrelayedStateAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedAs, storedStatus, templateValue, workflowFor, settleExpiringFlags, nextFlagExpiry, ownerRule, requestDetail } from './workflow.mjs';
64
+ import { branchClaim, checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace, reviewWorkspaces, workspaceTask } from './workspace.mjs';
60
65
  import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
61
66
  import { evaluate, holds, render } from './expr.mjs';
62
67
  import { cardName, cardNumber, identifyNotice, noticeName, numbered } from './notices.mjs';
63
68
  import { noticeReceiptsInTurns, noticeKey, requireNoticeBasis, noticeWriterBasis } from './notice-receipts.mjs';
64
- import { pendingRequestMail } from './request-mail.mjs';
69
+ import { pendingRequestMail, pendingRequestRows } from './request-mail.mjs';
70
+ import { deferredCloseCurrent, requireDeferredClose } from './close-current.mjs';
71
+ import { currentAgentWork, latestAgentRow } from './agent-current.mjs';
65
72
  import {
66
73
  cardContext, cardLabel, claimTtl, currentRun, DEFAULT_FAILURE_LIMIT, extendClaim, getTask, lastSession,
67
- recomputeReady, runEffects, queueEffects, setRunSession, subscribeIn, replaceWorkerSubscriptions, subscriptions, reconcileDependencySubscriptions, mentioned,
74
+ recomputeReady, runEffects, queueEffects, requestReview, acceptRequest, routeRequest, completeBacklogRequests, setRunSession, subscribeIn, replaceWorkerSubscriptions, subscriptions, reconcileDependencySubscriptions, mentioned,
68
75
  } from './engine.mjs';
69
76
 
70
77
  const WORKER = fileURLToPath(new URL('../bin/hermes-worker.mjs', import.meta.url));
@@ -117,7 +124,7 @@ function sc(args, { input = null, timeout = 120_000, as = null } = {}) {
117
124
  let stdout = '', stderr = '', done = false;
118
125
  const child = spawnChild(supercodeBin(), args, { cwd: tmpdir(), env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
119
126
  const finish = (code, extra = '') => { if (done) return; done = true; clearTimeout(timer); noteStep(`supercode ${args[0]}`, performance.now() - started); resolve({ code, stdout, stderr: stderr + extra }); };
120
- const timer = setTimeout(() => { child.kill('SIGKILL'); finish(null, `supercode ${args[0]} did not answer within ${timeout} ms`); }, timeout);
127
+ const timer = setTimeout(() => { stopWorkChild(child, () => child.kill('SIGKILL'), { signal: 'SIGKILL' }); finish(null, `supercode ${args[0]} did not answer within ${timeout} ms`); }, timeout);
121
128
  child.stdout.setEncoding('utf8').on('data', (chunk) => { stdout += chunk; });
122
129
  child.stderr.setEncoding('utf8').on('data', (chunk) => { stderr += chunk; });
123
130
  child.on('error', (error) => finish(null, error.message));
@@ -159,7 +166,8 @@ export function callerSession() {
159
166
  /** The fleet as the home's machines answer for themselves (machine-fleet.mjs): this machine, and every machine an open
160
167
  * card of the home names or one of its notice subscribers is on. */
161
168
  export async function observeFleet(root = fleetRoot) {
162
- const machines = await homeFleetMachines(root, normalMachine);
169
+ // An unread or moved target set leaves presence unknown for the round, never the previous round's reading.
170
+ const machines = await homeFleetMachines(root, normalMachine).catch((error) => { presenceReading = null; notePresence(null); throw error; });
163
171
  const fleet = await readMachineFleet(machines, localMachine());
164
172
  presenceReading = { known: fleet.presenceKnown === true, online: new Set([...(fleet.unknown?.has(localMachine()) ? [] : [localMachine()]), ...fleet.online]), offline: fleet.offline ?? new Set(), unknown: fleet.unknown ?? new Set(), errors: fleet.errors ?? new Map() };
165
173
  notePresence(presenceReading);
@@ -168,7 +176,7 @@ export async function observeFleet(root = fleetRoot) {
168
176
 
169
177
  // how the launcher opens each harness a pane lane can run, and how it names the session: Claude Code is told the id
170
178
  // it will have (`--session-id`) and resumed by it (`--resume`); Codex names its own session, which the board reads from
171
- // the fleet by the pane it opened, and is resumed by it (`codex resume <id>`)
179
+ // its keyed native launch receipt, and is resumed by it (`codex resume <id>`)
172
180
  const HARNESSES = {
173
181
  'claude-code': { program: 'claude', fresh: (sid) => ['--session-id', sid], resume: (sid) => ['--resume', sid], namesItself: false },
174
182
  codex: { program: 'codex', fresh: () => [], resume: (sid) => ['resume', sid], namesItself: true },
@@ -180,18 +188,43 @@ const HARNESSES = {
180
188
  // machine_offline; a stream that timed out, 504), says nothing about what it was asked: it is tried again next round.
181
189
  const doorTransient = (r) => r.code === null || ['unavailable', 'machine_offline', 'rate_limited', 504, '504', 'ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN'].includes(json(String(r.stderr ?? '').trim().split('\n').pop(), null)?.code);
182
190
  const adoptedOnly = (s) => s?.mode === 'adopt' && !s.launch_key && s.surface !== 'headless';
183
- /** A close the machine refused for good (not deferred): the board will not try it again, and the session runs on. */
184
- const closeRefused = (s) => Boolean(s?.close_failed_at && s.close_error && !/^close deferred/.test(s.close_error) && !s.close_confirmed_at);
185
191
  const addressOf = (s) => s?.address ?? (s?.session_id && s.machine ? `sc:${s.machine}:${s.harness ?? 'claude-code'}:${s.session_id}` : null);
186
192
 
187
- // A session in a live pane of its machine is the fleet's reading; any other run is read on its own machine: a headless
188
- // worker by its pid, a pane by that machine's pane list, a session in no pane (an adopted native one) by that machine's
189
- // harness discovery. Null only when the machine cannot answer.
193
+ // Conversation presence requires the recorded physical store. A fleet row or scoped native activity identifies it;
194
+ // a pane's absence identifies an ended instance, while its existence alone says nothing about its current session.
195
+ // A headless worker is also read by its owned pid. Unread provenance or activity stays unknown.
190
196
  // A pane is read from the machine's record when the fleet reading holds its panes (current, or the machine is not in
191
197
  // it): the pane list asked through its door serves READING_MS, so a pane that ended since that reading was read live
192
198
  // for up to those 15 s more, and a session's loss was read that much late.
193
199
  const paneInRecord = (fleet, session) => session?.surface !== 'headless' && session?.pane && session.machine && fleet?.panes?.has(normalMachine(session.machine)) ? fleet.panes.get(normalMachine(session.machine)).has(session.pane) : null;
194
- const sessionAlive = (fleet, session) => fleet.native?.has(addressOf(session)) ? fleet.native.get(addressOf(session)).alive : paneInRecord(fleet, session) ?? unpublishedAlive(session);
200
+ /** Fleet rows of this retained conversation, including its physical native store. */
201
+ const fleetSessionRows = (fleet, session) => {
202
+ const homes = nativeHome(session)?.homes;
203
+ return homes ? (fleet?.sessions ?? []).filter(row => row.address === addressOf(session) && inNativeHome(row, homes)) : [];
204
+ };
205
+ const fleetSessionRow = (fleet, session) => {
206
+ const rows = fleetSessionRows(fleet, session);
207
+ return rows.length === 1 ? rows[0] : rows.find(row => row.pane === session?.pane) ?? null;
208
+ };
209
+ const homeIdentity = (session) => {
210
+ const homes = nativeHome(session)?.homes;
211
+ return homes ? JSON.stringify(Object.entries(homes).sort(([a], [b]) => a.localeCompare(b))) : null;
212
+ };
213
+ const sameNativeSession = (left, right) => Boolean(addressOf(left) && addressOf(left) === addressOf(right) && homeIdentity(left) && homeIdentity(left) === homeIdentity(right));
214
+ function sessionAlive(fleet, session) {
215
+ if (!session) return null;
216
+ if (session.surface === 'headless' && session.pid) return pidAlive(session.pid);
217
+ if (session.session_id && !nativeHome(session)) return null;
218
+ const published = fleetSessionRows(fleet, session);
219
+ if (published.some(row => row.state === 'running')) return true;
220
+ const native = unpublishedAlive(session);
221
+ if (native === true) return true;
222
+ if (published.length) return null;
223
+ if (native != null) return native;
224
+ const pane = paneInRecord(fleet, session);
225
+ // A live pane alone cannot identify the conversation now running in it.
226
+ return !session.session_id || pane === false ? pane : null;
227
+ }
195
228
  /** A subscriber's address as the session it names, for sessionAlive (its machine, harness and native id). */
196
229
  const sessionAt = (address) => { const m = /^sc:([^:]+):([^:]+):(.+)$/.exec(String(address)); return m && HARNESSES[m[2]] ? { address, machine: normalMachine(m[1]), harness: m[2], session_id: m[3] } : null; };
197
230
  /** An agent's address (`sc:<machine>:agent:<name>`) as its machine and name, or null. */
@@ -231,45 +264,38 @@ async function subscriberAlive(fleet, address) {
231
264
  if (!session) return false;
232
265
  await readBefore(fleet, session);
233
266
  const alive = sessionAlive(fleet, session);
234
- if (alive == null && subscriberGone(fleet, address)) return false;
235
267
  return alive ?? null;
236
268
  }
237
- /**
238
- * A subscriber that is gone: no transcript, no process and no pane. Its machine's discovery answered whole (no source
239
- * failed, nothing truncated) without the session, the fleet lists no pane for it, and the reading came from a daemon
240
- * whose index names a Claude subagent by its own file (#1423, marked `subagent_rows: 'own'` on its discovery): an older
241
- * daemon's index dropped a parent session that ran a subagent, so its absence there is never read as gone.
242
- */
243
- function subscriberGone(fleet, address) {
244
- if (fleet.noticeAlive?.has(address) || fleet.alive.has(address) || fleet.native?.has(address)) return false;
245
- const session = sessionAt(address);
246
- const seen = session && discoveries.get(nativeKey(session.machine, session.harness, session.session_id));
247
- if (!seen?.complete) return false;
248
- return !seen.sessions.some((r) => r.locator?.session_id === session.session_id && r.locator?.harness === session.harness);
249
- }
250
269
  const paneLists = new Map();
251
270
  /** A session the fleet reading did not publish: alive by what was read of it before (readBefore), else unknown (null). */
252
271
  function unpublishedAlive(session) {
253
272
  if (!session) return null;
254
273
  if (session.surface === 'headless' && session.pid) return pidAlive(session.pid);
255
274
  if (!session.machine) return null;
256
- if (!session.pane) {
257
- if (!session.session_id) return null;
258
- try { return nativeReading(nativeSessions(session.machine, session.harness ?? 'claude-code', session.session_id), session).alive; } catch { return null; }
259
- }
275
+ if (session.session_id) try {
276
+ const reading = nativeReading(nativeSessions(session), session).alive;
277
+ if (reading != null) return reading;
278
+ } catch { /* unread native identity */ }
279
+ if (!session.pane) return null;
260
280
  const entry = paneLists.get(session.machine);
261
281
  if (!entry || Date.now() - entry.at > READING_MS) return null;
262
- return entry.panes ? entry.panes.some((p) => p.pane === session.pane && !p.dead) : null;
282
+ if (!entry.panes) return null;
283
+ const present = entry.panes.some((p) => p.pane === session.pane && !p.dead);
284
+ return !session.session_id || !present ? present : null;
263
285
  }
264
- /** A machine's pane list, read through its door (one reading serves READING_MS). */
265
- async function readPaneList(machine) {
266
- const entry = paneLists.get(machine);
267
- if (entry && Date.now() - entry.at <= READING_MS) return entry;
268
- const r = await doorOn(machine, 'harness.v1.panes.list', {}, { timeout: 60_000 });
269
- const panes = r.code === 0 ? json(r.stdout.trim(), null) : null;
270
- const read = { at: Date.now(), panes: Array.isArray(panes) ? panes : null };
271
- paneLists.set(machine, read);
272
- return read;
286
+ /**
287
+ * A machine's panes as panes.list rows (`list`, null when unread, with `error` saying why): its held record's, read at
288
+ * this moment where that record is current (`held`: its rows and the session under each live pane, no door call), else
289
+ * its door's, as before (a record not current is no reading of the machine, and its door still answers, so a close,
290
+ * launch or adoption there acts as it did). A list asked as the board (`options.as`: a close) is always its door's, which
291
+ * lists only the panes the board's grants there let it observe.
292
+ */
293
+ async function paneRows(machine, options = { timeout: 60_000 }) {
294
+ const held = options.as ? null : heldPanes(normalMachine(machine ?? localMachine()));
295
+ if (held) return { list: held.panes, held };
296
+ const r = await doorOn(machine, 'harness.v1.panes.list', {}, options);
297
+ const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
298
+ return { list: Array.isArray(list) ? list : null, held: null, error: (r.stderr || r.stdout).trim().slice(-300) };
273
299
  }
274
300
 
275
301
  /** Native activity comes from the machine's harness discovery, not catalogue age: each session the round reads is
@@ -278,7 +304,7 @@ async function readPaneList(machine) {
278
304
  const discoveries = new Map();
279
305
  // A machine's pane list and harness discovery are read before a board write opens, never inside one: a door that
280
306
  // takes seconds would hold the board's write lock past a worker's own command's busy wait. Reading is async (readNative,
281
- // readPaneList, readBefore: the doors in this process, never holding the event loop); every decision reads what was
307
+ // paneRows, readBefore: the doors in this process, never holding the event loop); every decision reads what was
282
308
  // read (nativeSessions, unpublishedAlive, sessionAlive), and a reading not taken yet is unknown. One reading serves
283
309
  // READING_MS.
284
310
  const READING_MS = 15_000;
@@ -286,60 +312,99 @@ const txRead = (board, fn) => tx(board, fn);
286
312
  /** Take the readings a decision or a board write about `session` will need, before it. */
287
313
  async function readBefore(fleet, session) {
288
314
  if (!session?.machine || session.surface === 'headless') return;
289
- // A pane may use a different Codex home than its daemon. Its native activity
290
- // locator names the source to discover; the daemon's default can miss it.
291
- const locator = fleet?.sessions?.find(row => row.address === addressOf(session))?.locator;
292
- const codexHome = session.codex_home ?? (session.harness === 'codex' && locator?.storage?.kind === 'file'
293
- ? /^(.*)[\\/]sessions[\\/]/.exec(locator.storage.path)?.[1] : null);
294
- if (session.session_id) try { await readNative(session.machine, session.harness ?? 'claude-code', session.session_id, codexHome); } catch { /* unread: unknown */ }
295
- if (session.pane && !fleet?.native?.has(addressOf(session))) await readPaneList(session.machine);
315
+ const homes = (await readNativeHome(session))?.homes;
316
+ if (session.session_id && homes) {
317
+ const harness = session.harness ?? 'claude-code', key = nativeKey(session.machine, harness, session.session_id, homes);
318
+ const held = discoveries.has(key) && discoveries.get(key).activity !== false ? null : heldNative(fleet, session.machine, harness, session.session_id, homes);
319
+ if (held) discoveries.set(key, { at: Date.now(), sessions: [held] });
320
+ else try { await readNative(session.machine, harness, session.session_id, homes); } catch { /* unread: unknown */ }
321
+ }
322
+ // a pane the round's fleet reading holds no session for, on a machine whose record that reading did not hold: its
323
+ // machine's pane list (one reading serves READING_MS), for unpublishedAlive
324
+ if (session.pane && paneInRecord(fleet, session) == null) {
325
+ const entry = paneLists.get(session.machine);
326
+ if (!entry || Date.now() - entry.at > READING_MS) paneLists.set(session.machine, { at: Date.now(), panes: (await paneRows(session.machine)).list });
327
+ }
328
+ }
329
+ /**
330
+ * A session's discovery row as its machine's record holds it (the fleet reading's `held`: the activity its machine
331
+ * follows of each live session, its locator, folder and last write), or null where the record holds none (a session
332
+ * not live there, one past the sessions its machine follows, a record not current): that one is discovered, as before.
333
+ * A held row says nothing of another session's absence.
334
+ * Codex activity is read through its own scoped discovery, including its app-server status.
335
+ */
336
+ function heldNative(fleet, machine, harness, sessionId, homes) {
337
+ if (harness === 'codex') return null;
338
+ return fleet?.held?.get(`sc:${normalMachine(machine)}:${harness}:${sessionId}`)?.find(row =>
339
+ row.locator?.harness === harness && row.locator.session_id === sessionId && inNativeHome(row, homes)) ?? null;
340
+ }
341
+
342
+ /** Retained launch provenance, including the original Codex run contract. */
343
+ const legacyNativeHomes = new Map();
344
+ const legacyHomeKey = session => JSON.stringify([normalMachine(session.machine), session.session_id ?? session.launch_key, session.codex_home]);
345
+ function nativeHome(session) {
346
+ if (session?.native_home) return session.native_home;
347
+ if (session?.codex_home && (session.harness ?? 'claude-code') === 'codex')
348
+ return legacyNativeHomes.get(legacyHomeKey(session)) ?? null;
349
+ return null;
350
+ }
351
+ /** The machine's file door resolves an existing legacy store, including aliases, without reading its transcript. */
352
+ async function readNativeHome(session) {
353
+ const held = nativeHome(session);
354
+ if (held || !session?.codex_home || session.harness !== 'codex' || !session.machine) return held;
355
+ const path = (session.codex_home.includes('\\') ? win32.join : join)(session.codex_home, 'sessions');
356
+ const answer = await doorOn(session.machine, 'files.stat', { path }, { timeout: NATIVE_DISCOVERY_MS });
357
+ const source = answer.code === 0 ? lastJson(answer.stdout) : null;
358
+ if (source?.kind !== 'directory' || typeof source.path !== 'string') return null;
359
+ const home = { homes: { codex: source.path }, env: { CODEX_HOME: session.codex_home } };
360
+ legacyNativeHomes.set(legacyHomeKey(session), home);
361
+ return home;
362
+ }
363
+ function nativeHomes(session) {
364
+ const home = nativeHome(session);
365
+ if (!home) throw unavailable(`native home is unknown for ${addressOf(session)}; no default home was read`);
366
+ return home.homes;
367
+ }
368
+ function inNativeHome(row, homes) {
369
+ const path = row?.locator?.storage?.path;
370
+ return Boolean(homes && typeof path === 'string' && Object.values(homes).some(root => path === root || path.startsWith(root + '/') || path.startsWith(root + '\\')));
371
+ }
372
+ function locatorHome(locator) {
373
+ if (locator?.storage?.kind !== 'file' || !['claude-code', 'codex'].includes(locator.harness)) return null;
374
+ const codex = locator.harness === 'codex', folder = codex ? 'sessions' : 'projects';
375
+ const match = (codex ? /^(.*[\\/])sessions[\\/]/ : /^(.*[\\/])projects[\\/]/).exec(locator.storage.path);
376
+ if (!match) return null;
377
+ return { homes: { [codex ? 'codex' : 'claude_code']: match[1] + folder },
378
+ env: { [codex ? 'CODEX_HOME' : 'CLAUDE_CONFIG_DIR']: match[1].slice(0, -1) } };
296
379
  }
297
380
  /** A session's native discovery as this round read it (readNative); throws (unavailable) when it was not read. */
298
- function nativeSessions(machine, harness, sessionId) {
299
- const seen = discoveries.get(nativeKey(machine, harness, sessionId));
381
+ function nativeSessions(session) {
382
+ const seen = discoveries.get(nativeKey(session.machine, session.harness ?? 'claude-code', session.session_id, nativeHomes(session)));
300
383
  if (seen) return seen.sessions;
301
384
  throw unavailable('native liveness is read before it is used (readBefore), and was not');
302
385
  }
303
- /** Read a session's native discovery through its machine's door, once a round. */
304
- async function readNative(machine, harness, sessionId, codexHome = null) {
305
- const key = nativeKey(machine, harness, sessionId), seen = discoveries.get(key);
306
- const home = harness === 'codex' ? codexHome ?? seen?.codex_home ?? null : null;
307
- if (seen && (seen.codex_home ?? null) === home) return seen.sessions;
308
- const { sessions, complete } = await discoverNative(machine, harness, sessionId, home);
309
- discoveries.set(key, { at: Date.now(), sessions, complete, codex_home: home });
386
+ /** Read a session's native discovery through its machine's door, once a round. `activity: false`: only its rows (its
387
+ * locator, folder, whether its machine's index holds it), read from the index with no activity read for them; a
388
+ * reading kept without activity is read again, with it, by the next caller that needs its activity. */
389
+ async function readNative(machine, harness, sessionId, homes = null, { activity = true } = {}) {
390
+ const key = nativeKey(machine, harness, sessionId, homes), seen = discoveries.get(key);
391
+ if (seen && (seen.activity !== false || !activity)) return seen.sessions;
392
+ const { sessions } = await discoverNative(machine, harness, sessionId, homes, { activity });
393
+ discoveries.set(key, { at: Date.now(), sessions, ...(activity ? {} : { activity: false }) });
310
394
  return sessions;
311
395
  }
312
396
  // A native discovery is bounded as the fleet reading is: one that does not answer in time is unknown, read next round.
313
397
  const NATIVE_DISCOVERY_MS = 15_000;
314
- const nativeKey = (machine, harness, sessionId) => `${normalMachine(machine)}\u0000${harness}\u0000${sessionId}`;
315
- async function discoverNative(machine, harness, sessionId, codexHome = null) {
398
+ const nativeKey = (machine, harness, sessionId, homes = null) => JSON.stringify([normalMachine(machine), harness, sessionId, homes]);
399
+ async function discoverNative(machine, harness, sessionId, homes = null, { activity = true } = {}) {
316
400
  if (offlineMachine(machine)) throw unavailable(`native liveness unavailable: machine ${normalMachine(machine)} is offline`);
317
401
  // the machine's own harness door, this one's or another's, as every other harness read here is
318
402
  // Liveness uses the native activity receipt, never transcript previews. A name miss can match many retained
319
403
  // rows; opening every one for its messages delays the board's liveness decision for data it never reads.
320
- 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 });
404
+ const result = await doorOn(machine, 'harness.v1.sessions.discover', { harnesses: [harness], query: sessionId, limit: 2000, include_child_sessions: true, include_latest_candidates: false, ...(activity ? {} : { include_activity: false }), ...(homes ? { homes } : {}) }, { timeout: NATIVE_DISCOVERY_MS });
321
405
  const page = result.code === 0 ? lastJson(result.stdout) : null;
322
406
  if (!Array.isArray(page?.sessions)) throw unavailable(`native liveness unavailable on ${machine}: ${(result.stderr || result.stdout).trim().slice(-300)}`);
323
- // whole: every source read and none still being reconciled, nothing truncated, from an index that keeps every
324
- // session's own row (subscriberGone); a source still being read answers from the rows it held before
325
- const complete = page.subagent_rows === 'own' && !(Array.isArray(page.failed) && page.failed.length) && !page.reconciling?.length
326
- && !page.receipt?.truncated && page.next_cursor == null;
327
- return { sessions: page.sessions, complete };
328
- }
329
- /**
330
- * Whether `session`'s machine holds no session of that id at all: its discovery was read whole (every source, nothing
331
- * truncated) and has no row for it. A launch that stopped before its first turn (a question on its screen that no
332
- * input answers) leaves a pane and no transcript, and a harness asked to resume that id exits at once. A reading not
333
- * taken, or not whole, says nothing.
334
- */
335
- async function nativeAbsent(session) {
336
- if (!session?.session_id || !session.machine) return false;
337
- const harness = session.harness ?? 'claude-code';
338
- try {
339
- const rows = await readNative(session.machine, harness, session.session_id, session.codex_home ?? null);
340
- return discoveries.get(nativeKey(session.machine, harness, session.session_id))?.complete === true
341
- && !rows.some((r) => r.locator?.session_id === session.session_id);
342
- } catch { return false; }
407
+ return { sessions: homes ? page.sessions.filter(row => inNativeHome(row, homes)) : page.sessions };
343
408
  }
344
409
  function nativeReading(rows, session) {
345
410
  const row = rows.find(r => r.locator?.session_id === session.session_id && r.locator?.harness === session.harness);
@@ -350,8 +415,7 @@ function nativeReading(rows, session) {
350
415
  /** Match a manual continuation by the same harness identity and an actually live machine pane. */
351
416
  function continuationPane(fleet, session, panes) {
352
417
  if (!session?.session_id || !(panes instanceof Set)) return null;
353
- const candidates = new Set((fleet.sessions ?? []).filter((s) => s.address === addressOf(session) && s.pane && panes.has(s.pane)).map((s) => s.pane));
354
- if (session.pane && panes.has(session.pane)) candidates.add(session.pane);
418
+ const candidates = new Set(fleetSessionRows(fleet, session).filter((s) => s.pane && panes.has(s.pane)).map((s) => s.pane));
355
419
  if (candidates.size > 1) throw new Error(`session ${session.session_id} has multiple live panes: ${[...candidates].join(', ')}; close the extra pane before continuing`);
356
420
  return [...candidates][0] ?? null;
357
421
  }
@@ -359,7 +423,11 @@ function continuationPane(fleet, session, panes) {
359
423
 
360
424
  function pidAlive(pid) {
361
425
  if (!pid) return false;
362
- try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
426
+ try { process.kill(pid, 0); return true; } catch (error) {
427
+ if (error.code === 'ESRCH') return false;
428
+ if (error.code === 'EPERM') return true;
429
+ throw new Error(`could not read worker pid ${pid}: ${error.message}`);
430
+ }
363
431
  }
364
432
 
365
433
  // ------------------------------------------------------------------ settings
@@ -431,7 +499,7 @@ const offlineMachine = (machine) => {
431
499
  // each), so a park that waited for them waited out most of the round, past the owner's 8 s wait for 'parked' (VO-3578
432
500
  // r2454 F11: 13.6 s and 13.0 s of held card verbs). A door that acts (a pane prepared, a session owned) is never cut: it
433
501
  // finishes and the park waits for it.
434
- const READ_DOORS = new Set(['harness.v1.agent.show', 'harness.v1.launches.get', 'harness.v1.panes.capture', 'harness.v1.panes.list',
502
+ const READ_DOORS = new Set(['files.stat', 'harness.v1.agent.show', 'harness.v1.launches.get', 'harness.v1.panes.capture', 'harness.v1.panes.list', 'harness.v1.transcripts.models',
435
503
  'harness.v1.panes.presence', 'harness.v1.sessions.activity_under', 'harness.v1.sessions.discover', 'harness.v1.sessions.load']);
436
504
  const refusal = (code, message) => ({ code: 1, stdout: '', stderr: JSON.stringify({ code, message }) });
437
505
  /** A machine's public door (`harness.v1.*`), answered at once when the machine reads as offline. */
@@ -511,25 +579,84 @@ async function recordBounces(board, fleet, entry, report) {
511
579
  }
512
580
  }
513
581
  } catch (error) { report.undelivered?.push(['mail', 'bounces', String(error.message).slice(-200)]); }
514
- // the first input of each session launched in the last ten minutes, until its machine's launch record settles it
515
- const running = view(board, (db) => db.prepare('SELECT t.id, r.id run_id, r.metadata FROM tasks t JOIN task_runs r ON r.id = t.current_run_id WHERE r.ended_at IS NULL').all());
582
+ // The first input of each session launched within its launch window, until its machine's launch record settles it.
583
+ // A brief is owed until its session receives it: a first input that bounced is filed to that same session again,
584
+ // as its run's instruction, on this round; one still undelivered when the window ends is the launch's incident.
585
+ const { launchWindow } = boardSettings(entry.params);
586
+ const running = view(board, (db) => db.prepare('SELECT t.id, r.id run_id, r.task_id, r.ended_at, r.metadata FROM tasks t JOIN task_runs r ON r.id = t.current_run_id WHERE r.ended_at IS NULL').all());
516
587
  for (const row of running) {
517
588
  const session = json(row.metadata, {})?.supercode;
518
- if (!session?.launch_key || session.first_input || !fleet?.ok || now() - Number(session.launched_at ?? 0) > 600) continue;
589
+ if (!session?.launch_key || session.first_input || !fleet?.ok || now() - Number(session.launched_at ?? 0) > launchWindow) continue;
519
590
  if (!fleet.capabilities?.get(normalMachine(session.machine ?? localMachine()))?.launches) continue;
520
591
  let receipt; try { receipt = await launchReceipt(board, session); } catch { continue; }
521
592
  const settled = receipt?.inputBounced ? 'bounced' : receipt?.inputDelivered === true ? 'delivered' : receipt?.inputRefused || receipt?.inputError ? 'undelivered' : null;
522
593
  if (!settled) continue;
594
+ if (settled === 'bounced' && session.launch_pending) {
595
+ const refiled = bindLaunch(board, { id: row.run_id, task_id: row.id, ended_at: row.ended_at }, session, receipt, { refile: (db, address) => {
596
+ const task = getTask(db, row.id), told = roleBrief(db, board, entry, task, statusOf(db, task));
597
+ if (!told) return;
598
+ const brief = queueBrief(db, board, task, row.run_id, address, told, 'started');
599
+ setRunSession(db, row.run_id, { first_input: 'refiled', brief_owed: brief });
600
+ event(db, row.id, 'first_input_refiled', { launch_key: session.launch_key, pane: session.pane ?? null, address, reason: receipt.inputError ?? null }, row.run_id);
601
+ } });
602
+ if (refiled) { (report.bounced ??= []).push([row.id, session.pane ?? session.launch_key, 'first input filed again']); continue; }
603
+ }
523
604
  tx(board, (db) => {
524
605
  const run = db.prepare('SELECT ended_at, metadata FROM task_runs WHERE id = ?').get(row.run_id);
525
606
  if (!run || run.ended_at || json(run.metadata, {})?.supercode?.first_input) return;
526
607
  setRunSession(db, row.run_id, { first_input: settled });
527
608
  if (settled !== 'bounced') return;
528
609
  event(db, row.id, 'first_input_bounced', { launch_key: session.launch_key, pane: session.pane ?? null, reason: receipt.inputError ?? null }, row.run_id);
529
- tell(db, row.id, `${row.id}: the first input of the session the board launched for it${session.pane ? ` (pane ${session.pane})` : ''} bounced: ${receipt.inputError}. The session runs without its prompt; its quiet is not counted as idleness.`);
610
+ tell(db, row.id, `${row.id}: the first input of the session the board launched for it${session.pane ? ` (pane ${session.pane})` : ''} bounced: ${receipt.inputError}. It could not be filed to its session again; its launch becomes an incident if the launch window ends with its brief undelivered.`);
530
611
  (report.bounced ??= []).push([row.id, session.pane ?? session.launch_key, 'first input']);
531
612
  });
532
613
  }
614
+ await settleOwedBriefs(board, fleet, entry, report, launchWindow);
615
+ }
616
+
617
+ /**
618
+ * A re-filed brief is owed until its session receives it (ADR 0019): the session's own turn reading (its published fleet
619
+ * row, else its native discovery, as turnInFlight reads it) shows a turn state that began after the brief was filed. A
620
+ * session that cannot be read keeps it owed. When the launch window ends, a brief never filed, or a read session with no
621
+ * turn since its filing, makes the launch an incident with that reading as its evidence.
622
+ */
623
+ async function settleOwedBriefs(board, fleet, entry, report, launchWindow) {
624
+ const owed = view(board, db => db.prepare(`SELECT t.id, r.id run_id, r.metadata FROM tasks t JOIN task_runs r ON r.id = t.current_run_id
625
+ WHERE r.ended_at IS NULL AND json_extract(r.metadata,'$.supercode.first_input')='refiled'`).all());
626
+ for (const row of owed) {
627
+ await yieldToOwner();
628
+ if (stopping) return;
629
+ const session = json(row.metadata, {})?.supercode ?? {};
630
+ const filed = view(board, db => db.prepare(`SELECT created_at, json_extract(payload,'$.message_id') AS message FROM task_events
631
+ WHERE task_id=? AND run_id=? AND kind='session_notice_filed' AND json_extract(payload,'$.source_event')=? ORDER BY id DESC LIMIT 1`)
632
+ .get(row.id, row.run_id, session.brief_owed ?? null));
633
+ let reading = null;
634
+ if (filed && fleet?.ok && session.session_id) {
635
+ reading = fleetSessionRow(fleet, session);
636
+ if (reading?.turn == null) try {
637
+ await readNativeHome(session);
638
+ const activity = nativeReading(await readNative(session.machine, session.harness ?? 'claude-code', session.session_id, nativeHomes(session)), session).row?.activity;
639
+ reading = activity ? { turn: activity.turn ?? null, turn_since_ms: activity.evidence?.native_state_since_ms ?? null, turn_source: activity.evidence?.source ?? null } : null;
640
+ } catch { reading = null; }
641
+ if (reading?.turn == null) reading = null;
642
+ }
643
+ if (reading && ['working', 'needs_input', 'idle'].includes(reading.turn) && Number(reading.turn_since_ms) > Number(filed.created_at) * 1000) {
644
+ tx(board, db => { if (currentRun(db, row.id)?.id === row.run_id) setRunSession(db, row.run_id, { first_input: 'delivered', brief_owed: null }); });
645
+ continue;
646
+ }
647
+ if (now() - Number(session.launched_at ?? 0) < launchWindow || (filed && !reading)) continue;
648
+ const error = filed ? `its re-filed brief ${filed.message} shows no turn in its session since it was filed when the launch window ended`
649
+ : 'its re-filed brief was never filed before the launch window ended';
650
+ const evidence = { error, brief_event: session.brief_owed ?? null, message_id: filed?.message ?? null, filed_at: filed?.created_at ?? null,
651
+ reading: reading ? { turn: reading.turn, turn_since_ms: reading.turn_since_ms ?? null, source: reading.turn_source ?? null } : null,
652
+ launch_key: session.launch_key ?? null, pane: session.pane ?? null, launch_window_seconds: launchWindow };
653
+ tx(board, db => {
654
+ if (currentRun(db, row.id)?.id !== row.run_id) return;
655
+ setRunSession(db, row.run_id, { first_input: 'undelivered', brief_owed: null });
656
+ surfaceIncident(db, board, entry, row.id, 'spawn_failed', evidence, row.run_id);
657
+ });
658
+ report.failed.push([row.id, error]);
659
+ }
533
660
  }
534
661
 
535
662
  /**
@@ -578,7 +705,7 @@ async function supervise(board, fleet, settings, report, entry, effects) {
578
705
  const run = currentRun(board.db, id), session = run?.metadata?.supercode;
579
706
  if (session?.harness !== 'claude-code' || session.mode !== 'fresh' || !session.launch_key || session.first_response || !session.session_id) continue;
580
707
  let locator;
581
- try { locator = nativeReading(nativeSessions(session.machine, session.harness, session.session_id), session).row?.locator; } catch { continue; }
708
+ try { locator = nativeReading(nativeSessions(session), session).row?.locator; } catch { continue; }
582
709
  if (!locator) continue;
583
710
  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 });
584
711
  const loaded = answer.code === 0 ? lastJson(answer.stdout) : null;
@@ -613,7 +740,8 @@ async function supervise(board, fleet, settings, report, entry, effects) {
613
740
  if (!task || (entry.workflow.run ? !task.current_run_id : task.status !== 'running')) return;
614
741
  const runRow = currentRun(db, id);
615
742
  let session = runRow?.metadata?.supercode ?? null;
616
- const liveBinding=fleet.sessions?.find(item=>item.address===addressOf(session)&&item.state==='running'&&item.pane);
743
+ const liveBindings = fleetSessionRows(fleet, session).filter(item => item.state === 'running' && item.pane);
744
+ const liveBinding = new Set(liveBindings.map(item => item.pane)).size === 1 ? liveBindings[0] : null;
617
745
  if(liveBinding?.pane&&liveBinding.pane!==session.pane){
618
746
  session={...session,pane:liveBinding.pane,missing_since:null};setRunSession(db,runRow.id,session);
619
747
  event(db,id,'session_rebound',{session_id:session.session_id,pane:session.pane},runRow.id);
@@ -621,9 +749,9 @@ async function supervise(board, fleet, settings, report, entry, effects) {
621
749
  const t = now();
622
750
  if (runRow && session) {
623
751
  const alive = session.surface === 'headless' ? pidAlive(session.pid) : sessionAlive(fleet, session);
624
- let native = fleet.sessions?.find(item => item.address === addressOf(session));
752
+ let native = fleetSessionRow(fleet, session);
625
753
  if (!native && session.session_id) try {
626
- const row = nativeReading(nativeSessions(session.machine, session.harness ?? 'claude-code', session.session_id), session).row;
754
+ const row = nativeReading(nativeSessions(session), session).row;
627
755
  native = { turn: row?.activity?.turn };
628
756
  } catch { /* an unread turn stays unknown */ }
629
757
  // the session's machine as this round read it, so the card can say why a session reads unknown (executionOf)
@@ -682,7 +810,7 @@ async function supervise(board, fleet, settings, report, entry, effects) {
682
810
  const reason = session?.launch_deferred_error ?? session?.launch_error ?? 'no session or process was bound to the dispatched run';
683
811
  const error = `launch window ${settings.launchWindow}s expired after ${age}s: ${reason}`;
684
812
  if (!session?.launch_expired_at) {
685
- // a fresh launch the reading never showed has no session to resume: its next run starts fresh
813
+ // An unbound launch retains its unknown conversation and needs deliberate disposition before replacement.
686
814
  setRunSession(db, runRow.id, { launch_expired_at: t, launch_error: error, ...(session?.launch_key && session.mode === 'fresh' ? { forgotten: true } : {}) });
687
815
  event(db, id, 'session_launch_expired', { error, age_seconds: age, window_seconds: settings.launchWindow, key: session?.launch_key ?? null, pane: session?.pane ?? null }, runRow.id);
688
816
  }
@@ -744,6 +872,14 @@ async function supervise(board, fleet, settings, report, entry, effects) {
744
872
  if (t - Number(session.launched_at ?? 0) < settings.crashGrace) return;
745
873
  // how the worker ended, from the exit code its wrapper wrote (Hermes reads its reaped children's)
746
874
  const code = session.exit_file && existsSync(session.exit_file) ? Number(readFileSync(session.exit_file, 'utf8').trim()) : null;
875
+ if (code === 78) {
876
+ const refusal = session.continuation_failure_file && existsSync(session.continuation_failure_file)
877
+ ? readFileSync(session.continuation_failure_file, 'utf8').trim() : null;
878
+ const error = refusal || `headless worker ${session.pid} could not continue or retain its native conversation; its refusal is in ${join(logsDir(board.root, board.slug), `${id}.log`)}`;
879
+ setRunSession(db, runRow.id, { continuation_required: true });
880
+ surfaceIncident(db, board, entry, id, 'exited', { code, worker_pid: session.pid, error }, runRow.id);
881
+ return;
882
+ }
747
883
  const why = code === 75 ? 'worker hit a rate limit (exit 75)' : code === 0 ? `worker ${session.pid} exited cleanly (rc=0) without completing or blocking the card` : `worker ${session.pid} exited${code === null ? '' : ` with ${code}`} without completing the card`;
748
884
  observe('exited', { code, worker_pid: session.pid, exit_code: code, protocol_violation: code === 0, error: why }, 'crashed');
749
885
  return;
@@ -771,9 +907,8 @@ async function supervise(board, fleet, settings, report, entry, effects) {
771
907
  return;
772
908
  } catch (error) { if (!error.noTransition) { report.failed.push([id, `session_closed: ${String(error.message).slice(-200)}`]); return; } }
773
909
  }
774
- if (exit.failed && session.mode === 'resume' && !session.seen_at) {
775
- setRunSession(db, runRow.id, { resumable: false, resume_failed_before_live: true });
776
- }
910
+ if (exit.failed && session.mode === 'resume' && !session.seen_at)
911
+ setRunSession(db, runRow.id, { resume_failed_before_live: true });
777
912
  observe('exited', ended, 'crashed');
778
913
  return;
779
914
  }
@@ -798,9 +933,7 @@ async function supervise(board, fleet, settings, report, entry, effects) {
798
933
  if (alive) { next.seen_at ??= t; next.missing_since = null; }
799
934
  if (missing) next.missing_since ??= t;
800
935
  if (missing && (ended || t - next.missing_since >= Number(limits.grace ?? settings.grace))) {
801
- // lost: its pane's end was seen, or it is gone from an online machine for the whole grace. A resume that never
802
- // came up is not resumable.
803
- if (next.mode === 'resume' && !next.seen_at) next.resumable = false;
936
+ // Loss, including a resume that never came up, retains the same conversation for deliberate disposition.
804
937
  setRunSession(db, runRow.id, next);
805
938
  observe('session_lost', { session_id: next.session_id, machine: next.machine, pane: next.pane, mode: next.mode, seen: Boolean(next.seen_at),
806
939
  never_came_up: next.mode === 'fresh' && !next.seen_at, ...(ended ? { pane_ended: ended } : {}),
@@ -824,7 +957,7 @@ async function supervise(board, fleet, settings, report, entry, effects) {
824
957
  /** A running card's limits: its status's `run.limits`, each evaluated for the card (seconds; null for none). */
825
958
  function limitsOf(db, board, entry, task, session) {
826
959
  const status = statusOf(db, task);
827
- const scope = scopeOf(db, board, task, { params: entry.params, extra: { run: { surface: session?.surface ?? null, local: true } } });
960
+ const scope = scopeOf(db, board, task, { params: entry.params, lazy: true, extra: { run: { surface: session?.surface ?? null, local: true } } });
828
961
  const out = {};
829
962
  for (const [name, expr] of Object.entries({ ...entry.workflow.run?.limits, ...runSpec(entry, status, task)?.limits })) {
830
963
  const v = evaluate(expr, scope);
@@ -833,33 +966,42 @@ function limitsOf(db, board, entry, task, session) {
833
966
  return out;
834
967
  }
835
968
 
836
- async function stopSession(board, session) {
969
+ async function stopSession(board, session, revalidate = () => {}) {
837
970
  if (!session) return;
838
971
  if (view(board, db => detachedSession(db, session))) throw Object.assign(new Error('close deferred: session was detached to a person'), { sessionDetached: true });
839
- return sessionOperation(board, { address: addressOf(session), kind: 'session close' }, () => stopOwnedSession(board, session));
972
+ return sessionOperation(board, { address: addressOf(session), kind: 'session close' }, () => stopOwnedSession(board, session, revalidate));
840
973
  }
841
974
 
842
- async function stopOwnedSession(board, session) {
975
+ async function stopOwnedSession(board, session, revalidate = () => {}) {
843
976
  if (boardCopyOf(board)) throw copyRefusal(board, 'closes no session');
844
- if (session.surface === 'headless' && pidAlive(session.pid)) {
977
+ if (session.surface === 'headless') {
978
+ if (normalMachine(session.machine ?? localMachine()) !== localMachine()) throw new Error(`close deferred: headless worker ${session.pid} is not on this machine`);
979
+ if (!session.pid) throw new Error('close deferred: headless launch has no confirmed worker pid');
980
+ if (!pidAlive(session.pid)) return;
845
981
  // SIGTERM to the worker's process group, then SIGKILL if it is still there after a grace, as Hermes stops its own
846
- const signal = (sig) => { try { process.kill(-session.pid, sig); } catch { try { process.kill(session.pid, sig); } catch { /* gone */ } } };
847
- signal('SIGTERM');
982
+ const signal = (sig) => { try { stopWorkChild(session.pid, () => process.kill(-session.pid, sig), { signal: sig }); } catch { try { stopWorkChild(session.pid, () => process.kill(session.pid, sig), { signal: sig }); } catch { /* gone */ } } };
983
+ revalidate(); signal('SIGTERM');
848
984
  for (let waited = 0; waited < 10 && pidAlive(session.pid); waited += 1) await new Promise((resolve) => setTimeout(resolve, 500));
849
- if (pidAlive(session.pid)) signal('SIGKILL');
985
+ if (pidAlive(session.pid)) {
986
+ revalidate(); signal('SIGKILL');
987
+ for (let waited = 0; waited < 10 && pidAlive(session.pid); waited += 1) await new Promise((resolve) => setTimeout(resolve, 100));
988
+ }
989
+ if (pidAlive(session.pid)) throw new Error(`close deferred: headless worker ${session.pid} is still present after SIGKILL`);
850
990
  return;
851
991
  }
852
992
  if (session.surface !== 'headless' && offlineMachine(session.machine)) throw new Error(`close deferred: machine ${normalMachine(session.machine)} is offline`);
853
993
  if (!session.pane && session.surface === 'native') {
994
+ revalidate();
854
995
  const r = await sc(['close', addressOf(session)], { timeout: 120_000, as: boardAddress(board.label) });
855
996
  if (r.code !== 0) throw new Error(`${doorTransient(r) ? 'close deferred: ' : ''}closing native session ${addressOf(session)}: ${(r.stderr || r.stdout).trim().slice(-500)}`);
856
997
  // the close's receipt is a discovery taken after it, never the round's cached one from before
857
- discoveries.delete(nativeKey(session.machine, session.harness, session.session_id));
858
- const reading = nativeReading(await readNative(session.machine, session.harness, session.session_id, session.codex_home ?? null), session);
998
+ discoveries.delete(nativeKey(session.machine, session.harness, session.session_id, nativeHomes(session)));
999
+ const reading = nativeReading(await readNative(session.machine, session.harness, session.session_id, nativeHomes(session)), session);
859
1000
  if (reading.alive !== false) throw new Error(`native close has no confirmed stopped receipt for ${addressOf(session)}`);
860
1001
  return;
861
1002
  }
862
1003
  if (session.pane) {
1004
+ revalidate();
863
1005
  const r = await runtimeDoor(normalMachine(session.machine??localMachine())===localMachine()?null:session.machine,'harness.v1.panes.close',{pane:session.pane,...(session.launch_key?{launch:session.launch_key}:{})},{env:commandEnv(),timeout:60_000,as:boardAddress(board.label)});
864
1006
  // a door that timed out or could not be reached says nothing about the pane: the close waits for the next round
865
1007
  const transient = doorTransient(r);
@@ -912,77 +1054,120 @@ export function whereOf(board, task) {
912
1054
  // what the platform tells expressions about this machine and this tick
913
1055
  const hostOf = (pressure) => ({ memory_pressure: pressure, max_in_progress: derivedMaxInProgress() });
914
1056
 
915
- /** What every board of the home at `root` holds now, counted for its caps (capacity.mjs). */
916
- async function homeHoldings(root, slugs, lanes, fleet, entry) {
917
- const live = (s) => fleet.sessions?.find((x) => x.address === addressOf(s)) ?? null;
918
- const out = [];
919
- for (const slug of slugs) {
920
- try {
921
- // Capacity spans awaits. It needs its own committed snapshot, not the shared writer's total_changes counter:
922
- // unrelated owner calls and delivery writes used to invalidate all three attempts at this read.
923
- let b;
924
- try { b = openBoardSnapshot(root, slug); }
925
- catch (error) {
926
- if (error.code !== 'board_read_by_owner') throw error;
927
- b = openBoard(root, slug); // files/non-WAL backings retain their guarded optimistic read
928
- }
1057
+ /**
1058
+ * What every board of the home at `root` holds now, counted for its caps (capacity.mjs). A counted session's model is
1059
+ * the one its transcript last names (transcriptOf), as this machine's daemon holds it: asked once a round, for the
1060
+ * sessions counted, through harness.v1.transcripts.models. The count is taken with the answers the last round had; where
1061
+ * this round's answers differ, it is taken again with them, so it always counts this round's. With `retainRevisions`,
1062
+ * each board read by the count returned stays open with its revision (`revisions`, closed by `releaseRevisions`), so a
1063
+ * start's admission can check nothing changed since.
1064
+ */
1065
+ async function homeHoldings(root, slugs, lanes, fleet, entry, { retainRevisions = false } = {}) {
1066
+ const live = (s) => fleetSessionRow(fleet, s);
1067
+ const count = async (modelOf, read) => {
1068
+ const out = [];
1069
+ out.revisions = new Map();
1070
+ out.releaseRevisions = () => { for (const held of out.revisions.values()) held.board.close(); out.revisions.clear(); };
1071
+ for (const slug of slugs) {
929
1072
  try {
930
- b.slug ??= slug; b.label ??= boardLabel(root, slug);
931
- // each run's session read before it is counted live or not (unknown stays unknown)
932
- for (const r of b.db.prepare('SELECT r.metadata FROM tasks t JOIN task_runs r ON r.id = t.current_run_id').all()) await readBefore(fleet, json(r.metadata, {})?.supercode);
933
- const storedOf = (id) => storedStatus(b.db, b, entry, id).stored;
934
- // a session on a machine whose record is not current is not known gone: it keeps its place in the caps
935
- out.push(...await holdings(b, { lanes, fleetOk: fleet.ok,
936
- alive: (s) => sessionAlive(fleet, s) ?? (s?.surface !== 'headless' && s?.machine && fleet.unknown?.has(normalMachine(s.machine)) ? true : null), liveRecord: live, modelOf: transcriptModel, storedOf, machineOf: normalMachine, home: localMachine(),
937
- runLaneOf: row => runLane(b.db, b, entry, getTask(b.db, row.id), lanes) }));
938
- }
939
- finally { b.close(); }
940
- } catch (error) { error.capacityUnread = true; throw error; }
1073
+ // The owner holds this connection and its current-row projection. Capacity validates its
1074
+ // optimistic revision across yields; an unstable read still admits nothing.
1075
+ const b = openBoard(root, slug);
1076
+ try {
1077
+ b.slug ??= slug; b.label ??= boardLabel(root, slug);
1078
+ // each run's session read before it is counted live or not (unknown stays unknown)
1079
+ if (read) for (const r of b.db.prepare('SELECT r.metadata FROM tasks t JOIN task_runs r ON r.id = t.current_run_id').all()) await readBefore(fleet, json(r.metadata, {})?.supercode);
1080
+ const storedOf = (id, row) => {
1081
+ // The usual native hold mapping depends only on this card's retained columns. Custom
1082
+ // stored_as expressions retain their ordinary authoritative evaluation.
1083
+ if (entry.workflow.stored_as?.length) return storedStatus(b.db, b, entry, id).stored;
1084
+ if (entry.workflow.flags?.paused && !row.archived) {
1085
+ if (json(row.paused, null)) return 'paused';
1086
+ if ((json(row.blocked_by, []) ?? []).length) return 'blocked';
1087
+ }
1088
+ return entry.workflow.flags?.paused ? storedAs(entry.workflow,row.stage) : row.status;
1089
+ };
1090
+ // a session on a machine whose record is not current is not known gone: it keeps its place in the caps
1091
+ out.push(...await holdings(b, { lanes, fleetOk: fleet.ok,
1092
+ alive: (s) => sessionAlive(fleet, s) ?? (s?.surface !== 'headless' && s?.machine && fleet.unknown?.has(normalMachine(s.machine)) ? true : null), liveRecord: live, modelOf, storedOf, machineOf: normalMachine, home: localMachine(),
1093
+ runLaneOf: row => runLane(b.db, b, entry, getTask(b.db, row.id), lanes) }));
1094
+ if (retainRevisions) out.revisions.set(slug, { board: b, revision: currentRecordChanges(b.db).token });
1095
+ }
1096
+ finally { if (!retainRevisions || !out.revisions.has(slug)) b.close(); }
1097
+ } catch (error) { out.releaseRevisions(); error.capacityUnread = true; throw error; }
1098
+ }
1099
+ return out;
1100
+ };
1101
+ const asked = new Map();
1102
+ const transcriptKey = (t) => JSON.stringify([t.address, t.locator.storage]);
1103
+ const modelOf = (s) => { const t = transcriptOf(fleet, s); if (!t) return null; const key = transcriptKey(t); asked.set(key, t.locator); return transcriptModels.get(key) ?? null; };
1104
+ let out = await count(modelOf, true);
1105
+ let answered;
1106
+ try { answered = asked.size ? await readTranscriptModels(asked) : new Map(); }
1107
+ catch (error) { out.releaseRevisions(); throw error; }
1108
+ const changed = [...new Set([...asked.keys(), ...transcriptModels.keys()])].some((key) => (answered.get(key) ?? null) !== (transcriptModels.get(key) ?? null));
1109
+ transcriptModels = answered;
1110
+ if (changed) {
1111
+ // the count taken again holds its own boards' revisions, read with it
1112
+ out.releaseRevisions();
1113
+ out = await count((s) => { const t = transcriptOf(fleet, s); return t ? transcriptModels.get(transcriptKey(t)) ?? null : null; }, false);
941
1114
  }
942
1115
  out.defaults = harnessDefaults(out);
943
1116
  return out;
944
1117
  }
945
1118
 
946
- // where each session's transcript is, once found (a session's file does not move)
947
- const transcripts = new Map();
1119
+ /** The models this machine's daemon last answered, by address and transcript storage (homeHoldings). */
1120
+ let transcriptModels = new Map();
1121
+ /** One ask of this machine's daemon for the model each of `asked` (identity → transcript locator) last names: a map of
1122
+ * the answers (a model or null); empty when the daemon did not answer, so each reads as unknown (null). */
1123
+ async function readTranscriptModels(asked) {
1124
+ const entries = [...asked];
1125
+ const r = await doorOn(localMachine(), 'harness.v1.transcripts.models', { locators: entries.map(([, locator]) => locator) }, { timeout: NATIVE_DISCOVERY_MS });
1126
+ const models = r.code === 0 ? lastJson(r.stdout)?.models : null;
1127
+ if (!Array.isArray(models) || models.length !== entries.length) return new Map();
1128
+ return new Map(entries.map(([key], index) => [key, typeof models[index] === 'string' ? models[index] : null]));
1129
+ }
948
1130
 
949
1131
  /**
950
- * The model a session on this machine runs, as its own transcript reports it (Claude Code's assistant messages and
951
- * Codex's turn contexts both carry `"model"`): the last one written, so a model changed mid-session is the one read.
952
- * Null for another machine's session, or one whose transcript cannot be found or names none.
1132
+ * The transcript a session on this machine runs in, whose model (the last one its transcript's last 512 KiB names,
1133
+ * interchange transcript-model.mjs) its machine's daemon holds: `{ address, locator }`, the locator from the round's
1134
+ * fleet reading (the record's sessions) or this round's discovery of the session, and only for the transcript the board
1135
+ * has always read: Claude Code's under this process's own CLAUDE_CONFIG_DIR (or ~/.claude), `projects/<folder>/<id>.jsonl`;
1136
+ * Codex's rollout under its own CODEX_HOME (or ~/.codex), `sessions/<year>/<month>/<day>/…-<id>.jsonl`, the home and
1137
+ * the transcript compared by real path (realOf). Null for another machine's session, another harness's, a transcript
1138
+ * elsewhere (another home, a compressed rollout), or one neither reading holds.
953
1139
  */
954
- function transcriptModel(session) {
1140
+ // each path as its real path (symlinks resolved), kept once resolved: a home and a transcript are compared by where
1141
+ // they are, however either is spelled; a path that does not resolve stands as written, and is resolved again next time
1142
+ const realPaths = new Map();
1143
+ const realOf = (path) => {
1144
+ if (realPaths.has(path)) return realPaths.get(path);
1145
+ try { const real = realpathSync(path); realPaths.set(path, real); return real; } catch { return path; }
1146
+ };
1147
+ function transcriptOf(fleet, session) {
955
1148
  if (!session?.session_id || normalMachine(session.machine ?? localMachine()) !== localMachine()) return null;
956
- const sid = session.session_id;
957
- let file = transcripts.get(sid);
958
- if (file === undefined) {
959
- file = null;
960
- try {
961
- if ((session.harness ?? 'claude-code') === 'claude-code') {
962
- const projects = join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects');
963
- for (const dir of readdirSync(projects)) { const f = join(projects, dir, `${sid}.jsonl`); if (existsSync(f)) { file = f; break; } }
964
- } else if (session.harness === 'codex') {
965
- // Codex files its rollouts by day: sessions/YYYY/MM/DD/rollout-<time>-<id>.jsonl, newest days first
966
- const root = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions');
967
- const sorted = (dir) => readdirSync(dir).sort().reverse();
968
- search: for (const y of sorted(root)) for (const m of sorted(join(root, y))) for (const d of sorted(join(root, y, m))) {
969
- const hit = readdirSync(join(root, y, m, d)).find((f) => f.endsWith(`-${sid}.jsonl`));
970
- if (hit) { file = join(root, y, m, d, hit); break search; }
971
- }
972
- }
973
- } catch { file = null; }
974
- if (file) transcripts.set(sid, file);
975
- }
976
- if (!file) return null;
977
- try {
978
- const size = statSync(file).size;
979
- const fd = openSync(file, 'r');
980
- const length = Math.min(size, 512 * 1024);
981
- const buf = Buffer.alloc(length);
982
- try { readSync(fd, buf, 0, length, size - length); } finally { closeSync(fd); }
983
- const models = [...buf.toString('utf8').matchAll(/"model":"([^"]+)"/g)].map((m) => m[1]).filter((m) => !m.startsWith('<'));
984
- return models.at(-1) ?? null;
985
- } catch { return null; }
1149
+ const harness = session.harness ?? 'claude-code', sid = session.session_id;
1150
+ if (harness !== 'claude-code' && harness !== 'codex') return null;
1151
+ const homes = nativeHome(session)?.homes;
1152
+ if (!homes) return null;
1153
+ const address = `sc:${localMachine()}:${harness}:${sid}`;
1154
+ const named = (row) => row?.locator?.harness === harness && row.locator.session_id === sid ? row : null;
1155
+ const read = (row) => {
1156
+ const path = row?.locator?.storage?.kind === 'file' ? row.locator.storage.path : null;
1157
+ if (typeof path !== 'string') return null;
1158
+ const real = realOf(path);
1159
+ if (harness === 'claude-code') {
1160
+ const projects = realOf(homes.claude_code);
1161
+ if (dirname(dirname(real)) !== projects || basename(real) !== `${sid}.jsonl`) return null;
1162
+ } else {
1163
+ const root = realOf(homes.codex);
1164
+ if (dirname(dirname(dirname(dirname(real)))) !== root || !basename(real).endsWith(`-${sid}.jsonl`)) return null;
1165
+ }
1166
+ return { address, locator: row.locator };
1167
+ };
1168
+ // the record's row, else this round's discovery of the session
1169
+ return fleet?.transcripts?.get(address)?.map(named).map(read).find(Boolean)
1170
+ ?? read(discoveries.get(nativeKey(localMachine(), harness, sid, homes))?.sessions.find(named));
986
1171
  }
987
1172
 
988
1173
  /**
@@ -1000,7 +1185,24 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
1000
1185
  const pressure = memoryPressure();
1001
1186
  const tickState = { local_started: 0 };
1002
1187
  const startable = new Set(Object.keys(workflow.statuses).filter((status) => transitionsFor(workflow, status, d.start).length));
1003
- const everyCard = board.db.prepare("SELECT id FROM tasks WHERE status != 'archived'").all().map((r) => getTask(board.db, r.id));
1188
+ // These exclusions are unconditional below; workflow guards, priority, dependencies and
1189
+ // clocks still decide over the resulting current candidates on every round.
1190
+ const everyCard = [];
1191
+ let startCursor = '';
1192
+ for (;;) {
1193
+ const page = view(board, db => db.prepare(`SELECT t.id FROM supercode_cards c CROSS JOIN tasks t ON t.id=c.task_id
1194
+ WHERE c.subtask_of IS NULL AND c.stage NOT IN ('done','cancelled') AND t.current_run_id IS NULL
1195
+ AND t.status!='archived' AND t.id>?${only ? ' AND t.id=?' : ''}
1196
+ UNION ALL SELECT t.id FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id
1197
+ WHERE t.current_run_id IS NULL AND t.status NOT IN ('archived','done','cancelled') AND c.stage IS NULL
1198
+ AND c.subtask_of IS NULL AND t.id>?${only ? ' AND t.id=?' : ''} ORDER BY id LIMIT 32`)
1199
+ .all(startCursor, ...(only ? [only] : []), startCursor, ...(only ? [only] : [])));
1200
+ if (!page.length) break;
1201
+ everyCard.push(...page);
1202
+ startCursor = page.at(-1).id;
1203
+ if (page.length < 32) break;
1204
+ await yieldToOwner();
1205
+ }
1004
1206
  const scopeFor = (task, extra = {}) => scopeOf(board.db, board, task, { params, lazy: true, extra: { host: hostOf(pressure), tick: tickState, ...extra } });
1005
1207
  // who would run it: the role of the status its start moves it to, and that role's lane
1006
1208
  const planOf = (task, scope) => {
@@ -1017,34 +1219,41 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
1017
1219
  report.queued.push([task.id, why]);
1018
1220
  if (!dry && !guardNoted(board.db, task.id, why)) tx(board, db => noteGuard(db, task.id, why));
1019
1221
  };
1020
- for (const task of everyCard) {
1222
+ const selectionWait = (task, state) => {
1223
+ if (state.person) return `assigned to ${state.person}: a person's card runs no session; it closes when that person completes it`;
1224
+ if (state.status === 'running' && completedWork(board.db, task.id)) return 'work is complete; the board must select its independent review before another implementer run';
1225
+ if (state.detached) return `session belongs to ${state.detached.person}; verified adoption is required`;
1226
+ if (state.dispatch_hold) return 'dispatch held: an explicit start or verified adoption must release it';
1227
+ if (state.backlog) return `backlog: ${state.backlog.reason ?? 'not planned to start'}`;
1228
+ if (workflow.run && state.paused) return `paused: ${state.paused.reason ?? 'manager pause'}`;
1229
+ if (workflow.run && state.blocked_by.length) return `blocked by ${state.blocked_by.map(b => b.id).join(', ')}`;
1230
+ if (workflow.run && state.every && state.next_run_at > now()) return `next scheduled run at ${state.next_run_at}`;
1231
+ if (!startable.has(state.status)) return `no ${d.start} transition from ${state.status}`;
1232
+ return null;
1233
+ };
1234
+ for (const { id } of everyCard) {
1235
+ await yieldToOwner();
1236
+ if (stopping) return;
1237
+ const task = view(board, db => getTask(db, id));
1238
+ if (!task) continue;
1021
1239
  if (only && task.id !== only) continue;
1022
1240
  // a subtask or a card with a run is never started here, whatever its scope says: no scope is built to learn it (the
1023
1241
  // scope of each of a board's 2,155 unarchived cards, 2,039 of them subtasks or running, was 0.8 s of every round)
1024
1242
  if (task.subtask_of || task.current_run_id) continue;
1025
1243
  const state = scopeFor(task).card;
1026
1244
  if (state.archived || ['done', 'cancelled'].includes(state.status)) continue;
1027
- // A person's card is a dependency on that person, never work for a session: the board starts none for it, whatever
1028
- // its workflow's start would allow, and it closes when that person completes it (person-done).
1029
- if (state.person) { waiting(task, `assigned to ${state.person}: a person's card runs no session; it closes when that person completes it`); continue; }
1030
- if (state.status === 'running' && completedWork(board.db, task.id)) {
1031
- waiting(task, 'work is complete; the board must select its independent review before another implementer run'); continue;
1032
- }
1033
- if (state.detached) { waiting(task, `session belongs to ${state.detached.person}; verified adoption is required`); continue; }
1034
- if (state.dispatch_hold) { waiting(task, 'dispatch held: an explicit start or verified adoption must release it'); continue; }
1035
- if (workflow.run && state.paused) { waiting(task, `paused: ${state.paused.reason ?? 'manager pause'}`); continue; }
1036
- if (workflow.run && state.blocked_by.length) { waiting(task, `blocked by ${state.blocked_by.map(b => b.id).join(', ')}`); continue; }
1037
- if (workflow.run && state.every && state.next_run_at > now()) { waiting(task, `next scheduled run at ${state.next_run_at}`); continue; }
1038
- if (!startable.has(state.status)) { waiting(task, `no ${d.start} transition from ${state.status}`); continue; }
1245
+ const selectionReason = selectionWait(task, state);
1246
+ if (selectionReason) { waiting(task, selectionReason); continue; }
1039
1247
  const plan = planOf(task, scopeFor(task));
1040
1248
  if (!plan) { waiting(task, `start guard refused: ${transitionsFor(workflow, state.status, d.start).map(t => t.guard ?? 'sender role').join(' | ')}`); continue; }
1041
1249
  const scope = scopeFor(task, { lane: plan.lane ?? {}, run: { surface: plan.lane?.surface ?? null, local: executionHost(plan.lane, task.machine, localMachine(), normalMachine) === localMachine() } });
1042
1250
  if (!holds(d.eligible ?? null, scope)) { waiting(task, `dispatch eligibility refused (memory ${pressure}, local starts ${tickState.local_started}): ${d.eligible}`); continue; }
1043
- candidates.push({ task, plan, scope });
1251
+ candidates.push({ task, plan, status: state.status,
1252
+ slot: String(templateValue(plan.runSpec?.session ?? 'worker', scope) ?? 'worker'),
1253
+ order: (d.order ?? []).map((o) => { const desc = o.startsWith('-'); return { v: evaluate(desc ? o.slice(1) : o, scope), desc }; }) });
1044
1254
  }
1045
- const keyOf = (c) => (d.order ?? []).map((o) => { const desc = o.startsWith('-'); const v = evaluate(desc ? o.slice(1) : o, c.scope); return { v, desc }; });
1046
1255
  candidates.sort((a, b) => {
1047
- const ka = keyOf(a); const kb = keyOf(b);
1256
+ const ka = a.order; const kb = b.order;
1048
1257
  for (let i = 0; i < ka.length; i += 1) {
1049
1258
  if (ka[i].v === kb[i].v) continue;
1050
1259
  const lt = (ka[i].v ?? -Infinity) < (kb[i].v ?? -Infinity);
@@ -1055,16 +1264,23 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
1055
1264
  // the caps, over what the home holds now; what each candidate would be once started
1056
1265
  const caps = capacity(d.capacity, held, evaluate);
1057
1266
  const entryOf = (c) => ({ card: c.task.id, board: board.slug, machine: executionHost(c.plan.lane, c.task.machine, localMachine(), normalMachine), assignee: c.plan.profile ?? null,
1058
- dispatch: true, slot: String(templateValue(c.plan.runSpec?.session ?? 'worker', c.scope) ?? 'worker'),
1267
+ dispatch: true, slot: c.slot,
1059
1268
  status: c.plan.target, stored: c.plan.target, model: c.task.model_override ?? c.plan.lane?.model ?? held.defaults?.[c.plan.lane?.harness] ?? null, harness: c.plan.lane?.harness ?? null });
1060
1269
  const waitingEntries = candidates.map(entryOf);
1061
1270
  // a dry run (dispatch --dry-run) plans the same way and writes nothing: no notes, no moves, no launches
1062
1271
  const note = (id, why) => { if (!dry && !guardNoted(board.db, id, why)) tx(board, (db) => noteGuard(db, id, why)); };
1063
1272
  for (const [i, c] of candidates.entries()) {
1064
- const { task, plan, scope } = c;
1273
+ await yieldToOwner();
1274
+ if (stopping) return;
1275
+ const { task, plan } = c;
1065
1276
  if (!plan.profile) { report.unassigned.push(task.id); note(task.id, 'no profile runs it (no assignee, and the workflow names none)'); continue; }
1066
1277
  if (!plan.lane) { report.nonspawnable.push(task.id); note(task.id, `${plan.profile} is no lane of this home`); continue; }
1067
- const capped = caps.admit(waitingEntries[i], scope, waitingEntries);
1278
+ const capped = view(board, db => {
1279
+ const current = getTask(db, task.id);
1280
+ return current ? caps.admit(waitingEntries[i], scopeFor(current, { lane: plan.lane,
1281
+ run: { surface: plan.lane.surface, local: executionHost(plan.lane, current.machine, localMachine(), normalMachine) === localMachine() } }), waitingEntries)
1282
+ : { why: 'the selected card no longer exists' };
1283
+ });
1068
1284
  let why = capped?.why ?? null;
1069
1285
  // the launcher's own conditions: a lane that can start this card where it names
1070
1286
  if (!why) why = launcherCheck(board.db, task, plan.lane, fleet);
@@ -1076,13 +1292,23 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
1076
1292
  }
1077
1293
  let moved, checked;
1078
1294
  try {
1079
- checked = await checkWorkspace(board, task);
1295
+ checked = await checkWorkspace(board, task, await workspaceForRun(board, task, { role: plan.runSpec?.role, slot: c.slot, lane: plan.lane }));
1080
1296
  moved = tx(board, (db) => {
1081
1297
  // The claim: another round (a second serve, a one-shot dispatch) may have started this card, or others that count
1082
1298
  // against the same caps, since this round read the board. Its starts are counted, and this card starts only if it
1083
1299
  // still waits where it did and still fits.
1084
1300
  const now = getTask(db, task.id);
1085
- if (!now || now.current_run_id || now.archived || statusOf(db, now) !== scope.card.status) throw Object.assign(new Error('started or moved by another round'), { claimedElsewhere: true });
1301
+ if (!now || now.subtask_of || now.current_run_id || now.archived || statusOf(db, now) !== c.status) throw Object.assign(new Error('started or moved by another round'), { claimedElsewhere: true });
1302
+ const scope = scopeFor(now, { lane: plan.lane, run: { surface: plan.lane.surface, local: executionHost(plan.lane, now.machine, localMachine(), normalMachine) === localMachine() } });
1303
+ const selectionReason = selectionWait(now, scope.card);
1304
+ if (selectionReason) throw Object.assign(new Error(selectionReason), { claimedElsewhere: true });
1305
+ const currentPlan = planOf(now, scope);
1306
+ if (!currentPlan || currentPlan.target !== plan.target || currentPlan.profile !== plan.profile
1307
+ || ['model_override', 'provider_override', 'reasoning_effort', 'machine', 'workspace_kind', 'workspace_path', 'workspace_anchor', 'branch_name'].some(field => now[field] !== task[field])
1308
+ || executionHost(plan.lane, now.machine, localMachine(), normalMachine) !== waitingEntries[i].machine
1309
+ || String(templateValue(currentPlan.runSpec?.session ?? 'worker', scope) ?? 'worker') !== c.slot
1310
+ || !holds(d.eligible ?? null, scope))
1311
+ throw Object.assign(new Error('start selection changed while the owner answered other work'), { claimedElsewhere: true });
1086
1312
  for (const row of db.prepare('SELECT t.id, t.assignee, t.model_override, c.machine, r.metadata FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id JOIN task_runs r ON r.id=t.current_run_id WHERE t.current_run_id IS NOT NULL').all()) {
1087
1313
  const lane = lanes[row.assignee] ?? {}, status = statusOf(db, row);
1088
1314
  const session = json(row.metadata, {})?.supercode ?? {};
@@ -1093,9 +1319,9 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
1093
1319
  }
1094
1320
  const refused = caps.admit(waitingEntries[i], scope, waitingEntries);
1095
1321
  if (refused) throw Object.assign(new Error(refused.why), { claimedElsewhere: true });
1096
- assertWorkspaceOwner(db, task, checked);
1322
+ assertWorkspaceOwner(db, now, checked);
1097
1323
  if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, task.id);
1098
- if (!task.assignee && plan.profile) {
1324
+ if (!now.assignee && plan.profile) {
1099
1325
  db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(plan.profile, task.id);
1100
1326
  event(db, task.id, 'assigned', { assignee: plan.profile, source: 'workflow role' });
1101
1327
  }
@@ -1142,6 +1368,8 @@ const superseded = (taskId, runId) => Object.assign(new Error(`run ${runId} of $
1142
1368
  /** A launch's preparation awaits (minting, workspace, doors) while the owner answers verbs: after each await, and right
1143
1369
  * before it is issued, the run must still be the card's current, unended one (a cancel or restart meanwhile ends it). */
1144
1370
  function assertRunCurrent(board, taskId, runId) {
1371
+ const task = view(board, db => getTask(db, taskId));
1372
+ if (task?.backlog) throw Object.assign(new Error(`${taskId} is in backlog; plan it before launching work`), { launchSuperseded: true });
1145
1373
  if (!view(board, (db) => runCurrent(db, taskId, runId))) throw superseded(taskId, runId);
1146
1374
  }
1147
1375
  /** A write a launch makes for its run: made only while that run is still the card's current one, checked in the same
@@ -1155,7 +1383,10 @@ function runTx(board, taskId, runId, fn) {
1155
1383
  function launchFailed(board, entry, id, error, payload) {
1156
1384
  return tx(board, (db) => {
1157
1385
  if (!runCurrent(db, id, error.launchRun)) return null;
1158
- if (monitorsRuns(entry)) { surfaceIncident(db, board, entry, id, 'spawn_failed', payload, error.launchRun); return { effects: [] }; }
1386
+ if (monitorsRuns(entry) || error.continuationRequired) {
1387
+ if (error.continuationRequired) setRunSession(db, error.launchRun, { continuation_required: true });
1388
+ surfaceIncident(db, board, entry, id, 'spawn_failed', payload, error.launchRun); return { effects: [] };
1389
+ }
1159
1390
  if (recoversRuns(entry)) retainRecovery(db, id, 'spawn_failed', payload, error.launchRun);
1160
1391
  return fire(db, board, entry, id, 'spawn_failed', { roles: ['platform'], payload });
1161
1392
  });
@@ -1175,12 +1406,8 @@ export async function launchRun(board, entry, id, lane, fleet, settings) {
1175
1406
 
1176
1407
  async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
1177
1408
  let task = getTaskNow(board, id);
1409
+ assertRunCurrent(board, id, runId);
1178
1410
  if (task.current_run_id !== runId) throw superseded(id, runId);
1179
- const checked = await checkWorkspace(board, task);
1180
- if (checked) runTx(board, id, runId, (db) => {
1181
- assertWorkspaceOwner(db, task, checked);
1182
- db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, id);
1183
- });
1184
1411
  const status = view(board, (db) => statusOf(db, task));
1185
1412
  const spec = runSpec(entry, status, task);
1186
1413
  if (!spec) return null;
@@ -1192,6 +1419,12 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
1192
1419
  // the run's session record knows its status, role and slot (a reviewer's slot is never the implementer's); the slot
1193
1420
  // is a template, so a step can ask for isolation by naming a new one
1194
1421
  const slot = String(templateValue(spec.session ?? 'worker', scope) ?? 'worker');
1422
+ const workspaceOptions = await workspaceForRun(board, task, { role: spec.role, slot, lane, runId });
1423
+ const checked = await checkWorkspace(board, task, workspaceOptions);
1424
+ if (checked) runTx(board, id, runId, (db) => {
1425
+ assertWorkspaceOwner(db, task, checked);
1426
+ db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, id);
1427
+ });
1195
1428
  // Explicit starts and deferred/resumed launches reach this path without startCards. Recheck the dispatcher-session
1196
1429
  // caps here too, excluding this attempt's own reservation. Other cap kinds keep their existing admission rules.
1197
1430
  const sessionCaps = entry.workflow.dispatch.capacity?.filter(cap => cap.counts === 'sessions' && cap.where?.dispatch === true) ?? [];
@@ -1234,16 +1467,29 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
1234
1467
  if (spec.role === 'reviewer') {
1235
1468
  const worker = view(board, (db) => lastSession(db, id, 'worker'));
1236
1469
  const reviewer = view(board, (db) => lastSession(db, id, slot));
1237
- if (slot === 'worker' || (worker?.session_id && worker.session_id === reviewer?.session_id)) throw new Error('a reviewer must have a different session from the implementer');
1470
+ if (slot === 'worker' || (worker?.session_id && worker.session_id === reviewer?.session_id)) throw ownerRule('a reviewer must have a different session from the implementer');
1238
1471
  }
1239
1472
  // the model it runs is recorded with the session, for caps that count by model
1240
- const meta = { status, role: spec.role, slot, model: task.model_override ?? lane.model ?? null, prompt_digest: told.digest, ...(spec.role === 'reviewer' ? { review_candidate: reviewCandidate(board, id) } : {}) };
1473
+ const meta = { status, role: spec.role, slot, continuation_required: false, model: task.model_override ?? lane.model ?? null, prompt_digest: told.digest, ...(spec.role === 'reviewer' ? { review_candidate: reviewCandidate(board, id) } : {}) };
1474
+ await retireUnissuedSlotLaunch(board, id, runId, slot);
1475
+ assertRunCurrent(board, id, runId);
1241
1476
  try {
1242
1477
  // Teams is never on a launch's path: the card's agent is its identity minted on this host under its retained intent,
1243
1478
  // and its sessions are joined to it off this path, after the launch. No Teams answer starts, stops or holds a card.
1244
1479
  const context=current().facts.SUPERCODE_CONTEXT,team=agentScope(context);
1245
- const agent=await localCardAgent({root:board.root,org:team.org,server:team.server,profiles:entry.profiles,
1246
- profile:lane.name,card:id,handle:cardHandleOf(board,id),readableId:readableIdOf(board,id),role:spec.role??slot});
1480
+ const decision = continuationDecision(board, task, runId, slot, lane, spec.role ?? slot);
1481
+ meta.slot_generation = decision.authorization ? randomUUID() : decision.prior?.generation ?? randomUUID();
1482
+ // Adoption never transfers ownership or creates launch identity. A later continuation keeps that policy.
1483
+ let agent = null;
1484
+ if (decision.prior && adoptedOnly(decision.prior.session) && !decision.authorization) {
1485
+ Object.assign(meta, { agent_id: null, agent_org: null, agent_context: null, agent_env: {},
1486
+ agent_profile_intent: structuredClone(resolveAgentProfile(entry.profiles, lane.name).resolved) });
1487
+ } else {
1488
+ agent=await localCardAgent({root:board.root,org:team.org,server:team.server,profiles:entry.profiles,
1489
+ profile:lane.name,card:id,handle:cardHandleOf(board,id),readableId:readableIdOf(board,id),role:spec.role??slot,
1490
+ // an authorized replacement binds only the agent its decision names; one naming none mints the role's next generation
1491
+ ...((decision.authorization ? decision.authorization.target?.agent : decision.prior?.session?.agent_id)
1492
+ ? { existingAgent: decision.authorization ? decision.authorization.target.agent : decision.prior.session.agent_id } : {})});
1247
1493
  assertRunCurrent(board,id,runId);
1248
1494
  recordCardAgentAssignment(board,cardAgentAssignments(board.root,id).find(assignment=>assignment.org===team.org&&assignment.id===agent.assignment));
1249
1495
  // G3: a run of an agent over its budget does not start; the reason waits on the card until the window passes
@@ -1261,15 +1507,16 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
1261
1507
  assertRunCurrent(board,id,runId);
1262
1508
  if(surface==='headless')meta.agent_env.HERMES_HOME=agentHome;
1263
1509
  else{const memory=await memorySnapshot(agentHome).catch(()=>'');assertRunCurrent(board,id,runId);if(memory)prompt=`${prompt}\n\n${memory}`;}
1510
+ }
1264
1511
  const result = surface === 'pane'
1265
- ? 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 } })
1266
- : await launchHeadless(board, task, runId, lane, settings, { prompt, skills: spec.skills ?? [], meta });
1512
+ ? await launchPane(board, task, runId, lane, fleet, { input: briefByReference(board, task, runId, spec.role ?? slot, where, prompt), slot, meta, workspaceOptions, orchestration: entry.orchestration ?? { profiles: entry.profiles } })
1513
+ : await launchHeadless(board, task, runId, lane, settings, { prompt, skills: spec.skills ?? [], meta, workspaceOptions });
1267
1514
  tx(board,db=>{
1268
1515
  if(!runCurrent(db,id,runId))return; // replaced once launched: its session is the ended run's, closed as one
1269
1516
  setRunSession(db,runId,{launch_deferred:false,launch_deferred_error:null,restart_authorized:false});
1270
1517
  event(db,id,'restart_authorization_consumed',{},runId);
1271
1518
  });
1272
- if(result?.session_id)recordAgentRun(board.root,team.org,agent.identity.id,{session:`${result.harness}:${result.session_id}`,machine:result.machine,run:runId});
1519
+ if(result?.session_id&&agent)recordAgentRun(board.root,team.org,agent.identity.id,{session:`${result.harness}:${result.session_id}`,machine:result.machine,run:runId});
1273
1520
  return result;
1274
1521
  } catch(error) {
1275
1522
  // An issued asynchronous launch may bind later. Its original permission
@@ -1294,6 +1541,25 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
1294
1541
  }
1295
1542
  }
1296
1543
 
1544
+ function continuationDecision(board, task, runId, slot, lane, role, agent = undefined) {
1545
+ const assignment = cardAgentAssignments(board.root, task.id).find(item => item.id === cardRoleAssignment(task.id, role) && item.state === 'active');
1546
+ const decision = view(board, db => replacementFor(db, task.id, slot, { harness: lane.harness, cardMachine: task.machine,
1547
+ agent, assignment, beforeRun: runId }));
1548
+ const previous = decision.prior?.session;
1549
+ if (previous && !decision.authorization) {
1550
+ if (!previous.session_id) throw continuationRefusal(decision.prior, 'its issued launch has no known native conversation id');
1551
+ if (previous.forgotten) throw continuationRefusal(decision.prior, 'a forgotten marker has no current deliberate replacement decision');
1552
+ if ((previous.harness ?? 'claude-code') !== lane.harness) throw continuationRefusal(decision.prior, 'the configured harness changed without a matching manager decision');
1553
+ if (normalMachine(previous.machine) !== executionHost(lane, task.machine, localMachine(), normalMachine))
1554
+ throw continuationRefusal(decision.prior, 'the configured machine changed without a matching manager decision');
1555
+ if (assignment && previous.agent_id && assignment.agent_id !== previous.agent_id)
1556
+ throw continuationRefusal(decision.prior, 'the role owner changed without a matching assignment decision');
1557
+ if (agent !== undefined && previous.agent_id && previous.agent_id !== agent)
1558
+ throw continuationRefusal(decision.prior, 'the established conversation belongs to a different agent');
1559
+ }
1560
+ return { ...decision, assignment };
1561
+ }
1562
+
1297
1563
  /**
1298
1564
  * What the board types into a pane session to start a run: one short line in the board's own words naming the role,
1299
1565
  * the card and where the run's brief is, with the brief itself kept in a file. A terminal harness takes its input as a
@@ -1324,16 +1590,16 @@ export function roleBrief(db, board, entry, task, status, surface = 'pane') {
1324
1590
  if (!spec) return null;
1325
1591
  const where = whereOf(board, task);
1326
1592
  const nativeHolds = Boolean(entry.workflow.flags?.paused);
1327
- const scope = scopeOf(db, board, task, { params: entry.params });
1593
+ const scope = scopeOf(db, board, task, { params: entry.params, lazy: true });
1328
1594
  // a subtask's run also sees its arc: the arc's text, tasks and thread (where its owner's rulings are)
1329
- const prompt = render(spec.prompt, { ...scope, where, context: cardContext(db, task.id, { nativeHolds }),
1595
+ const prompt = render(spec.prompt, { ...scope, where, context: cardContext(db, task.id, { nativeHolds, review: spec.role === 'reviewer' }),
1330
1596
  arc_context: task.subtask_of ? cardContext(db, task.subtask_of, { nativeHolds }) : '', doors: doors(entry.workflow, status, spec.role, task.id, where, surface) });
1331
1597
  return { spec, where, prompt, digest: promptDigest(spec) };
1332
1598
  }
1333
1599
  const promptDigest = (spec) => spec?.prompt ? createHash('sha256').update(String(spec.prompt)).digest('hex').slice(0, 16) : null;
1334
1600
 
1335
- /** File a role's brief (roleBrief) to the session at `address` on run `runId`, by reference, as a session notice in the
1336
- * caller's transaction, and record which prompt it was. */
1601
+ /** File a role's brief (roleBrief) to the session at `address` on run `runId`, by reference, as a session notice effect
1602
+ * in the caller's transaction (sessionNotice files it as the run's instruction), and record which prompt it was. */
1337
1603
  function queueBrief(db, board, task, runId, address, told, how) {
1338
1604
  const role = told.spec.role ?? 'implementer';
1339
1605
  const text = briefByReference(board, task, runId, role, told.where, told.prompt, how);
@@ -1342,6 +1608,7 @@ function queueBrief(db, board, task, runId, address, told, how) {
1342
1608
  messageKey: `board-brief-${task.id}-${runId}-${told.digest}-${how}-${createHash('sha256').update(address).digest('hex').slice(0, 12)}` };
1343
1609
  db.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
1344
1610
  setRunSession(db, runId, { prompt_digest: told.digest });
1611
+ return source;
1345
1612
  }
1346
1613
 
1347
1614
  /**
@@ -1349,16 +1616,19 @@ function queueBrief(db, board, task, runId, address, told, how) {
1349
1616
  * it was told (its run's `prompt_digest`; a run that records none was never told this one), as a room's guidance is told
1350
1617
  * again on every change. A session being launched, closed, released or in a person's hands is told nothing here.
1351
1618
  */
1352
- function retellBriefs(board, entry, report) {
1619
+ async function retellBriefs(board, entry, report) {
1353
1620
  // each card in its own write: a brief that cannot be told (its prompt unreadable, its card changed) is said in the
1354
1621
  // round's report and stops no other card's
1355
1622
  for (const { id } of view(board, (db) => db.prepare('SELECT id FROM tasks WHERE current_run_id IS NOT NULL').all())) {
1623
+ await yieldToOwner();
1624
+ if (stopping) return;
1356
1625
  try {
1357
1626
  tx(board, (db) => {
1358
1627
  const run = currentRun(db, id), session = run?.metadata?.supercode, address = addressOf(session);
1359
1628
  if (!run || run.ended_at != null || !address || session.surface === 'headless' || session.launch_pending || session.launch_deferred
1360
1629
  || session.close_requested || session.close_confirmed_at || session.released || detachedSession(db, session)) return;
1361
1630
  const task = getTask(db, id), status = statusOf(db, task);
1631
+ if (task.backlog || task.paused || task.blocked_by.length || scopeOf(db, board, task, { params: entry.params, lazy: true }).card.dispatch_hold) return;
1362
1632
  if (promptDigest(runSpec(entry, status, task)) === (session.prompt_digest ?? null)) return;
1363
1633
  const told = roleBrief(db, board, entry, task, status, 'pane');
1364
1634
  if (!told?.digest) return;
@@ -1384,38 +1654,101 @@ function pendingBrief(board, task, runId, where) {
1384
1654
  return existsSync(file) ? `Run ${runId}'s instructions from the board are its brief ${briefFrom(board, task, file, where)}, and carry it out.` : null;
1385
1655
  }
1386
1656
 
1387
- /** Start one card now by a person's word (a triage card's session, a manager's start): the workflow's start event. */
1388
- export async function startCard(board, id, { roles = ['manager'], lanes, settings } = {}) {
1657
+ /** Start one card now, or retain the exact unmet admission detail without claiming a launch. */
1658
+ export async function startCard(board, id, options = {}) {
1659
+ try { return await startCardNow(board, id, options); }
1660
+ catch (error) {
1661
+ if (error.ownerRule) return routeRequest(board, { id, name: 'start', payload: {}, reason: error });
1662
+ if (!error.boardDetail && !error.refusal && !error.noTransition && !error.continuationRequired
1663
+ && !error.launchDeferred && !error.launchPending && error.code !== 'review_refused') throw error;
1664
+ return acceptRequest(board, { id, name: 'start', payload: {}, reason: error });
1665
+ }
1666
+ }
1667
+
1668
+ async function startCardNow(board, id, { roles = ['manager'], lanes, settings } = {}) {
1389
1669
  // the board's own published fleet projection, as a tick reads it: a manager's or creator's start that continues the
1390
1670
  // slot's session checks that session through it
1391
1671
  fleetRoot = board.root;
1392
1672
  const entry = await loadWorkflow(board.root);
1673
+ if (!roles.some(role => ['manager', 'creator'].includes(role))) throw ownerRule('Only the manager or creator starts a held card; request its decision.');
1674
+ lanes ??= lanesOf(entry.profiles);
1675
+ settings ??= boardSettings(entry.params);
1676
+ const expiryEffects = tx(board, db => { const effects = []; settleExpiringFlags(db, board, entry, effects); return effects; });
1677
+ queueEffects(board, expiryEffects);
1678
+ const stop = paused(board.root);
1679
+ if (stop) throw requestDetail(`the board is stopped: ${stop.reason ?? stop}`);
1680
+ const before = getTaskNow(board, id);
1681
+ if (!before) throw requestDetail(`no card ${id}`);
1393
1682
  const fleet = await observeFleet();
1394
- const before = getTaskNow(board, id), checked = await checkWorkspace(board, before);
1683
+ const pressure = memoryPressure(), tickState = { local_started: 0 };
1684
+ const planOf = (db, task) => {
1685
+ if (!task) throw requestDetail(`no card ${id}`);
1686
+ const scope = scopeOf(db, board, task, { params: entry.params, extra: { sender: roles, verb: entry.workflow.dispatch.start,
1687
+ event: { lock: claimer() }, host: hostOf(pressure), tick: tickState } });
1688
+ // Explicit start releases this hold in the admitted transaction below. Preview that same transition.
1689
+ scope.card.dispatch_hold = null;
1690
+ const status = statusOf(db, task);
1691
+ const transition = transitionsFor(entry.workflow, status, entry.workflow.dispatch.start)
1692
+ .find(t => (!t.by?.length || t.by.some(role => roles.includes(role))) && holds(t.guard ?? null, scope));
1693
+ if (!transition) throw requestDetail(`${id} has no admitted ${entry.workflow.dispatch.start} transition from ${status}`);
1694
+ const target = transition.target ? String(templateValue(transition.target, scope)) : status;
1695
+ const spec = runSpec(entry, target, scope.card);
1696
+ const profile = spec ? evaluate(entry.workflow.roles?.[spec.role] ?? 'card.assignee', scope) : null;
1697
+ const lane = profile ? lanes[profile] : null;
1698
+ if (spec && !lane) throw requestDetail(`${id} would be ${target}, run by ${spec.role} (${profile ?? 'nobody'}), which is no lane of this home`);
1699
+ return { task, scope, target, spec, profile, lane, key: JSON.stringify([status, task.current_run_id, task.assignee, task.machine,
1700
+ task.model_override, task.provider_override, target, spec?.role, spec?.session, profile, lane?.harness, lane?.surface]) };
1701
+ };
1702
+ const plan = view(board, db => planOf(db, getTask(db, id)));
1703
+ const checked = await checkWorkspace(board, before, await workspaceForRun(board, before, { role: plan.spec?.role,
1704
+ slot: String(templateValue(plan.spec?.session ?? 'worker', plan.scope) ?? 'worker'), lane: plan.lane }));
1705
+ const held = await homeHoldings(board.root, listBoards(board.root), lanes, fleet, entry, { retainRevisions: true });
1706
+ try {
1707
+ const admissionOf = p => ({ card: id, board: board.slug, machine: executionHost(p.lane, p.task.machine, localMachine(), normalMachine),
1708
+ assignee: p.profile ?? null, status: p.target, stored: p.target, dispatch: true,
1709
+ slot: String(templateValue(p.spec?.session ?? 'worker', p.scope) ?? 'worker'),
1710
+ model: p.task.model_override ?? p.lane?.model ?? held.defaults?.[p.lane?.harness] ?? null, harness: p.lane?.harness ?? null });
1711
+ const caps = capacity(entry.workflow.dispatch.capacity, held, evaluate);
1712
+ const refused = caps.admit(admissionOf(plan), { ...plan.scope, lane: plan.lane ?? {} });
1713
+ if (refused) throw requestDetail(refused.why);
1395
1714
  const moved = tx(board, (db) => {
1396
- if (detachedCard(db, id)) throw new Error(`${id} is detached to a person; request and verify adoption before starting it`);
1715
+ const task = getTask(db, id);
1716
+ if (task?.backlog) throw requestDetail(`${id} is in backlog; plan it before starting it`);
1717
+ const currentPlan = planOf(db, task);
1718
+ if (currentPlan.key !== plan.key) throw requestDetail(`${id}'s role or configuration changed during start admission; retry its current plan`);
1719
+ // The full home projection, including stopped card holdings, must still be the one admitted above.
1720
+ for (const [slug, { board: reading, revision }] of held.revisions) {
1721
+ const change = currentRecordChanges(reading.db, revision);
1722
+ // Owner fencing updates board state at transaction entry; only current card/run/event changes invalidate capacity.
1723
+ if (change.reset || change.cards.length || change.eventsRewritten)
1724
+ throw requestDetail(`capacity of board ${slug} changed during start admission; retry against its current holdings`);
1725
+ }
1726
+ const capped = caps.admit(admissionOf(currentPlan), { ...currentPlan.scope, lane: currentPlan.lane ?? {} });
1727
+ if (capped) throw requestDetail(capped.why);
1728
+ if (detachedCard(db, id)) throw requestDetail(`${id} is detached to a person; request and verify adoption before starting it`);
1397
1729
  const person = personOf(getTask(db, id)?.assignee);
1398
- if (person) throw new Error(`${id} is assigned to ${person}: a person's card runs no session; it closes when that person completes it`);
1730
+ if (person) throw requestDetail(`${id} is assigned to ${person}: a person's card runs no session; it closes when that person completes it`);
1399
1731
  assertWorkspaceOwner(db, before, checked);
1400
1732
  if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, id);
1401
- if (!roles.some(role => ['manager', 'creator'].includes(role))) throw new Error('Only the manager or creator starts a held card; request its decision.');
1402
1733
  event(db, id, 'dispatch_released', { reason: 'explicit start' });
1403
1734
  requestSessionClose(db,id);
1404
1735
  const moved = fire(db, board, entry, id, entry.workflow.dispatch.start, { roles, payload: { lock: claimer() } });
1405
1736
  setRunSession(db, getTask(db,id).current_run_id, { restart_authorized: true });
1406
1737
  return moved;
1407
1738
  });
1739
+ queueEffects(board, moved.effects);
1408
1740
  const task = getTaskNow(board, id);
1409
1741
  const spec = runSpec(entry, moved.to, task);
1410
1742
  const profile = spec ? view(board, (db) => evaluate(entry.workflow.roles?.[spec.role] ?? 'card.assignee', scopeOf(db, board, task, { params: entry.params }))) : null;
1411
1743
  const lane = profile ? lanes[profile] : null;
1412
- if (spec && !lane) throw new Error(`${id} is ${moved.to}, run by ${spec.role} (${profile ?? 'nobody'}), which is no lane of this home`);
1744
+ if (spec && !lane) throw requestDetail(`${id} is ${moved.to}, run by ${spec.role} (${profile ?? 'nobody'}), which is no lane of this home`);
1413
1745
  if (!lane) return null;
1414
1746
  try { return await launchRun(board,entry,id,lane,fleet,settings); }
1415
1747
  catch (error) {
1416
- if (monitorsRuns(entry) && !error.launchPending && !error.launchSuperseded) launchFailed(board, entry, id, error, { error: error.message, origin: originOf(error) });
1748
+ if ((monitorsRuns(entry) || error.continuationRequired) && !error.launchPending && !error.launchSuperseded) launchFailed(board, entry, id, error, { error: error.message, origin: originOf(error) });
1417
1749
  throw error;
1418
1750
  }
1751
+ } finally { held.releaseRevisions(); }
1419
1752
  }
1420
1753
 
1421
1754
  /** Reconcile durable close intent and ended runs, including old unverified closed_at records.
@@ -1442,6 +1775,9 @@ const TURN_IN_PROGRESS = new Set(['working', 'needs_input']);
1442
1775
  * A harness's own blocking prompt on the pane's screen, which no input from the board answers: the session is stopped,
1443
1776
  * not between turns. Claude Code's usage limit ("What do you want to do? 1. Stop and wait for limit to reset …") is
1444
1777
  * one; its reset time is said when the prompt names it. Null when the screen shows none.
1778
+ *
1779
+ * Read off the pane's screen through its door at the decision (turnAtClose), never from the held record: a held
1780
+ * reading lags the host by the record's cycle, and a limit screen read a round late closes the session a round late.
1445
1781
  */
1446
1782
  const BLOCKING_PROMPTS = [
1447
1783
  { reason: 'provider-limit', harness: 'claude-code', test: /Stop and wait for limit to reset/i, resets: /continue automatically at ([^\n│]+?)\s*(?:│|$)/im },
@@ -1468,19 +1804,34 @@ function unreadWatch(held, mark, gap, at = now()) {
1468
1804
  }
1469
1805
  async function turnAtClose(session, pane = null, { launchWindow = null, quiet = null, gap = 0, inputAbandonment = null } = {}) {
1470
1806
  const machine = session.machine ?? localMachine();
1471
- const recordOf = (rows, id, harness) => rows.find((row) => row.locator?.session_id === id && row.locator?.harness === harness);
1472
1807
  let activity, rows = null;
1473
- // A Codex pane may run under its own Codex home (a pane route's CODEX_HOME, recorded on the run as codex_home), which
1474
- // the machine's default sources do not hold: every read of its session here names that home, as readNative does.
1475
- const codexHome = (session.harness ?? 'claude-code') === 'codex' ? session.codex_home ?? null : null;
1808
+ const homes = (await readNativeHome(session))?.homes;
1809
+ // A home the board never recorded never holds a close: the pane's own door (its machine's listing) reads the turn
1810
+ // from the harness lifecycle under the pane, which its machine knows without the home (ADR 0019).
1811
+ if (!homes) {
1812
+ if (!pane) return { why: `the native home of ${addressOf(session)} is unknown and no pane of it is listed to read` };
1813
+ if (unboundLaunch(session, launchWindow)) return null;
1814
+ const evidence = pane.evidence ?? {};
1815
+ if (evidence.liveness === 'ended') return null;
1816
+ if (evidence.liveness === 'running') return { why: `pane ${session.pane} is in a turn, as its pane's own reading says` };
1817
+ if (evidence.signal === 'asking') return { why: `pane ${session.pane} waits on a question, as its pane's own reading says` };
1818
+ if (evidence.liveness === 'waiting' && evidence.livenessSource === 'cooperative') return null;
1819
+ const command = String(pane.command ?? '').split(/[\\/]/).pop().toLowerCase();
1820
+ if (command && !HARNESS_COMMANDS.has(command)) return null;
1821
+ return { why: `pane ${session.pane}'s turn could not be read through its pane's door (${evidence.liveness ?? 'no reading'})` };
1822
+ }
1476
1823
  if (pane?.pid) {
1477
- const r = await doorOn(machine, 'harness.v1.sessions.activity_under', { pids: [pane.pid], ...(codexHome ? { homes: { codex: join(codexHome, 'sessions') } } : {}) }, { timeout: NATIVE_DISCOVERY_MS });
1824
+ const r = await doorOn(machine, 'harness.v1.sessions.activity_under', { pids: [pane.pid], homes }, { timeout: NATIVE_DISCOVERY_MS });
1478
1825
  const page = r.code === 0 ? lastJson(r.stdout) : null;
1479
1826
  if (!Array.isArray(page?.activities)) return { why: `the session in pane ${session.pane} could not be read just now (${(r.stderr || r.stdout).trim().slice(-160)})` };
1480
- activity = page.activities.find((item) => item.pid === pane.pid)?.activity ?? null;
1827
+ const observed = page.activities.find((item) => item.pid === pane.pid);
1828
+ activity = observed?.activity ?? null;
1829
+ if (activity && (!inNativeHome(observed, homes) || (session.session_id &&
1830
+ (activity.session_id !== session.session_id || activity.harness !== session.harness))))
1831
+ return { why: `pane ${session.pane} has no verified activity for this native identity` };
1481
1832
  // No session read under the pane is no absence (a registry it could not read, another config home, a failed process
1482
1833
  // read, a process walk cut short): the pane's own foreground program says whether a harness runs there. A pane
1483
- // running none is closed; one running a harness is read by the session id the run holds.
1834
+ // running none is closed; one running a harness requires its own process/source receipt.
1484
1835
  if (!activity) {
1485
1836
  // A pane the board launched whose session no reading ever found (the fleet's never bound it, and none runs under
1486
1837
  // its process now) past the launch window holds a harness that never started a session: nothing is cut by its
@@ -1488,19 +1839,13 @@ async function turnAtClose(session, pane = null, { launchWindow = null, quiet =
1488
1839
  if (unboundLaunch(session, launchWindow)) return null;
1489
1840
  const command = String(pane.command ?? '').split(/[\\/]/).pop().toLowerCase();
1490
1841
  if (command && !HARNESS_COMMANDS.has(command)) return null;
1491
- const unfound = `pane ${session.pane} runs ${command || 'a program it does not name'}, whose session could not be read`;
1492
- if (!session.session_id) return { why: unfound };
1493
- try { rows = (await discoverNative(machine, session.harness ?? 'claude-code', session.session_id, codexHome)).sessions; }
1494
- catch (error) { return { why: `${unfound} (${String(error.message).slice(0, 160)})` }; }
1495
- activity = recordOf(rows, session.session_id, session.harness ?? 'claude-code')?.activity ?? null;
1496
- // a row that shows no live owner beside a pane that runs a harness places nothing: another session may run there
1497
- if (activity?.presence !== 'running') return { why: unfound };
1842
+ return { why: `pane ${session.pane} runs ${command || 'a program it does not name'}, whose native identity could not be read` };
1498
1843
  }
1499
1844
  } else if (pane) {
1500
1845
  return { why: `pane ${session.pane} names no process to read` };
1501
1846
  } else {
1502
1847
  if (!session.session_id) return { why: 'its session is not known yet' };
1503
- try { rows = (await discoverNative(machine, session.harness ?? 'claude-code', session.session_id, codexHome)).sessions; }
1848
+ try { rows = (await discoverNative(machine, session.harness ?? 'claude-code', session.session_id, homes)).sessions; }
1504
1849
  catch (error) { return { why: `its session could not be read just now (${String(error.message).slice(0, 160)})` }; }
1505
1850
  activity = nativeReading(rows, session).row?.activity ?? null;
1506
1851
  if (!activity) return { why: `its session ${addressOf(session)} is not in its machine's discovery` };
@@ -1524,9 +1869,9 @@ async function turnAtClose(session, pane = null, { launchWindow = null, quiet =
1524
1869
  if (blocked) return { stopped: blocked };
1525
1870
  const sessionId = activity.session_id ?? session.session_id, harness = activity.harness ?? session.harness ?? 'claude-code';
1526
1871
  if (!sessionId || !quiet) return { why: unknown };
1527
- if (!rows) try { rows = (await discoverNative(machine, harness, sessionId, codexHome)).sessions; }
1872
+ if (!rows) try { rows = (await discoverNative(machine, harness, sessionId, homes)).sessions; }
1528
1873
  catch (error) { return { why: `${unknown}, and its record could not be read just now (${String(error.message).slice(0, 160)})` }; }
1529
- const record = recordOf(rows, sessionId, harness);
1874
+ const record = nativeReading(rows, { session_id: sessionId, harness }).row;
1530
1875
  if (TURN_IN_PROGRESS.has(record?.activity?.turn)) return inTurn(record.activity.turn);
1531
1876
  const mark = Math.max(Number(record?.updated_at_ms) || 0, Number(activity.evidence?.native_state_since_ms) || 0);
1532
1877
  if (!mark) return { why: `${unknown}, and its record shows no write to count from` };
@@ -1587,21 +1932,46 @@ function recordInputClose(board, row, session, reading) {
1587
1932
  });
1588
1933
  }
1589
1934
 
1935
+ /** Pending close candidates only, in finite indexed pages. */
1936
+ async function* closeCandidates(board, taskId) {
1937
+ let cursor = 0;
1938
+ const high = view(board, db => Number(db.prepare('SELECT MAX(id) AS id FROM task_runs').get()?.id ?? 0));
1939
+ for (;;) {
1940
+ const rows = view(board, db => db.prepare(`SELECT id,task_id,profile,ended_at,metadata FROM task_runs
1941
+ WHERE ${CLOSE_OPEN_RUNS} AND id>? AND id<=?${taskId ? ' AND task_id=?' : ''} ORDER BY id LIMIT 32`)
1942
+ .all(cursor, high, ...(taskId ? [taskId] : [])));
1943
+ if (!rows.length) return;
1944
+ for (const row of rows) yield row;
1945
+ cursor = rows.at(-1).id;
1946
+ if (rows.length < 32) return;
1947
+ await yieldToOwner();
1948
+ }
1949
+ }
1950
+
1590
1951
  /** The closes this process is making now, by run: each one's outcome, for a pass that meets it under way. */
1591
1952
  const closesUnderway = new Map();
1592
1953
  export async function closeRequested(board, report, { taskId = null, caller = null, ttl = null, fleet = null } = {}) {
1593
- const rows = await readRunPages(board.db, 'id, task_id, profile, ended_at, metadata', { task: taskId, where: 'metadata IS NOT NULL' });
1594
- // Repair pre-transfer history once: only identities with an older unended reference need a write.
1595
- const latest = new Map(), transfers = new Map();
1596
- for (const row of [...rows].sort((a,b) => b.id-a.id)) {
1597
- const s = json(row.metadata, {})?.supercode;
1598
- if (!s) continue;
1599
- for (const identity of [s.session_id && `sid:${s.session_id}`, s.pane && `pane:${s.pane}`].filter(Boolean)) {
1600
- const key = `${row.task_id}:${s.machine}:${identity}`;
1601
- const successor = latest.get(key);
1602
- if (successor && !row.ended_at) transfers.set(successor.id, successor);
1603
- else if (!successor) latest.set(key, row);
1954
+ const onTask = taskId ? ' AND task_id=?' : '';
1955
+ // Only unended references can need transfer repair. Seek each one's latest successor by
1956
+ // its indexed identity, instead of decoding every historical run to discover the same fact.
1957
+ const transfers = new Map();
1958
+ let repairCursor = 0;
1959
+ for (;;) {
1960
+ const page = view(board, db => db.prepare(`SELECT id,task_id,metadata FROM task_runs
1961
+ WHERE ended_at IS NULL AND id>?${onTask} ORDER BY id LIMIT 32`).all(repairCursor, ...(taskId ? [taskId] : [])));
1962
+ if (!page.length) break;
1963
+ for (const row of page) {
1964
+ const session = json(row.metadata, {})?.supercode;
1965
+ for (const name of ['session_id', 'pane']) if (session?.[name]) {
1966
+ const successor = view(board, db => db.prepare(`SELECT id,task_id,metadata FROM task_runs WHERE task_id=?
1967
+ AND ${runField('machine')} IS ? AND ${runField(name)}=? AND id>? ORDER BY id DESC LIMIT 1`)
1968
+ .get(row.task_id, session.machine ?? null, session[name], row.id));
1969
+ if (successor) transfers.set(successor.id, successor);
1970
+ }
1604
1971
  }
1972
+ repairCursor = page.at(-1).id;
1973
+ if (page.length < 32) break;
1974
+ await yieldToOwner();
1605
1975
  }
1606
1976
  if (transfers.size) tx(board, (db) => {
1607
1977
  for (const row of transfers.values()) {
@@ -1613,15 +1983,15 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1613
1983
  // Each machine's pane list, read once per pass: its live panes, and its dead ones. A dead pane is still the machine's
1614
1984
  // until it is closed (a pane whose program failed is kept, marked dead, for its last screen), so it is closed as any.
1615
1985
  const panes = new Map();
1986
+ // asked of the machine's door as this board, which lists the panes the board's grants there let it observe
1616
1987
  const paneListOn = async (machine) => {
1617
1988
  if (!panes.has(machine)) {
1618
- const r = await doorOn(machine, 'harness.v1.panes.list',{}, { timeout: 60_000, as: boardAddress(board.label) });
1619
- const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
1620
- panes.set(machine, Array.isArray(list) ? { live: new Map(list.filter(p => !p.dead).map((p) => [p.pane, { pid: p.pid ?? null, command: p.activeCommand ?? null }])), dead: new Set(list.filter(p => p.dead).map((p) => p.pane)) } : { error: `cannot read panes on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}` });
1989
+ const { list, error } = await paneRows(machine, { timeout: 60_000, as: boardAddress(board.label) });
1990
+ panes.set(machine, list ? { live: new Map(list.filter(p => !p.dead).map((p) => [p.pane, { pid: p.pid ?? null, command: p.activeCommand ?? null, evidence: p.evidence ?? null }])), dead: new Set(list.filter(p => p.dead).map((p) => p.pane)) } : { error: `cannot read panes on ${machine}: ${error}` });
1621
1991
  }
1622
1992
  return panes.get(machine);
1623
1993
  };
1624
- for (const row of rows) {
1994
+ for await (const row of closeCandidates(board, taskId)) {
1625
1995
  await yieldToOwner();
1626
1996
  let session = json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
1627
1997
  if (!session || detachedSession(board.db, session) || session.close_transferred_to || session.close_confirmed_at) continue;
@@ -1634,17 +2004,17 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1634
2004
  if (session.mode === 'adopt' && session.address) {
1635
2005
  tx(board, (db) => setRunSession(db, row.id, { launch_pending: false, launch_key: null, launch_error: null }));
1636
2006
  session = json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
1637
- } else if (!session.pane && !session.address && !session.pid) {
2007
+ } else if ((!session.pane && !session.address && !session.pid) || (session.launch_not_issued && session.mode === 'fresh')) {
1638
2008
  // settled only on a fleet reading that shows no session with its id on its machine; a lost open answer may
1639
- // have opened one, and that is closed as its own once it is bound
1640
- if (launchReading(fleet, session) !== 'absent') { report.undelivered.push([row.task_id, session.launch_key, 'close waits for a fleet reading that shows its launch absent', 'close']); continue; }
2009
+ // have opened one, and that is closed as its own once it is bound. A machine-cancelled absent key cannot arrive late.
2010
+ if (!session.launch_not_issued && launchReading(fleet, session) !== 'absent') { report.undelivered.push([row.task_id, session.launch_key, 'close waits for a fleet reading that shows its launch absent', 'close']); continue; }
1641
2011
  const settled = tx(board, (db) => {
1642
2012
  const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
1643
- if (!current || current.launch_key !== session.launch_key || current.pane || current.close_confirmed_at) return false;
2013
+ if (!current || current.launch_key !== session.launch_key || (current.pane && !current.launch_not_issued) || current.close_confirmed_at) return false;
1644
2014
  const at = now();
1645
2015
  setRunSession(db, row.id, { launch_pending: false, close_requested: current.close_requested ?? at, closed_at: at, close_confirmed_at: at, close_error: null,
1646
2016
  launch_error: current.launch_error ?? 'no pane was bound to its launch', forgotten: current.mode === 'fresh' ? true : Boolean(current.forgotten) });
1647
- event(db, row.task_id, 'session_closed', { key: session.launch_key, machine: session.machine, reason: 'no pane was bound to its launch, and the fleet reading shows neither a pane nor its session' }, row.id);
2017
+ event(db, row.task_id, 'session_closed', { key: session.launch_key, machine: session.machine, reason: session.launch_not_issued ? 'the machine retired its absent launch key as not issued' : 'no pane was bound to its launch, and the fleet reading shows neither a pane nor its session' }, row.id);
1648
2018
  return true;
1649
2019
  });
1650
2020
  if (settled) (report.closed ??= []).push({ task_id: row.task_id, ...session });
@@ -1652,7 +2022,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1652
2022
  }
1653
2023
  }
1654
2024
  if ((session?.monitor_held || session?.retain_after_run) && !session.close_requested) continue; // only an explicit close action ends this process
1655
- if ((!session?.pane && session?.surface !== 'native') || session.close_transferred_to || session.close_confirmed_at || (!row.ended_at && !session.close_requested)) continue;
2025
+ if ((!session?.pane && !['native', 'headless'].includes(session?.surface)) || session.close_transferred_to || session.close_confirmed_at || (!row.ended_at && !session.close_requested)) continue;
1656
2026
  if (caller && session.session_id === caller) continue; // let the command that ended its own run return
1657
2027
  // The board closes only what it launched. An adopted session (a person's own thread, a hook session the card was
1658
2028
  // given) is released instead: left running, its slot freed. Closing one failed on every round for a session no close
@@ -1688,6 +2058,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1688
2058
  else if ((await paneListOn(session.machine ?? localMachine())).dead?.has(session.pane)) continue;
1689
2059
  }
1690
2060
  const active = view(board, (db) => db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all());
2061
+ for (const run of active) await readNativeHome(json(run.metadata, {})?.supercode);
1691
2062
  // Persist retries even for historical ends that did not have a close_session action. The close is claimed in the
1692
2063
  // same transaction, before the pane or process is touched: of two rounds (or a round and a verb) that read one close,
1693
2064
  // only the one whose claim committed closes it, and the other never records the pane it finds gone as a failure.
@@ -1711,25 +2082,35 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1711
2082
  try {
1712
2083
  const claimed = tx(board, (db) => {
1713
2084
  const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
1714
- if (!current || detachedSession(db, current) || current.close_confirmed_at || current.close_transferred_to || current.pane !== session.pane || current.session_id !== session.session_id || claimHolds(current.close_claim)) return false;
2085
+ if (!current || detachedSession(db, current) || current.close_confirmed_at || current.close_transferred_to || current.pane !== session.pane || current.session_id !== session.session_id || current.pid !== session.pid
2086
+ || homeIdentity(current) !== homeIdentity(session) || claimHolds(current.close_claim)) return false;
1715
2087
  requestSessionClose(db, row.task_id, { runId: row.id });
1716
2088
  // a close this process already claims, with a minute of its claim left, is tried again without a new claim
1717
2089
  const own = current.close_claim?.lock === claimer() && Number(current.close_claim.expires) > now() + 60;
1718
2090
  if (!own) setRunSession(db, row.id, { close_claim: newClaim(ttl ?? boardSettings(workflowFor(board.root).params).ttl) });
1719
- return true;
2091
+ const retained = json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(row.id)?.metadata, {})?.supercode;
2092
+ return { lock: retained.close_claim.lock, expires: retained.close_claim.expires, requested: retained.close_requested,
2093
+ monitor: retained.monitor_held, retained: retained.retain_after_run };
1720
2094
  });
1721
2095
  if (!claimed) continue;
1722
2096
  noteClaim(board, 'runs', row.id);
2097
+ const revalidate = () => requireDeferredClose(board, row, session, claimed);
1723
2098
  try {
1724
2099
  const machine = session.machine ?? localMachine();
1725
- if (session.surface === 'native' && !session.pane) {
1726
- const reading = nativeReading(await readNative(machine, session.harness, session.session_id, session.codex_home ?? null), session);
2100
+ if (session.surface === 'headless') {
2101
+ await stopSession(board, session, revalidate);
2102
+ } else if (session.surface === 'native' && !session.pane) {
2103
+ const reading = nativeReading(await readNative(machine, session.harness, session.session_id, nativeHomes(session)), session);
1727
2104
  if (reading.alive == null) throw new Error(`close deferred: native liveness unknown for ${addressOf(session)}`);
1728
2105
  if (reading.alive) {
1729
- const protectedNow = active.some(r => addressOf(json(r.metadata, {})?.supercode) === addressOf(session));
2106
+ const protectedNow = active.some(r => {
2107
+ const current = json(r.metadata, {})?.supercode;
2108
+ return addressOf(current) === addressOf(session)
2109
+ && (!homeIdentity(current) || !homeIdentity(session) || sameNativeSession(current, session));
2110
+ });
1730
2111
  if (protectedNow) throw new Error('close deferred: native session is owned by an unended run');
1731
2112
  if (row.ended_at) { const { idleQuiet: quiet, interval } = boardSettings(workflowFor(board.root).params); await holdForTurn(session, null, { quiet, gap: Math.max(3 * interval, quiet / 2) }); }
1732
- await stopSession(board, session);
2113
+ await stopSession(board, session, revalidate);
1733
2114
  }
1734
2115
  } else if (!finalFailure) {
1735
2116
  const observed = await paneListOn(machine);
@@ -1741,7 +2122,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1741
2122
  if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
1742
2123
  }
1743
2124
  // a dead pane's program has ended: it has no turn to close between, and is closed at once
1744
- if (dead) { await stopSession(board, session); observed.dead.delete(session.pane); }
2125
+ if (dead) { await stopSession(board, session, revalidate); observed.dead.delete(session.pane); }
1745
2126
  else if (observed.live.has(session.pane)) {
1746
2127
  // An ended run's session is closed only between its turns, read last, after the claim and the pane list (a pane
1747
2128
  // already gone is settled above with no reading; t_f34710bc's coder was cut mid-proof at 15:01:13Z, 2026-10-04).
@@ -1759,10 +2140,11 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1759
2140
  } else if (reading && !recordInputClose(board, row, session, reading))
1760
2141
  throw new Error('close deferred: ownership changed before ending its input dialog');
1761
2142
  }
1762
- await stopSession(board, session); observed.live.delete(session.pane);
2143
+ await stopSession(board, session, revalidate); observed.live.delete(session.pane);
1763
2144
  }
1764
2145
  }
1765
2146
  } catch (e) {
2147
+ if (e.closeSuperseded) { report.undelivered.push([row.task_id, addressOf(session), e.message, 'close']); continue; }
1766
2148
  // a deferred close (an unreachable door, an unread pane list or session discovery) waits for the next round;
1767
2149
  // the prefix is decided on the whole message, before it is cut to size
1768
2150
  const message = String(e.message), deferred = Boolean(e.launchDeferred) || /^close deferred: /.test(message);
@@ -1770,7 +2152,8 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1770
2152
  // the watch a turn reading left (or ended: a turn read in progress); an attempt that took none keeps the last
1771
2153
  if ('closeUnread' in e) unread = e.closeUnread;
1772
2154
  }
1773
- tx(board, (db) => {
2155
+ const confirmed = tx(board, (db) => {
2156
+ if (!deferredCloseCurrent(board, row, session, claimed)) return false;
1774
2157
  const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
1775
2158
  if (!current || detachedSession(db, current) || current.pane !== session.pane || current.session_id !== session.session_id) return;
1776
2159
  const at = now();
@@ -1798,18 +2181,64 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1798
2181
  event(db, row.task_id, 'session_closed', { session_id: session.session_id, pane: session.pane, machine: session.machine, unsubscribed,
1799
2182
  ...(finalFailure ? { reason: 'authorized pane presence observed absence after an earlier close refusal' } : {}) }, row.id);
1800
2183
  }
2184
+ return true;
1801
2185
  });
1802
2186
  if (error) report.undelivered.push([row.task_id, addressOf(session), error, 'close']);
1803
- else { closed = true; (report.closed ??= []).push({ task_id: row.task_id, ...session }); }
2187
+ else if (confirmed) { closed = true; (report.closed ??= []).push({ task_id: row.task_id, ...session }); }
1804
2188
  } finally { closesUnderway.delete(flight); settle({ error, closed }); }
1805
2189
  }
2190
+ // A backlog request that first met a busy or unread native session remains its dispatch hold. Once the existing
2191
+ // close pass records every stop/release receipt, apply that retained request as its original sender.
2192
+ const backlogged = await completeBacklogRequests(board, { taskId });
2193
+ if (backlogged.length) (report.backlogged ??= []).push(...backlogged);
1806
2194
  }
1807
2195
 
1808
2196
  // When this process last read each run's turn (run id → seconds). Kept in memory on purpose: a board that restarted
1809
2197
  // has read nothing itself, and its first reading of a run is where that run's watch resumes (resumedWatch).
1810
2198
  const idleWatch = new Map();
2199
+ // When this process last read each run's turn as unreadable (run id → seconds): its unread clock (`unknown_since`)
2200
+ // runs only across rounds this process read without a break, whether or not the run's machine read online.
2201
+ const turnUnreadWatch = new Map();
1811
2202
  /** Native idle intervals are independent of dispatch cadence and transcript writes.
1812
2203
  * Keep bounded readings on the run so an ordinary quiet stretch can be reviewed later. */
2204
+ /**
2205
+ * The oldest message each running card's session on this machine has waiting undelivered, with its mailbox's reason
2206
+ * (`harness.v1.mail.waiting` for those mailboxes alone), by session address: `{ message_id, since_ms, attempts, reason }`,
2207
+ * or `{ unread }` when this machine's daemon did not answer. One call for the local sessions whose stall notice is due,
2208
+ * and none on an observation that has no such session.
2209
+ * Another machine's sessions add nothing: that door answers only its own machine's processes.
2210
+ */
2211
+ async function undeliveredMail(board, running) {
2212
+ const here = localMachine(), addresses = new Set();
2213
+ for (const { id } of running) {
2214
+ const address = addressOf(currentRun(board.db, id)?.metadata?.supercode);
2215
+ if (address && address.split(':')[1] === here) addresses.add(address);
2216
+ }
2217
+ const out = new Map();
2218
+ if (!addresses.size) return out;
2219
+ const asked = [...addresses].slice(0, 256);
2220
+ for (const address of asked) out.set(address, null);
2221
+ for (const address of addresses) if (!out.has(address)) out.set(address, { unread: 'the due stall mailboxes exceeded the bounded mail read' });
2222
+ let rows;
2223
+ try {
2224
+ rows = (await machineCall(null, 'harness.v1.mail.waiting', { addresses: asked }, { timeoutMs: 5_000 }))?.rows;
2225
+ if (!Array.isArray(rows)) throw new Error('this machine answered without its waiting-mail rows');
2226
+ }
2227
+ catch (error) {
2228
+ const unread = String(error?.message ?? error).slice(0, 300);
2229
+ for (const address of addresses) out.set(address, { unread });
2230
+ return out;
2231
+ }
2232
+ const text = (value, bound) => typeof value === 'string' && value ? value.slice(0, bound) : null;
2233
+ for (const row of Array.isArray(rows) ? rows : []) {
2234
+ const to = text(row?.to, 512), message = text(row?.id, 128), reason = text(row?.last_error, 2_000);
2235
+ if (!to || !addresses.has(to) || !message || !reason || out.get(to) != null) continue;
2236
+ out.set(to, { message_id: message, since_ms: Number.isFinite(row.since_ms) ? row.since_ms : null,
2237
+ attempts: Number.isInteger(row.attempts) && row.attempts >= 0 ? row.attempts : 0, reason });
2238
+ }
2239
+ return out;
2240
+ }
2241
+
1813
2242
  async function observeIdle(board, fleet, settings, report, entry, effects) {
1814
2243
  if (!fleet.ok) return;
1815
2244
  // The published fleet says whether a session runs and its turn, not when it last wrote: that is the session's own
@@ -1817,84 +2246,284 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1817
2246
  // Without it every reading was 'no_timestamp' and no session was ever found idle.
1818
2247
  const updatedAt = (session) => {
1819
2248
  let rows = null;
1820
- try { rows = nativeSessions(session.machine ?? localMachine(), session.harness ?? 'claude-code', session.session_id); } catch { /* unread: no timestamp */ }
2249
+ try { rows = nativeSessions(session); } catch { /* unread: no timestamp */ }
1821
2250
  return Number(rows?.find((row) => row.locator?.session_id === session.session_id)?.updated_at_ms) || null;
1822
2251
  };
1823
2252
  const managers = [].concat(entry.params.managers ?? []).filter(Boolean);
1824
- const running = board.db.prepare(entry.workflow.run ? "SELECT id FROM tasks WHERE current_run_id IS NOT NULL" : "SELECT id FROM tasks WHERE status = 'running'").all();
1825
- const edges = localSessionDelegations(board.root, localMachine());
1826
- const reached = new Set(running.map(({ id }) => addressOf(currentRun(board.db, id)?.metadata?.supercode)).filter(Boolean));
2253
+ const running = board.db.prepare(entry.workflow.run ? 'SELECT id FROM tasks WHERE current_run_id IS NOT NULL' : "SELECT id FROM tasks WHERE status = 'running'").all();
2254
+ const idleSubject = (db, id) => {
2255
+ const run = currentRun(db, id);
2256
+ const session = run?.metadata?.supercode;
2257
+ if (!session?.session_id || !['pane', 'native'].includes(session.surface) || session.offline_since != null || run.ended_at != null) return null;
2258
+ const task = getTask(db, id), status = statusOf(db, task);
2259
+ const handles = name => entry.workflow.statuses[status]?.on?.[name] || entry.workflow.on?.[name] || entry.workflow.run?.on?.[name];
2260
+ if (!handles('session_idle') && !handles('session_stalled')) return null;
2261
+ const held = task.backlog || task.paused || task.blocked_by.length || task.archived || task.detached
2262
+ || scopeOf(db, board, task, { params: entry.params, lazy: true }).card.dispatch_hold;
2263
+ return { run, session, task, status, handles, held };
2264
+ };
2265
+ const cardsOf = new Map(), retained = new Map(), sources = new Map(), listedSources = new Map();
2266
+ for (const row of fleet.sessions) {
2267
+ await yieldToOwner();
2268
+ if (stopping) return;
2269
+ const identity = homeIdentity({ ...row, native_home: locatorHome(row.locator) });
2270
+ const homes = listedSources.get(row.address) ?? new Set(); homes.add(identity); listedSources.set(row.address, homes);
2271
+ }
2272
+ for (const { id } of running) {
2273
+ await yieldToOwner();
2274
+ if (stopping) return;
2275
+ const session = view(board, db => currentRun(db, id)?.metadata?.supercode);
2276
+ if (!session) continue;
2277
+ await timed('idle native preparation', () => readBefore(fleet, session));
2278
+ if (stopping) return;
2279
+ const consumesCommits = view(board, db => {
2280
+ const subject = idleSubject(db, id);
2281
+ if (!subject || subject.held || !subject.handles('session_idle') || subject.session.role === 'reviewer') return false;
2282
+ const current = subject.session;
2283
+ if (current.machine && !fleet.online.has(current.machine) || current.surface === 'native' && sessionAlive(fleet, current) !== true) return false;
2284
+ return true;
2285
+ });
2286
+ if (consumesCommits) await timed('idle commit preparation', () => refreshCardInputs(board, id, { commits: 'cached' }));
2287
+ const address = addressOf(session);
2288
+ if (!address) continue;
2289
+ cardsOf.set(address, [...(cardsOf.get(address) ?? []), id]);
2290
+ retained.set(address, [...(retained.get(address) ?? []), session]);
2291
+ const identity = homeIdentity(session), prior = sources.get(address);
2292
+ sources.set(address, prior === undefined || prior === identity ? identity : null);
2293
+ }
2294
+ const edges = await timed('idle local delegations', () => localSessionDelegations(board.root, localMachine(), () => !stopping));
2295
+ if (stopping) return;
2296
+ for (const row of fleet.sessions) {
2297
+ await yieldToOwner();
2298
+ if (stopping) return;
2299
+ if (row.starter_session?.startsWith('sc:')) edges.push({ starter: row.starter_session, session: row.address });
2300
+ }
2301
+ for (const { id } of running) {
2302
+ await yieldToOwner();
2303
+ if (stopping) return;
2304
+ const edge = view(board, db => {
2305
+ const session = currentRun(db, id)?.metadata?.supercode;
2306
+ return session ? { starter: creatorOf(db, id)?.replace(/^session:/, ''), target: addressOf(session) } : null;
2307
+ });
2308
+ const { starter, target } = edge ?? {};
2309
+ if (starter?.startsWith('sc:') && target) edges.push({ starter, session: target });
2310
+ }
2311
+ const reached = new Set(retained.keys());
1827
2312
  const delegations = [];
1828
2313
  const pending = [...reached];
1829
2314
  const byStarter = new Map();
1830
- for (const edge of edges) { const children = byStarter.get(edge.starter) ?? []; children.push(edge); byStarter.set(edge.starter, children); }
2315
+ for (const edge of edges) {
2316
+ await yieldToOwner();
2317
+ if (stopping) return;
2318
+ const children = byStarter.get(edge.starter) ?? []; children.push(edge); byStarter.set(edge.starter, children);
2319
+ }
1831
2320
  while (pending.length) for (const edge of byStarter.get(pending.pop()) ?? []) {
2321
+ await yieldToOwner();
2322
+ if (stopping) return;
1832
2323
  delegations.push(edge);
1833
2324
  if (!reached.has(edge.session)) { reached.add(edge.session); pending.push(edge.session); }
1834
2325
  }
1835
2326
  report.idle_delegations = { recorded: edges.length, reachable: delegations.length };
1836
- const delegatedReadings = [...fleet.sessions];
2327
+ const delegatedReadings = [], delegatedReadAddresses = new Set();
1837
2328
  // Forks and concurrent sessions need not have a pane. Read only the exact
1838
2329
  // native identities this home's starter receipts name, before any write.
1839
2330
  for (const { session: address } of delegations) {
1840
- if (delegatedReadings.some(row => row.address === address)) continue;
1841
- const match = /^sc:([^:]+):(claude-code|codex):([^:]+)$/.exec(address);
2331
+ await yieldToOwner();
2332
+ if (stopping) return;
2333
+ if (delegatedReadAddresses.has(address)) continue;
2334
+ delegatedReadAddresses.add(address);
2335
+ const match = /^sc:([^:]+):([^:]+):(.+)$/.exec(address);
1842
2336
  if (!match) continue;
1843
2337
  const [, machine, harness, session_id] = match;
1844
- const child = { machine, harness, session_id, surface: 'native' };
1845
- await readBefore(fleet, child);
2338
+ if (!retained.has(address)) {
2339
+ const saved = await timed('idle delegated provenance', async () => {
2340
+ const revision = listRevision(board.db), sessions = [];
2341
+ let after = 0;
2342
+ for (;;) {
2343
+ const rows = board.db.prepare(`SELECT id,metadata FROM task_runs WHERE id IN (${NOTICE_RUN_IDS})
2344
+ AND id>? ORDER BY id LIMIT 64`).all(session_id, address, after);
2345
+ for (const row of rows) {
2346
+ const session = json(row.metadata, {})?.supercode;
2347
+ if (session && (session.address === address || session.machine === machine && session.session_id === session_id
2348
+ && (session.harness ?? 'claude-code') === harness)) sessions.push(session);
2349
+ }
2350
+ if (rows.length < 64) break;
2351
+ after = rows.at(-1).id;
2352
+ await yieldToOwner();
2353
+ if (stopping) return null;
2354
+ }
2355
+ const current = listRevision(board.db);
2356
+ return Object.keys(revision).every(key => revision[key] === current[key]) ? sessions : null;
2357
+ });
2358
+ if (stopping) return;
2359
+ if (saved === null) { retained.set(address, []); sources.set(address, null); continue; }
2360
+ if (saved.length) {
2361
+ for (const session of saved) {
2362
+ await yieldToOwner();
2363
+ if (stopping) return;
2364
+ await timed('idle delegated native home', () => readNativeHome(session));
2365
+ }
2366
+ retained.set(address, saved);
2367
+ const homes = new Set(saved.map(homeIdentity));
2368
+ sources.set(address, homes.size === 1 ? [...homes][0] : null);
2369
+ }
2370
+ }
2371
+ if (!retained.has(address) && !['claude-code', 'codex'].includes(harness)) {
2372
+ const published = fleet.sessions.filter(row => row.address === address);
2373
+ const stores = new Set(published.map(row => row.locator?.storage ? JSON.stringify(row.locator.storage) : null));
2374
+ if (stores.size === 1 && !stores.has(null)) {
2375
+ sources.set(address, [...stores][0]); listedSources.set(address, stores);
2376
+ delegatedReadings.push(published.find(row => row.state === 'running' && row.turn === 'working') ?? published[0]);
2377
+ }
2378
+ continue;
2379
+ }
2380
+ const child = retained.get(address)?.[0] ?? { machine, harness, session_id, surface: 'native' };
2381
+ if (retained.has(address) && !sources.get(address)) continue;
2382
+ if (!retained.has(address)) try {
2383
+ const rows = (await timed('idle delegated native home', () => readNative(machine, harness, session_id, null, { activity: false })))
2384
+ .filter(row => row.locator?.harness === harness && row.locator.session_id === session_id);
2385
+ const homes = new Map(rows.map(row => { const home = locatorHome(row.locator); return [JSON.stringify(home?.homes), home]; }));
2386
+ if (homes.size === 1) child.native_home = [...homes.values()][0];
2387
+ } catch { /* source unknown */ }
2388
+ await timed('idle delegated activity', () => readBefore(fleet, child));
2389
+ if (stopping) return;
2390
+ const identity = homeIdentity(child);
2391
+ if (!identity) continue;
2392
+ sources.set(address, identity);
2393
+ const published = fleetSessionRow(fleet, child);
2394
+ if (published) { delegatedReadings.push(published); continue; }
1846
2395
  try {
1847
- const { row } = nativeReading(nativeSessions(machine, harness, session_id), child);
2396
+ const { row } = nativeReading(nativeSessions(child), child);
1848
2397
  if (row?.activity) delegatedReadings.push({ address, state: row.activity.presence, turn: row.activity.turn });
1849
2398
  } catch { /* an unread child is never called working */ }
1850
2399
  }
1851
- // every running card a session is on: a session waiting on its user is told once, naming them all
1852
- const cardsOf = new Map();
1853
- for (const { id } of running) {
1854
- const session = currentRun(board.db, id)?.metadata?.supercode; await readBefore(fleet, session);
1855
- await refreshCardInputs(board, id, { commits: true });
1856
- if (addressOf(session)) cardsOf.set(addressOf(session), [...(cardsOf.get(addressOf(session)) ?? []), id]);
2400
+ const liveOf = (session) => {
2401
+ const published = fleetSessionRow(fleet, session);
2402
+ let nativeRow = null;
2403
+ if (!published && (session.surface === 'native' || sessionAlive(fleet, session) === true)) try { nativeRow = nativeReading(nativeSessions(session), session).row; } catch { /* unread: no turn */ }
2404
+ return published ?? (nativeRow ? { address: addressOf(session), turn: nativeRow.activity?.turn ?? null, waiting_for: nativeRow.activity?.evidence?.waiting_for ?? null,
2405
+ turn_source: nativeRow.activity?.evidence?.source ?? null, turn_since_ms: nativeRow.activity?.evidence?.native_state_since_ms ?? null } : undefined);
2406
+ };
2407
+ const scopedAddress = target => sources.get(target) && (!listedSources.has(target)
2408
+ || listedSources.get(target).size === 1 && listedSources.get(target).has(sources.get(target)));
2409
+ // Starter receipts name addresses; they cannot disambiguate an address observed in multiple stores.
2410
+ const scopedEdges = delegations.filter(edge => scopedAddress(edge.starter) && scopedAddress(edge.session));
2411
+ const delegatedOf = (session) => {
2412
+ const address = addressOf(session);
2413
+ if (!homeIdentity(session) || sources.get(address) !== homeIdentity(session)) return [];
2414
+ return delegatedWork(address, scopedEdges, delegatedReadings);
2415
+ };
2416
+ const unreadClock = (session, run, observedAt) => {
2417
+ const last = turnUnreadWatch.get(run.id);
2418
+ return last != null && observedAt - last <= Math.max(3 * settings.interval, settings.idleQuiet / 2) && Number(session.unknown_since) > 0 ? Number(session.unknown_since) : observedAt;
2419
+ };
2420
+ const resumedClock = (session, run, live, observedAt) => {
2421
+ const last = idleWatch.get(run.id);
2422
+ return last == null || observedAt - last > Math.max(3 * settings.interval, settings.idleQuiet / 2)
2423
+ ? resumedWatch(session, live, run, fleet.linkedSince?.get(session.machine ?? localMachine()), observedAt) : null;
2424
+ };
2425
+ const noticeDue = (limits, quiet, prior, observedAt) => Number(limits.idle_notice) > 0 && quiet >= Number(limits.idle_notice)
2426
+ && (!prior || (Number(limits.idle_notice_repeat) > 0 && observedAt - prior.at >= Number(limits.idle_notice_repeat)));
2427
+ // A mailbox read explains a due notice; it is not part of every idle reading.
2428
+ // Preview only the existing clock/eligibility rules, without writes. The
2429
+ // transaction below rechecks them after the one bounded door read.
2430
+ const dueMail = [];
2431
+ for (const row of running) {
2432
+ await yieldToOwner();
2433
+ if (stopping) return;
2434
+ const due = view(board, (db) => {
2435
+ const { id } = row;
2436
+ const subject = idleSubject(db, id);
2437
+ if (!subject || subject.held || !subject.handles('session_stalled') || addressOf(subject.session)?.split(':')[1] !== localMachine()) return false;
2438
+ const { task, run } = subject;
2439
+ let { session } = subject;
2440
+ const observedAt = now(), limits = limitsOf(db, board, entry, task, session);
2441
+ if (session.machine && !fleet.online.has(session.machine) || sessionAlive(fleet, session) !== true) return false;
2442
+ const live = liveOf(session);
2443
+ if (live?.turn !== 'idle') return false;
2444
+ const resumed = resumedClock(session, run, live, observedAt);
2445
+ if (resumed !== null) session = { ...session, watch_since: resumed };
2446
+ if (session.unknown_since != null) session = { ...session, watch_since: observedAt };
2447
+ const prior = session.idle_notice?.native_since_ms === (live?.turn_since_ms ?? null) ? session.idle_notice : null;
2448
+ const due = noticeDue(limits, idleInterval(session, live, observedAt).quiet, prior, observedAt);
2449
+ return due && !delegatedOf(session).length;
2450
+ });
2451
+ if (due) dueMail.push(row);
1857
2452
  }
2453
+ const undelivered = await undeliveredMail(board, dueMail);
2454
+ const mailOf = (address) => undelivered.has(address) ? undelivered.get(address) : address?.split(':')[1] === localMachine()
2455
+ ? { unread: 'the session became due after this observation selected its mailboxes' } : null;
1858
2456
  for (const { id } of running) {
1859
- let staged = [], watched = null;
2457
+ await yieldToOwner();
2458
+ if (stopping) return;
2459
+ let staged = [], watched = null, unread = null;
1860
2460
  txRead(board, (db) => {
1861
- staged = []; watched = null;
1862
- const task = getTask(db, id);
1863
- const status = statusOf(db, task);
1864
- const handles = name => entry.workflow.statuses[status]?.on?.[name] || entry.workflow.on?.[name] || entry.workflow.run?.on?.[name];
1865
- if (!handles('session_idle') && !handles('session_stalled')) return;
1866
- const runRow = currentRun(db, id);
1867
- let session = runRow?.metadata?.supercode;
1868
- if (!session?.session_id || !['pane', 'native'].includes(session.surface) || session.offline_since != null) return;
1869
- if (task.paused || task.archived || task.detached || runRow.ended_at != null) {
2461
+ staged = []; watched = null; unread = null;
2462
+ const subject = idleSubject(db, id);
2463
+ if (!subject) return;
2464
+ const { task, status, handles, run: runRow } = subject;
2465
+ let { session } = subject;
2466
+ if (subject.held) {
1870
2467
  if (session.idle_since != null || session.idle_notice) setRunSession(db, runRow.id, { idle_since: null, idle_notice: null, watch_since: now() });
1871
2468
  return;
1872
2469
  }
1873
- // a machine this round did not read as online (offline, or its door silent) shows nothing of its session: no
1874
- // clock of the run moves on a round that could not see it
1875
- if (session.machine && !fleet.online.has(session.machine)) return;
1876
- if (session.surface === 'native' && sessionAlive(fleet, session) !== true) return;
2470
+ // Unknown is an observation failure, never a workflow stall or work reminder (ADR 0073).
2471
+ const reportUnread = (observedAt, why, live = null) => {
2472
+ unread = [runRow.id, observedAt];
2473
+ const since = unreadClock(session, runRow, observedAt);
2474
+ if (session.unknown_since !== since) setRunSession(db, runRow.id, { unknown_since: since });
2475
+ if (session.idle_since != null || session.idle_notice) setRunSession(db, runRow.id, { idle_since: null, idle_notice: null });
2476
+ const quiet = Math.max(0, observedAt - since);
2477
+ const machineReadable = !session.machine || fleet.online.has(session.machine);
2478
+ const address = addressOf(session), identity = homeIdentity(session), key = JSON.stringify([address, identity, machineReadable]);
2479
+ if (quiet < settings.idleQuiet || session.unknown_notice?.key === key && session.unknown_notice.since === since) return;
2480
+ const sinceAt = new Date(since * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
2481
+ const observation = { decision: 'turn_unknown_reported', session: address, run_id: runRow.id, observed_at: observedAt, quiet_seconds: quiet, turn: live?.turn ?? null,
2482
+ turn_readable: false, unreadable_since: since, unreadable_since_at: sinceAt, unreadable_reason: why,
2483
+ idle_since: null, idle_since_at: null, last_turn_at: null, last_turn_at_ms: null, last_turn_time_source: null,
2484
+ status: live?.status ?? null, turn_source: live?.turn_source ?? null, waiting_for: live?.waiting_for ?? null, native_since_ms: live?.turn_since_ms ?? null };
2485
+ const told = identity && db.prepare(`SELECT r.metadata FROM task_runs r WHERE r.id != ? AND r.id IN (${ACTIVE_RUN_IDS})`).all(runRow.id)
2486
+ .some(r => {
2487
+ const other = json(r.metadata, {})?.supercode;
2488
+ return other?.unknown_notice?.key === key && other.unknown_since === since && other.unknown_notice.since === since;
2489
+ });
2490
+ setRunSession(db, runRow.id, { unknown_notice: { key, since, at: observedAt }, idle_observation: observation });
2491
+ event(db, id, 'session_turn_unknown', observation, runRow.id);
2492
+ const to = managers[0] ?? null;
2493
+ if (to && !told && machineReadable) {
2494
+ const others = (cardsOf.get(address) ?? []).filter(card => card !== id);
2495
+ event(db, id, 'board_message', { to, text: `${id}: its session ${address} cannot be read since ${sinceAt}: ${why}. Read status=${JSON.stringify(observation.status)}, turn=${JSON.stringify(observation.turn)}, source=${JSON.stringify(observation.turn_source)}, waiting_for=${JSON.stringify(observation.waiting_for)}, native_since_ms=${JSON.stringify(observation.native_since_ms)}.${others.length ? ` It is also on ${others.join(', ')}.` : ''} This is an unknown observation; it authorizes no stall, work reminder, reclaim or replacement.` }, null);
2496
+ }
2497
+ (report.held ??= []).push([id, 'turn_unknown', address]);
2498
+ };
2499
+ // A machine read offline is its run's machine wait (machine_offline), which speaks for it. One read but not current
2500
+ // (its record's panes or sessions unread) shows nothing of the session's turn: that turn is unreadable.
2501
+ if (session.machine && !fleet.online.has(session.machine)) {
2502
+ if (fleet.offline?.has(normalMachine(session.machine)) || sessionAlive(fleet, session) === false) return;
2503
+ const error = fleet.errors?.get(normalMachine(session.machine)) ?? fleet.error ?? null;
2504
+ reportUnread(now(), `machine ${session.machine} cannot be read${error ? `: ${String(error).split('\n')[0].slice(0, 300)}` : ''}`);
2505
+ return;
2506
+ }
2507
+ if (sessionAlive(fleet, session) !== true) {
2508
+ if (sessionAlive(fleet, session) === false) return;
2509
+ reportUnread(now(), `its session on ${session.machine ?? 'its machine'} could not be read`);
2510
+ return;
2511
+ }
1877
2512
  // A session the machine's record publishes no activity for has its turn on its machine's discovery row, which
1878
2513
  // readBefore read: a session in no pane, and a live pane whose record names no session in it (a Codex pane on its
1879
2514
  // own Codex home, VO-3259 run 2408: supervise read its turn idle there while this read none, so its run took the
1880
2515
  // unknown-turn notice once and was never reminded or reclaimed). A pane is read so only while its record shows it.
1881
- const published = fleet.sessions.find((x) => x.address === addressOf(session));
1882
- let nativeRow = null;
1883
- if (!published && (session.surface === 'native' || sessionAlive(fleet, session) === true)) try { nativeRow = nativeReading(nativeSessions(session.machine, session.harness ?? 'claude-code', session.session_id), session).row; } catch { /* unread: no turn */ }
1884
- const live = published ?? (nativeRow ? { address: addressOf(session), turn: nativeRow.activity?.turn ?? null, waiting_for: nativeRow.activity?.evidence?.waiting_for ?? null,
1885
- turn_source: nativeRow.activity?.evidence?.source ?? null, turn_since_ms: nativeRow.activity?.evidence?.native_state_since_ms ?? null } : undefined);
2516
+ const live = liveOf(session);
1886
2517
  const observedAt = now();
1887
2518
  // Quiet time is time the board watched. A run this process has not read for a while (the board restarted, was
1888
2519
  // paused, could not read the fleet or the machine) resumes its idle clock where resumedWatch reads it: from the
1889
2520
  // harness's own idle-since where its session reads idle with one and its machine's record was current over it,
1890
2521
  // else from this reading, so a gap the board could not see is not idleness and no reminder is spent on it
1891
2522
  // (volter-org t_9a9c2a9c: reminder 1 in the second its machine came back, the run reclaimed 45 minutes later).
1892
- // The unknown-turn clock starts here.
1893
- const lastWatched = idleWatch.get(runRow.id);
1894
- if (lastWatched == null || observedAt - lastWatched > Math.max(3 * settings.interval, settings.idleQuiet / 2)) {
1895
- const resumed = resumedWatch(session, live, runRow, fleet.linkedSince?.get(session.machine ?? localMachine()), observedAt);
1896
- setRunSession(db, runRow.id, { watch_since: resumed, unknown_since: null });
1897
- session = { ...session, watch_since: resumed, unknown_since: null };
2523
+ const resumed = resumedClock(session, runRow, live, observedAt);
2524
+ if (resumed !== null) {
2525
+ setRunSession(db, runRow.id, { watch_since: resumed });
2526
+ session = { ...session, watch_since: resumed };
1898
2527
  }
1899
2528
  watched = [runRow.id, observedAt];
1900
2529
  // A reviewer's work is its verdict, and its verdict ends its run: nothing the card shows while the review runs is
@@ -1933,15 +2562,12 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1933
2562
  // put to its user is its harness's to say (`needs_input`); what it waits for is carried as it was read.
1934
2563
  const address = addressOf(session);
1935
2564
  const asked = live?.turn === 'needs_input';
1936
- const edges = [...delegations, ...fleet.sessions.filter(row => row.starter_session?.startsWith('sc:')).map(row => ({ starter: row.starter_session, session: row.address }))];
1937
- for (const child of db.prepare('SELECT id,current_run_id FROM tasks WHERE current_run_id IS NOT NULL').all()) {
1938
- const starter = creatorOf(db, child.id)?.replace(/^session:/, '');
1939
- const target = addressOf(currentRun(db, child.id)?.metadata?.supercode);
1940
- if (starter?.startsWith('sc:') && target) edges.push({ starter, session: target });
1941
- }
1942
- const delegated = delegatedWork(address, edges, delegatedReadings);
2565
+ const delegated = delegatedOf(session);
1943
2566
  if (live?.turn === 'idle' || asked || live?.turn === 'working' || delegated.length) {
1944
- if (session.unknown_since != null || session.unknown_notice) setRunSession(db, runRow.id, { unknown_since: null, unknown_notice: null });
2567
+ if (session.unknown_since != null || session.unknown_notice) {
2568
+ setRunSession(db, runRow.id, { unknown_since: null, unknown_notice: null, watch_since: observedAt });
2569
+ session = { ...session, watch_since: observedAt };
2570
+ }
1945
2571
  }
1946
2572
  // a reading that shows the session no longer waiting ends the prompt; a round that did not read it does not
1947
2573
  if (session.held_notice && live && !asked) setRunSession(db, runRow.id, { held_notice: null });
@@ -1982,29 +2608,8 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1982
2608
  retain(delegated.length ? 'delegated_work' : 'working', { delegated_sessions: delegated });
1983
2609
  return;
1984
2610
  }
1985
- // An unread or unknown turn does not prove idle, but cannot remain silent
1986
- // indefinitely. Bound the observed interval independently of transcript
1987
- // writes, and tell the manager once with the native facts we actually read.
1988
2611
  if (live?.turn !== 'idle') {
1989
- if (session.idle_since != null || session.idle_notice) setRunSession(db, runRow.id, { idle_since: null, idle_notice: null });
1990
- const since = Number(session.unknown_since ?? observedAt);
1991
- reading.unknown_since = since;
1992
- reading.quiet_seconds = Math.max(0, observedAt - since);
1993
- if (session.unknown_since !== since) setRunSession(db, runRow.id, { unknown_since: since });
1994
- if (reading.quiet_seconds < settings.idleQuiet) { retain('turn_unknown'); return; }
1995
- if (session.unknown_notice) return;
1996
- const key = [address, live?.turn_since_ms ?? 'unknown'].join('|');
1997
- const told = db.prepare(`SELECT r.metadata FROM task_runs r WHERE r.id != ? AND r.id IN (${ACTIVE_RUN_IDS})`).all(runRow.id)
1998
- .some(r => json(r.metadata, {})?.supercode?.unknown_notice?.key === key);
1999
- setRunSession(db, runRow.id, { unknown_notice: { key, at: observedAt, since } });
2000
- const observation = retain('turn_unknown_reported', { waiting_for: live?.waiting_for ?? null, turn_source: live?.turn_source ?? null, since_ms: live?.turn_since_ms ?? null });
2001
- event(db, id, 'session_turn_unknown', observation, runRow.id);
2002
- const to = managers[0] ?? null;
2003
- if (to && !told) {
2004
- const others = (cardsOf.get(address) ?? []).filter(card => card !== id);
2005
- event(db, id, 'board_message', { to, text: `${id}: its session ${address} has no known native turn after ${reading.quiet_seconds}s of observation (threshold ${settings.idleQuiet}s). Read status=${JSON.stringify(reading.status)}, turn=${JSON.stringify(reading.turn)}, source=${JSON.stringify(live?.turn_source ?? null)}, waiting_for=${JSON.stringify(live?.waiting_for ?? null)}, native_since_ms=${JSON.stringify(live?.turn_since_ms ?? null)}, last_message_at_ms=${JSON.stringify(reading.last_message_at_ms)}.${others.length ? ` It is also on ${others.join(', ')}.` : ''} Its liveness needs a decision; no idle state or permission answer was inferred.` }, null);
2006
- }
2007
- (report.held ??= []).push([id, 'turn_unknown', address]);
2612
+ reportUnread(observedAt, 'its session has no known native turn', live);
2008
2613
  return;
2009
2614
  }
2010
2615
  // A native idle receipt also works without a transcript timestamp.
@@ -2019,15 +2624,15 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
2019
2624
  let stalled = false;
2020
2625
  if (handles('session_stalled')) {
2021
2626
  const limits = limitsOf(db, board, entry, task, session);
2022
- const first = Number(limits.idle_notice), repeat = Number(limits.idle_notice_repeat);
2023
2627
  const nativeIdle = live?.turn_since_ms ?? null;
2024
2628
  const prior = session.idle_notice?.native_since_ms === nativeIdle ? session.idle_notice : null;
2025
- const due = first > 0 && interval.quiet >= first && (!prior || (repeat > 0 && observedAt - prior.at >= repeat));
2629
+ const due = noticeDue(limits, interval.quiet, prior, observedAt);
2026
2630
  if (due) try {
2027
2631
  const turnAt = Number(live?.turn_since_ms) || (lastAt > 0 ? Number(lastAtMs) : null);
2028
2632
  const payload = { ...reading, session: address, run_id: runRow.id, last_turn_at_ms: turnAt,
2029
2633
  last_turn_at: turnAt ? new Date(turnAt).toISOString() : null,
2030
- last_turn_time_source: Number(live?.turn_since_ms) > 0 ? 'native_idle_since' : turnAt ? 'last_session_update' : null, repeat: Boolean(prior) };
2634
+ last_turn_time_source: Number(live?.turn_since_ms) > 0 ? 'native_idle_since' : turnAt ? 'last_session_update' : null, repeat: Boolean(prior),
2635
+ undelivered: mailOf(address) };
2031
2636
  const result = fire(db, board, entry, id, 'session_stalled', { roles: ['platform'], payload });
2032
2637
  staged.push(...result.effects);
2033
2638
  stalled = true;
@@ -2053,7 +2658,12 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
2053
2658
  const said = doors(entry.workflow, status, session.role ?? runSpec(entry, status)?.role ?? 'implementer', id, where, session.surface !== 'headless' ? 'pane' : 'headless');
2054
2659
  const brief = pendingBrief(board, task, runRow.id, where);
2055
2660
  const r = fire(db, board, entry, id, 'session_idle', { roles: ['platform'], payload: { ...reading, turns: reading.turns_before, worked }, extra: { where, doors: said, pending_brief: brief } });
2056
- // a reminder wakes the session with what its run is for: send_to_actor saves it with the run's pending brief
2661
+ // a reminder wakes the session with what its run is for: send_to_actor saves it with the run's pending brief, and
2662
+ // one that carries the brief is filed as the run's instruction, as the brief itself is, never as a notice
2663
+ if (brief) for (const effect of r.effects) if (effect.kind === 'send_to_actor' && effect.durableId) {
2664
+ effect.as = 'instruction';
2665
+ db.prepare("UPDATE supercode_effects SET effect = json_set(effect, '$.as', 'instruction') WHERE id = ?").run(effect.durableId);
2666
+ }
2057
2667
  staged.push(...r.effects);
2058
2668
  const after = currentRun(db, id);
2059
2669
  const stored = json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(runRow.id)?.metadata, {})?.supercode;
@@ -2080,9 +2690,10 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
2080
2690
  }
2081
2691
  });
2082
2692
  if (watched) idleWatch.set(...watched);
2693
+ if (unread) turnUnreadWatch.set(...unread);
2083
2694
  effects.push(...staged);
2084
2695
  }
2085
- for (const [runId, at] of idleWatch) if (now() - at > 86_400) idleWatch.delete(runId);
2696
+ for (const watch of [idleWatch, turnUnreadWatch]) for (const [runId, at] of watch) if (now() - at > 86_400) watch.delete(runId);
2086
2697
  }
2087
2698
 
2088
2699
  function effectMessageKey(effect) {
@@ -2110,11 +2721,13 @@ export async function sessionNotice(board, e) {
2110
2721
  if (!address) throw new Error('lifecycle notice awaits the owned launch session identity');
2111
2722
  const card = view(board, (db) => getTask(db, e.id));
2112
2723
  const subject = numbered(boardKey(board.root, board.slug), card?.number, `${String(e.verb ?? 'run_changed').replaceAll('_', ' ')} · run ${e.runId} · event ${e.sourceEvent ?? 'none'} · ${card?.title || e.id}`, noticeName(card));
2113
- const filed = await fileMail(board, address, e.text, effectMessageKey(e), { subject });
2724
+ // a role's brief (queueBrief: an adopted session's, or one told again when its prompt changed) is the run's instruction
2725
+ const as = ['brief', 'brief_changed'].includes(e.verb) ? 'instruction' : 'notice';
2726
+ const filed = await fileMail(board, address, e.text, effectMessageKey(e), { as, subject });
2114
2727
  tx(board, (db) => {
2115
2728
  const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
2116
2729
  if (session?.lifecycle_notice_pending === e.durableId) setRunSession(db, e.runId, { lifecycle_notice_pending: null });
2117
- if (filed.detached) event(db, e.id, 'session_notice_not_required', { address, notice: e.durableId, reason: 'session detached to a person' }, e.runId);
2730
+ if (filed.detached) event(db, e.id, 'session_notice_not_required', { address, verb: e.verb, notice: e.durableId, reason: 'session detached to a person' }, e.runId);
2118
2731
  if (filed.new) event(db, e.id, 'session_notice_filed', { address, verb: e.verb, source_event: e.sourceEvent, notice: e.durableId, message_id: filed.message_id }, e.runId);
2119
2732
  });
2120
2733
  return true;
@@ -2132,7 +2745,7 @@ export async function sessionEffect(board, e) {
2132
2745
  if (!address) throw new Error('actor session identity unavailable');
2133
2746
  // filed in the actor's mailbox, which delivers it
2134
2747
  const card = view(board, (db) => getTask(db, e.id));
2135
- const filed = await fileMail(board, address, e.text, effectMessageKey(e), { subject: numbered(boardKey(board.root, board.slug), card?.number, `actor notice · run ${e.runId ?? 'none'} · ${card?.title || e.id}`, noticeName(card)) });
2748
+ const filed = await fileMail(board, address, e.text, effectMessageKey(e), { as: e.as === 'instruction' ? 'instruction' : 'notice', subject: numbered(boardKey(board.root, board.slug), card?.number, `actor notice · run ${e.runId ?? 'none'} · ${card?.title || e.id}`, noticeName(card)) });
2136
2749
  if (filed.new) tx(board, db => event(db, e.id, 'actor_notice_filed', { notice: e.durableId, address, message_id: filed.message_id }, e.runId));
2137
2750
  return true;
2138
2751
  }
@@ -2151,7 +2764,7 @@ export async function sessionEffect(board, e) {
2151
2764
  */
2152
2765
  const MERGE_BUDGET_MS = 300_000;
2153
2766
  export async function mergeReviewed(board, e) {
2154
- const { candidatePullRequests, mergeReviewedPullRequest } = await import('./ci.mjs');
2767
+ const { candidatePullRequests, mergeReviewedPullRequest, verifiedReleaseReadback } = await import('./ci.mjs');
2155
2768
  const pass = view(board, (db) => db.prepare("SELECT id, payload FROM task_events WHERE task_id = ? AND kind = 'review_pass' ORDER BY id DESC LIMIT 1").get(e.id));
2156
2769
  const recorded = json(pass?.payload, {})?.ci ?? null;
2157
2770
  const ci = typeof recorded === 'string' ? json(recorded, null) : recorded;
@@ -2161,6 +2774,14 @@ export async function mergeReviewed(board, e) {
2161
2774
  // effect's claim; what is left is the next attempt's
2162
2775
  const began = Date.now();
2163
2776
  for (const p of prs) {
2777
+ if (prDisposition(board,e.id,p.pr.url,p.commit).disposition === 'release-only') {
2778
+ // This head must never reach the merge door. Settlement independently rechecks its
2779
+ // current PR head and the retained disposition/verdict/readback binding.
2780
+ const readback = await verifiedReleaseReadback(board,e.id,p.pr.url,p.commit);
2781
+ if (!readback) return false;
2782
+ merges.push({ pr: p.pr.url, reviewed: p.commit, release_only: true, target: readback.target, artifact: readback.artifact });
2783
+ continue;
2784
+ }
2164
2785
  if (Date.now() - began > MERGE_BUDGET_MS) throw new Error(`the merge attempt spent its ${MERGE_BUDGET_MS / 1000} s; the rest is merged next attempt`);
2165
2786
  let answer;
2166
2787
  try { answer = await mergeReviewedPullRequest(p.pr.url, p.commit); } catch (error) {
@@ -2183,10 +2804,12 @@ export async function mergeReviewed(board, e) {
2183
2804
  // A fault other than the card's refusal keeps the effect for the next round, and the round goes on past it.
2184
2805
  try { tx(board, (db) => {
2185
2806
  event(db, e.id, 'reviewed_merged', { merges });
2807
+ // a closed card's kept worktree, or a closed subtask's, is read again now that its merge has landed
2808
+ for (const row of db.prepare("SELECT t.id FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE (t.id = ? OR c.subtask_of = ?) AND t.status IN ('done', 'cancelled', 'archived') AND t.workspace_kind = 'worktree'").all(e.id, e.id)) queueCleanup(db, row.id);
2186
2809
  // A card that moved after its pass cannot take the event: that is said to the manager once, never thrown, so the
2187
2810
  // kept effect does not stop every round after it (as merge_refused, above).
2188
2811
  let r;
2189
- try { r = fire(db, board, entry, e.id, 'reviewed_merged', { roles: ['platform'], payload: { merges, summary: merges.length ? `Merged at the reviewed heads: ${merges.map((m) => `${m.pr} (${m.reviewed.slice(0, 8)} → ${String(m.merge).slice(0, 8)})`).join(', ')}` : 'The pass named no PR to merge.' } }); }
2812
+ try { r = fire(db, board, entry, e.id, 'reviewed_merged', { roles: ['platform'], payload: { merges, summary: merges.length ? `Settled at the reviewed heads: ${merges.map((m) => m.release_only ? `${m.pr} (released ${m.artifact} on ${m.target})` : `${m.pr} (${m.reviewed.slice(0, 8)} → ${String(m.merge).slice(0, 8)})`).join(', ')}` : 'The pass named no PR to merge.' } }); }
2190
2813
  catch (error) {
2191
2814
  // only the card's refusal is said and consumed; anything else (a write contention, a fault) is thrown and retried
2192
2815
  if (!error.noTransition && !error.refusal && !/does not apply|there is for/.test(error.message)) throw error;
@@ -2262,13 +2885,10 @@ function noteGuard(db, id, reason) {
2262
2885
  if (guardNoted(db, id, reason)) return;
2263
2886
  event(db, id, 'respawn_guarded', { reason });
2264
2887
  }
2265
- /** Whether `reason` is already noted: once per reason per entry into the status (a card back in ready, a new `since`, is
2266
- * noted again, so the workflow's after-timers that read the reason, card.queue_reason, see it). Read before a write
2267
- * is opened too: a card that waits for the same reason round after round costs no transaction. */
2888
+ /** Whether `reason` is already noted since this stage entry and latest run. Read before a write is opened too:
2889
+ * a card that waits for the same reason round after round costs no transaction. */
2268
2890
  function guardNoted(db, id, reason) {
2269
- const since = Number(db.prepare('SELECT since FROM supercode_cards WHERE task_id = ?').get(id)?.since ?? 0);
2270
- const last = db.prepare("SELECT payload FROM task_events WHERE task_id = ? AND kind IN ('respawn_guarded', 'claimed') AND created_at >= ? ORDER BY id DESC LIMIT 1").get(id, since);
2271
- return json(last?.payload, {})?.reason === reason;
2891
+ return dispatchWait(db, id)?.reason === reason;
2272
2892
  }
2273
2893
 
2274
2894
  /** Hermes's emergency stop: the `ESTOP` sentinel in the home or in `~/.hermes` stops new starts (running ones go on). */
@@ -2286,14 +2906,15 @@ const getTaskNow = (board, id) => view(board, (db) => getTask(db, id));
2286
2906
 
2287
2907
  /** A transport error is not a negative launch receipt. The key remains owned by its run. */
2288
2908
  /**
2289
- * Where the round's fleet reading puts a launch's session: 'present' when its machine lists the launch's pane or a
2290
- * session with its id, 'absent' when its machine answered and lists neither, null when its machine was not read.
2909
+ * A bound launch's scoped conversation presence, or its pane presence before binding. Unread source/activity is null.
2291
2910
  */
2292
2911
  function launchReading(fleet, session) {
2293
2912
  const machine = normalMachine(session?.machine ?? localMachine());
2294
2913
  if (!fleet?.ok || !fleet.online?.has(machine) || !fleet.panes?.has(machine)) return null;
2295
- const sessionSeen = session.session_id && fleet.sessions?.some((f) => f.machine === machine && f.session_id === session.session_id);
2296
- return (session.pane && fleet.panes.get(machine).has(session.pane)) || sessionSeen ? 'present' : 'absent';
2914
+ if (!session.session_id) return session.pane && fleet.panes.get(machine).has(session.pane) ? 'present' : 'absent';
2915
+ if (!nativeHome(session)) return null;
2916
+ const alive = sessionAlive(fleet, session);
2917
+ return alive === true ? 'present' : alive === false ? 'absent' : null;
2297
2918
  }
2298
2919
 
2299
2920
  /** How a keyed launch ended, as its machine recorded it; read only where the machine has the door (supervise). */
@@ -2307,21 +2928,67 @@ async function launchReceipt(board, session, timeout = 30_000) {
2307
2928
  return receipt;
2308
2929
  }
2309
2930
 
2310
- function bindLaunch(board, row, session, pane, sid) {
2931
+ /** Retire only a fresh attempt the machine positively records as never issued; its intended id is no conversation. */
2932
+ async function retireUnissuedSlotLaunch(board, taskId, runId, slot) {
2933
+ for (;;) {
2934
+ const prior = view(board, db => establishedSlot(db, taskId, slot, runId));
2935
+ const session = prior?.session;
2936
+ if (!session?.launch_key || session.mode !== 'fresh' || session.surface !== 'pane' || session.address || session.seen_at) return;
2937
+ const result = await doorOn(session.machine, 'harness.v1.launches.cancel', { key: session.launch_key },
2938
+ { timeout: 5_000, as: boardAddress(board.label) });
2939
+ const receipt = result.code === 0 ? lastJson(result.stdout) : null;
2940
+ if (receipt?.key !== session.launch_key || receipt.by !== boardAddress(board.label) || receipt.status !== 'cancelled'
2941
+ || receipt.outcome !== 'not_issued' || receipt.pane || receipt.native_session || receipt.live !== false) return;
2942
+ const retired = tx(board, db => {
2943
+ if (!runCurrent(db, taskId, runId)) throw superseded(taskId, runId);
2944
+ const current = establishedSlot(db, taskId, slot, runId);
2945
+ if (current?.run !== prior.run || current.session.launch_key !== session.launch_key || current.session.address || current.session.seen_at) return false;
2946
+ setRunSession(db, prior.run, { launch_not_issued: true, launch_pending: !current.session.close_confirmed_at });
2947
+ requestSessionClose(db, taskId, { runId: prior.run });
2948
+ event(db, taskId, 'session_launch_not_issued', { key: session.launch_key, machine: session.machine,
2949
+ intended_session: session.session_id ?? null, receipt }, prior.run);
2950
+ return true;
2951
+ });
2952
+ if (!retired) return;
2953
+ await closeRequested(board, { undelivered: [] }, { taskId });
2954
+ if (view(board, db => establishedSlot(db, taskId, slot, runId))?.run === prior.run) return;
2955
+ }
2956
+ }
2957
+
2958
+ function bindLaunch(board, row, session, receipt, { refile = null } = {}) {
2959
+ if (receipt?.key !== session.launch_key || receipt.by !== boardAddress(board.label) || receipt.pane !== session.pane
2960
+ || (refile ? receipt.inputBounced !== true : receipt.inputDelivered !== true || receipt.inputReceipt !== 'native-user-message')
2961
+ || typeof receipt.native_session !== 'string' || !receipt.native_session) return false;
2962
+ const pane = receipt.pane, sid = receipt.native_session;
2963
+ const home = receipt.native_home ?? nativeHome(session);
2964
+ if (nativeHome(session) && home && JSON.stringify(nativeHome(session).homes) !== JSON.stringify(home.homes))
2965
+ throw unavailable(`launch ${session.launch_key} returned a different native home; its identity was not bound`);
2311
2966
  return tx(board, (db) => {
2312
2967
  const current = db.prepare('SELECT ended_at, metadata FROM task_runs WHERE id=?').get(row.id);
2313
2968
  const saved = json(current?.metadata, {})?.supercode;
2314
2969
  // a launch another round already bound, or whose session was closed, is not bound again
2315
2970
  if (!saved || saved.launch_key !== session.launch_key || !saved.launch_pending || saved.close_confirmed_at || (saved.session_id && sid && saved.session_id !== sid)) return false;
2316
- const address = sid ? `sc:${session.machine}:${session.harness}:${sid}` : null;
2317
- setRunSession(db, row.id, { ...saved, pane, session_id: sid ?? saved.session_id,
2318
- ...(address ? { address, launch_pending: false, launch_error: null, seen_at: now() } : {}) });
2319
- if (current.ended_at) requestSessionClose(db, row.task_id, { runId: row.id });
2320
- else if (address && getTask(db, row.task_id)?.current_run_id === row.id) {
2971
+ const task = getTask(db, row.task_id);
2972
+ let refused = null;
2973
+ if (!current.ended_at && task?.current_run_id === row.id && !task.backlog) {
2974
+ try { assertHeadlessBinding(board, row.task_id, row.id, saved.harness, sid); }
2975
+ catch (error) { refused = error; }
2976
+ }
2977
+ if (nativeHome(saved) && home && JSON.stringify(nativeHome(saved).homes) !== JSON.stringify(home.homes))
2978
+ throw unavailable(`launch ${session.launch_key} changed its retained native home before binding`);
2979
+ const address = `sc:${session.machine}:${session.harness}:${sid}`;
2980
+ setRunSession(db, row.id, { ...saved, pane, session_id: sid, native_home: home,
2981
+ address, launch_pending: false, launch_error: null, seen_at: now() });
2982
+ if (current.ended_at || task?.backlog || refused) requestSessionClose(db, row.task_id, { runId: row.id });
2983
+ if (refused) {
2984
+ setRunSession(db, row.id, { continuation_required: true });
2985
+ surfaceIncident(db, board, workflowFor(board.root), row.task_id, 'spawn_failed', { error: refused.message, origin: originOf(refused) }, row.id);
2986
+ } else if (!task?.backlog && getTask(db, row.task_id)?.current_run_id === row.id) {
2321
2987
  replaceWorkerSubscriptions(db, row.task_id, address);
2322
2988
  for (const p of db.prepare('SELECT parent_id FROM task_links WHERE child_id=?').all(row.task_id)) subscribeIn(db, p.parent_id, address, 'dependency');
2323
2989
  }
2324
- if (sid && saved.launch_pending) event(db, row.task_id, 'session_launch_reconciled', { key: session.launch_key, pane, session_id: sid, role: saved.role }, row.id);
2990
+ event(db, row.task_id, 'session_launch_reconciled', { key: session.launch_key, pane, session_id: sid, role: saved.role }, row.id);
2991
+ if (refile && !current.ended_at && !refused && getTask(db, row.task_id)?.current_run_id === row.id) refile(db, address);
2325
2992
  return true;
2326
2993
  });
2327
2994
  }
@@ -2338,12 +3005,11 @@ export async function reconcileCallerLaunch(board, sid) {
2338
3005
  return s?.machine === machine && s.harness === harness && (!s.session_id || s.session_id === sid);
2339
3006
  });
2340
3007
  if (!candidates.length) return;
2341
- const pane = process.env.SUPERCODE_TEAMS_PANE || (await observeFleet()).sessions?.find((s) => s.address === `sc:${machine}:${harness}:${sid}`)?.pane;
2342
- if (!pane) return;
2343
- // the caller's own pane (its environment, or this machine's reading) binds the run whose launch recorded that pane
2344
3008
  for (const row of candidates) {
2345
3009
  const s = json(row.metadata, {})?.supercode;
2346
- if ((s.pane === pane || s.session_id === sid) && bindLaunch(board, row, s, pane, sid)) return;
3010
+ await readNativeHome(s);
3011
+ const receipt = await launchReceipt(board, s, 5000);
3012
+ if (receipt?.native_session === sid && bindLaunch(board, row, s, receipt)) return;
2347
3013
  }
2348
3014
  }
2349
3015
 
@@ -2355,11 +3021,13 @@ const SESSION_UNREADABLE=-32050;
2355
3021
  * file's size and time here, what discovery says of it on another machine), and asked again once either differs: the
2356
3022
  * same bytes get the same answer only from the same reader. Every other failure (no answer, a timeout, a fault in the
2357
3023
  * door, a daemon that does not say which it is) says nothing of the record, and the read is made again. */
2358
- async function readRunUsage(root,org,row,endedAt){
3024
+ async function readRunUsage(root,org,row,endedAt,session){
2359
3025
  const [harness,...rest]=row.session_ref.split(':'),machine=row.machine&&normalMachine(row.machine)!==localMachine()?row.machine:null;
3026
+ const home = await readNativeHome(session);
3027
+ if (!home) return;
2360
3028
  // the usage door loads the session by its whole locator, which discovery names
2361
- const sessionId=rest.join(':'),found=await runtimeDoor(machine,'harness.v1.sessions.discover',{query:sessionId,harnesses:[harness],limit:200},{timeout:30_000});
2362
- const descriptor=found.code===0?json(found.stdout.trim(),{})?.sessions?.find(item=>item.locator?.harness===harness&&item.locator?.session_id===sessionId):null,locator=descriptor?.locator??null;
3029
+ const sessionId=rest.join(':'),found=await runtimeDoor(machine,'harness.v1.sessions.discover',{query:sessionId,harnesses:[harness],homes:home.homes,limit:200,include_activity:false},{timeout:30_000});
3030
+ const descriptor=found.code===0?json(found.stdout.trim(),{})?.sessions?.find(item=>item.locator?.harness===harness&&item.locator?.session_id===sessionId&&inNativeHome(item,home.homes)):null,locator=descriptor?.locator??null;
2363
3031
  let stamp=null;
2364
3032
  if(locator){
2365
3033
  const described=await runtimeDoor(machine,'harness.v1.machine.describe',{},{timeout:15_000}),reader=described.code===0?json(described.stdout.trim(),{}):null;
@@ -2378,19 +3046,21 @@ async function readRunUsage(root,org,row,endedAt){
2378
3046
  * handed to this machine's daemon, and usage read from each machine's own doors. No Teams call: the daemon declares and
2379
3047
  * ends agents and binds their sessions (D137). It runs in the delivery pass (`deliver`), never in a dispatch round. */
2380
3048
  async function synchronizeAgents(board, report, fleet = null) {
2381
- // A delayed launch receipt/catalogue event binds the same declared agent, never another identity.
2382
- // A resumed session is held by its newest run; an earlier run of the same session no longer owns it.
2383
- const agentRuns=[...new Map((await readRunPages(board.db, 'id,task_id,metadata,ended_at', { where: "json_extract(metadata,'$.supercode.agent_id') IS NOT NULL" }))
2384
- .map(row=>{const session=json(row.metadata,{})?.supercode;return [session?.session_id?addressOf(session):`run:${row.id}`,row];})).values()];
2385
- const live=new Map();
2386
- for(const scanned of agentRuns){
3049
+ const work = await currentAgentWork(board);
3050
+ const live = new Map();
3051
+ // Credential decisions depend on fleet/custody too, but only current retained credentials
3052
+ // enter this pass; settled ended history does not.
3053
+ for (const ref of work.credentials) {
3054
+ const row = work.rows.get(ref), session = row?.session;
3055
+ if (row && credentialsWanted(board,row,session,fleet)) live.set(credentialsKey(session.machine,session.agent_id),{task:row.task_id,run:row.id});
3056
+ }
3057
+ for (const key of [...work.pending]) {
2387
3058
  await yieldToOwner();
2388
- const row=view(board,db=>db.prepare('SELECT id,task_id,metadata,ended_at FROM task_runs WHERE id=?').get(scanned.id));
2389
- if(!row)continue;
2390
- const session=json(row.metadata,{})?.supercode;
2391
- if(credentialsWanted(board,row,session,fleet))live.set(credentialsKey(session.machine,session.agent_id),{task:row.task_id,run:row.id});
2392
- if(!session?.session_id||!session.agent_org||view(board,db=>detachedSession(db,session)))continue;
2393
- const ref=`${session.harness}:${session.session_id}`;
3059
+ // A yielded projection is advisory. Resolve its current latest identity again before
3060
+ // recording ownership, just as launch/close effects reread their target.
3061
+ const row = latestAgentRow(board,key), session = row?.session;
3062
+ if (!row || !session?.session_id || !session.agent_org || view(board,db => detachedSession(db,session))) { work.pending.delete(key); continue; }
3063
+ const ref = `${session.harness}:${session.session_id}`;
2394
3064
  // An ended run whose session the agent store already holds for another agent lost that claim when it ran: no round
2395
3065
  // can change an ended run, so the disagreement is history. It is said once on its card (agent_ownership_conflict)
2396
3066
  // and the run is not recorded again. A live run is never history: it is recorded below every round, and a conflict
@@ -2400,31 +3070,40 @@ async function synchronizeAgents(board, report, fleet = null) {
2400
3070
  if(owner&&owner.org===session.agent_org&&owner.agent_id!==session.agent_id){
2401
3071
  const said=db=>db.prepare("SELECT 1 FROM task_events WHERE task_id=? AND run_id=? AND kind='agent_ownership_conflict' LIMIT 1").get(row.task_id,row.id);
2402
3072
  if(!view(board,said))tx(board,db=>{if(!said(db))event(db,row.task_id,'agent_ownership_conflict',{session:ref,run_agent:session.agent_id,owner:owner.agent_id,owner_run:owner.run_id},row.id);});
2403
- continue;
3073
+ work.pending.delete(key); continue;
2404
3074
  }
2405
3075
  }
2406
3076
  // One run whose session another agent already owns is that card's problem, said on it; the round goes on for the rest.
2407
3077
  try{recordAgentRun(board.root,session.agent_org,session.agent_id,{session:ref,machine:session.machine,run:row.id});}
2408
3078
  catch(error){report.queued.push([row.task_id,`run ${row.id}: ${error.message}`]);continue;}
3079
+ work.pending.delete(key);
2409
3080
  }
2410
3081
  await keepAgentCredentialsCurrent(board,live,report);
2411
- // G3: an ended run's session usage, read once from its harness's own records on its machine
2412
- for(const org of new Set(agentRuns.map(row=>json(row.metadata,{})?.supercode?.agent_org).filter(Boolean))){
2413
- let read=0;
2414
- for(const row of runsAwaitingUsage(board.root,org)){
2415
- const run=agentRuns.find(item=>String(item.id)===String(row.run_id));
2416
- if(!run?.ended_at||read>=5||view(board,db=>detachedSession(db,json(run.metadata,{})?.supercode)))continue;
2417
- await readRunUsage(board.root,org,row,run.ended_at);read++;
3082
+ // Outstanding usage remains due work. A refused reader is retried at a bounded cadence,
3083
+ // independently of new events, instead of rediscovering settled history each idle pass.
3084
+ const runsById = new Map([...work.rows.values()].map(row => [String(row.id),row]));
3085
+ for (const org of pendingUsageOrgs(board.root)) {
3086
+ let read = 0;
3087
+ for (const row of runsAwaitingUsage(board.root,org)) {
3088
+ const run = runsById.get(String(row.run_id)), key = `${org}\0${row.session_ref}`;
3089
+ if (!run?.ended_at || read >= 5 || (work.usageDue.get(key) ?? 0) > Date.now()
3090
+ || view(board,db => detachedSession(db,run.session))) continue;
3091
+ // Failed calls remain pending in the authoritative agent store, including across restart.
3092
+ work.usageDue.set(key,Date.now()+60_000);
3093
+ await readRunUsage(board.root,org,row,run.ended_at,run.session); read++;
2418
3094
  }
2419
3095
  }
2420
- for(const assignment of cardAgentAssignments(board.root)){
3096
+ for (const target of [...work.assignments]) {
2421
3097
  await yieldToOwner();
2422
- const task=getTaskNow(board,assignment.target);
2423
- if(task&&(assignment.state==='ended'||task.archived||['done','cancelled'].includes(task.status)))finishCardAgentAssignment(board,assignment,`card ${assignment.target} ${task.archived?'archived':task.status}`);
2424
- else if(task){
2425
- if(task.readable_id)renameCardAgent(board.root,assignment,task.readable_id);
2426
- recordCardAgentAssignment(board,assignment);
3098
+ const task = getTaskNow(board,target);
3099
+ for (const assignment of cardAgentAssignments(board.root,target)) {
3100
+ if (task && (assignment.state==='ended' || task.archived || ['done','cancelled'].includes(task.status))) finishCardAgentAssignment(board,assignment,`card ${target} ${task.archived?'archived':task.status}`);
3101
+ else if (task) {
3102
+ if (task.readable_id) renameCardAgent(board.root,assignment,task.readable_id);
3103
+ recordCardAgentAssignment(board,assignment);
3104
+ }
2427
3105
  }
3106
+ work.assignments.delete(target);
2428
3107
  }
2429
3108
  // What a creating or closing verb could not hand on (no daemon running then) is handed on here.
2430
3109
  try{for(const pending of await handOffAgents(board.root))report.queued.push([pending.agent,pending.error]);}
@@ -2434,22 +3113,33 @@ async function synchronizeAgents(board, report, fleet = null) {
2434
3113
  async function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes) {
2435
3114
  const deferred=view(board,db=>db.prepare("SELECT t.id,r.id run_id,COALESCE(json_extract(r.metadata,'$.supercode.launched_at'),r.started_at) launched_at FROM tasks t JOIN task_runs r ON r.id=t.current_run_id WHERE r.ended_at IS NULL AND json_extract(r.metadata,'$.supercode.launch_deferred')=1").all());
2436
3115
  // A deferred launch was never issued (a reading or door was not yet available): it is the start its manager, creator
2437
- // or workflow asked for, carried out within the launch window; supervise surfaces it once when the window ends. The
2438
- // window counts only time the fleet could be read: a launch waiting on an unreadable fleet was never tried, so its
2439
- // clock moves with the wait instead of running out (every resumed card failed so in the 22:17Z event-stream outage).
3116
+ // or workflow asked for, carried out within the launch window that began when the run started; supervise surfaces it
3117
+ // once when the window ends. A round that cannot carry it out says why, reads nothing for it and leaves that clock
3118
+ // where it is: a wait never restarts the window.
2440
3119
  for(const {id,run_id,launched_at} of deferred) {
3120
+ await yieldToOwner();
3121
+ if (stopping) return;
2441
3122
  if (now() - launched_at >= settings.launchWindow) continue;
2442
3123
  // the run's launch is claimed before anything is done for it: another round's claim, or its launch, skips it here
2443
- const stillDeferred=db=>{const run=db.prepare('SELECT ended_at,metadata FROM task_runs WHERE id=?').get(run_id);return Boolean(run&&!run.ended_at&&getTask(db,id)?.current_run_id===run_id&&json(run.metadata,{})?.supercode?.launch_deferred);};
2444
- if (!fleet.ok) { tx(board,db=>{if(stillDeferred(db))setRunSession(db,run_id,{launched_at:now()});}); report.queued.push([id,`launch waits for the fleet: ${fleet.error}`]); continue; }
3124
+ const stillDeferred = db => {
3125
+ const run = currentRun(db, id), session = run?.metadata?.supercode;
3126
+ return run?.id === run_id && session?.launch_deferred && !getTask(db, id)?.backlog ? session : null;
3127
+ };
3128
+ const wait = reason => {
3129
+ const needsWait = db => Boolean(stillDeferred(db) && stillDeferred(db).launch_deferred_error !== reason);
3130
+ if (view(board, needsWait)) tx(board, db => { if (needsWait(db)) setRunSession(db, run_id, { launch_deferred_error: reason }); });
3131
+ report.queued.push([id, reason]);
3132
+ };
2445
3133
  try {
2446
3134
  const task=getTaskNow(board,id),status=view(board,db=>statusOf(db,task)),spec=runSpec(entry,status,task);
2447
- const profile=spec&&view(board,db=>evaluate(entry.workflow.roles?.[spec.role]??'card.assignee',scopeOf(db,board,task,{params:entry.params})));
2448
- const lane=lanes?.[profile];if(!lane){report.queued.push([id,'launch waits for its configured lane']);continue;}
2449
- // a launch bound to a machine whose record is not current was not tried either: its clock moves with the wait too
3135
+ if (task.backlog) { report.queued.push([id, 'backlog: its deferred launch is not admitted']); continue; }
3136
+ assertRunCurrent(board,id,run_id);
3137
+ const profile=spec&&view(board,db=>evaluate(entry.workflow.roles?.[spec.role]??'card.assignee',scopeOf(db,board,task,{params:entry.params,lazy:true})));
3138
+ const lane=lanes?.[profile];if(!lane){wait('launch waits for its configured lane');continue;}
3139
+ if (lane.surface === 'pane' && !fleet.ok) { wait(`launch waits for the fleet: ${fleet.error}`); continue; }
2450
3140
  const host=lane.surface==='pane'&&task.machine?executionHost(lane,task.machine,localMachine(),normalMachine):null;
2451
- if(host&&fleet.unknown?.has(host)){tx(board,db=>{if(stillDeferred(db))setRunSession(db,run_id,{launched_at:now()});});report.queued.push([id,`launch waits for machine ${host}'s fleet record: ${fleet.errors?.get(host)??'no current record'}`]);continue;}
2452
- const why=launcherCheck(board.db,task,lane,fleet);if(why){report.queued.push([id,why]);continue;}
3141
+ if(host&&fleet.unknown?.has(host)){wait(`launch waits for machine ${host}'s fleet record: ${fleet.errors?.get(host)??'no current record'}`);continue;}
3142
+ const why=launcherCheck(board.db,task,lane,fleet);if(why){wait(why);continue;}
2453
3143
  const claimed=tx(board,db=>{if(!stillDeferred(db))return false;setRunSession(db,run_id,{launch_deferred:false});return true;});
2454
3144
  if(!claimed){report.queued.push([id,'its launch was taken by another round']);continue;}
2455
3145
  const session=await launchRun(board,entry,id,lane,fleet,settings);
@@ -2467,27 +3157,15 @@ async function reconcileLaunches(board, fleet, report, entry, effects, settings,
2467
3157
  const rows = view(board, (db) => db.prepare(`SELECT id, task_id, started_at, ended_at, metadata FROM task_runs
2468
3158
  WHERE json_extract(metadata, '$.supercode.launch_pending') = 1
2469
3159
  AND json_extract(metadata, '$.supercode.close_confirmed_at') IS NULL`).all());
2470
- // A keyed launch is bound from the fleet reading, or its machine's exact native first-input receipt. A launch with
3160
+ // A keyed launch is bound only from its machine's exact native first-input receipt. A launch with
2471
3161
  // no pane waits for its launch window (supervise surfaces it then); a pane read absent on its online machine after
2472
3162
  // the grace ended before its session was bound.
2473
3163
  for (const row of rows) {
2474
3164
  const s = json(row.metadata, {})?.supercode;
2475
3165
  try {
2476
- // found by its pane (chosen before open) or by its session id (named before open, for Claude Code)
2477
- const found = fleet.sessions?.find((f) => f.machine === normalMachine(s.machine) && String(f.address).startsWith(`sc:${s.machine}:${s.harness}:`)
2478
- && ((s.pane && f.pane === s.pane) || (s.session_id && f.session_id === s.session_id)));
2479
- const sid = s.session_id ?? found?.address.split(':')[3];
2480
- if (found && sid) { bindLaunch(board, row, s, found.pane ?? s.pane, sid); continue; }
2481
- // An app-server-owned Codex thread can confirm its first input before process discovery knows that thread.
2482
- // Bind only this board's live issuance, its exact pane and the native user-message receipt, never a title match.
2483
- if (!row.ended_at && s.launch_key && launchReading(fleet, s) === 'present') {
2484
- const receipt = await launchReceipt(board, s, 5000);
2485
- if (receipt?.by === boardAddress(board.label) && receipt.pane === s.pane && receipt.live === true
2486
- && receipt.inputDelivered === true && receipt.inputReceipt === 'native-user-message'
2487
- && typeof receipt.native_session === 'string' && receipt.native_session) {
2488
- bindLaunch(board, row, s, s.pane, receipt.native_session); continue;
2489
- }
2490
- }
3166
+ await readNativeHome(s);
3167
+ const receipt = await launchReceipt(board, s, 5000);
3168
+ if (bindLaunch(board, row, s, receipt)) continue;
2491
3169
  // An ended run's launch that bound no session within its window awaits nothing: the run is over, and its pane's
2492
3170
  // close (closeRequested) needs no session. It was reported as awaiting its session every round, for good, once
2493
3171
  // its machine refused that close (t_7ec85b6b run 1203, 2026-10-09).
@@ -2504,7 +3182,7 @@ async function reconcileLaunches(board, fleet, report, entry, effects, settings,
2504
3182
  // another round may have bound this launch since the reading: only a launch still pending fails here
2505
3183
  const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(row.id)?.metadata, {})?.supercode;
2506
3184
  if (!current?.launch_pending || current.launch_key !== s.launch_key || current.address) return null;
2507
- // a fresh launch that never showed in a reading has no session to resume: its next run starts fresh
3185
+ // An unbound fresh launch is unusable; its next run requires a deliberate replacement disposition.
2508
3186
  setRunSession(db, row.id, { launch_pending: false, ...(s.mode === 'fresh' ? { forgotten: true } : {}) });
2509
3187
  const payload = { error: `launch ${s.launch_key} ended before session binding` };
2510
3188
  if (monitorsRuns(entry)) { surfaceIncident(db, board, entry, row.task_id, 'spawn_failed', payload); return { effects: [] }; }
@@ -2512,7 +3190,7 @@ async function reconcileLaunches(board, fleet, report, entry, effects, settings,
2512
3190
  return fire(db, board, entry, row.task_id, 'spawn_failed', { roles: ['platform'], payload });
2513
3191
  });
2514
3192
  if (moved) effects.push(...moved.effects);
2515
- } else report.queued.push([row.task_id, `launch ${s.launch_key}${s.pane ? ` (pane ${s.pane})` : ''} awaits its ${s.harness} session in the fleet reading`]);
3193
+ } else report.queued.push([row.task_id, `launch ${s.launch_key}${s.pane ? ` (pane ${s.pane})` : ''} awaits its native session receipt`]);
2516
3194
  } catch (error) { report.failed.push([row.task_id, `launch reconciliation retained: ${error.message}`]); }
2517
3195
  }
2518
3196
  }
@@ -2555,9 +3233,8 @@ function credentialsWanted(board, row, session, fleet) {
2555
3233
  if (!session?.agent_id || !session.machine || normalMachine(session.machine) === localMachine()) return false;
2556
3234
  if (!row.ended_at) return true;
2557
3235
  if (!view(board, (db) => detachedSession(db, session) && personCustody(db, addressOf(session)))) return false;
2558
- const address = addressOf(session);
2559
3236
  if (!fleet?.ok || fleet.unknown?.has(normalMachine(session.machine))) return true;
2560
- return Boolean(address && (fleet.alive?.has(address) || (fleet.native?.has(address) && fleet.native.get(address).alive !== false)));
3237
+ return sessionAlive(fleet, session) !== false;
2561
3238
  }
2562
3239
  const UNREACHABLE = new Set(['machine_offline', 'REQUEST_TIMEOUT', 'unavailable', 504, '504', 'ETIMEDOUT', 'ECONNRESET']);
2563
3240
  /** One keep (records) or removal (none) in the home of `scope` on `machine`: `{ home, leaseUntil }`, or `{ why }`. A
@@ -2657,21 +3334,15 @@ export async function drainAgentCredentials(root, slug) {
2657
3334
  } finally { board.close(); }
2658
3335
  }
2659
3336
 
2660
- async function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'worker', meta = {}, orchestration } = {}) {
3337
+ async function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'worker', meta = {}, workspaceOptions, orchestration } = {}) {
2661
3338
  const machine = executionHost(lane, task.machine, localMachine(), normalMachine);
2662
3339
  // Whether a board session opens a terminal window on its machine is the workflow's choice, stated on every launch;
2663
3340
  // no machine default decides it.
2664
3341
  const window = workflowFor(board.root).params.session_window;
2665
3342
  if (typeof window !== 'boolean') throw unavailable('the workflow declares no params.session_window (true or false): whether a board session opens a window on its machine is the workflow\'s choice');
2666
- {
2667
- const made = await resolveLocalWorkspace(board, task);
2668
- runTx(board, task.id, runId, (db) => {
2669
- db.prepare('UPDATE tasks SET workspace_path=?, branch_name=COALESCE(branch_name,?) WHERE id=?').run(made, task.workspace_kind === 'worktree' ? `wt/${task.id}` : null, task.id);
2670
- });
2671
- task = { ...task, workspace_path: made };
2672
- }
3343
+ const workspace = await prepareRunWorkspace(board, task, runId, meta, workspaceOptions);
2673
3344
  const model = task.model_override ?? lane.model;
2674
- if(meta.agent_profile_intent)meta.agent_profile_intent.worker={...meta.agent_profile_intent.worker,harness:lane.harness,model:model??null,cwd:task.workspace_path};
3345
+ if(meta.agent_profile_intent)meta.agent_profile_intent.worker={...meta.agent_profile_intent.worker,harness:lane.harness,model:model??null,cwd:workspace};
2675
3346
  const tail = model ? ['--model', model] : [];
2676
3347
  if (lane.harness === 'codex' && lane.reasoning_effort) tail.push('-c', `model_reasoning_effort=${JSON.stringify(lane.reasoning_effort)}`);
2677
3348
  const harness = HARNESSES[lane.harness] ? lane.harness : 'claude-code';
@@ -2685,7 +3356,7 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2685
3356
  else runTx(board, task.id, runId, (db) => event(db, task.id, 'agent_credentials_unkept', { machine, reason: kept.why }, runId));
2686
3357
  }
2687
3358
  // A card on this machine opens through this machine's own daemon; only another machine is reached with `--on`.
2688
- const base = ['open', '--new', how.program, '--detach', `--window=${window}`, ...(machine === localMachine() ? [] : ['--on', task.machine]), '--cwd', task.workspace_path, '--env', 'SUPERCODE_BOARD_PROCESS_GUARD=1',
3359
+ const base = ['open', '--new', how.program, '--detach', `--window=${window}`, ...(machine === localMachine() ? [] : ['--on', task.machine]), '--cwd', workspace, '--env', 'SUPERCODE_BOARD_PROCESS_GUARD=1',
2689
3360
  ...Object.entries(meta.agent_env??{}).flatMap(([key,value])=>['--env',`${key}=${value}`])];
2690
3361
  // the slot's last session: kept when live, resumed when lost, never another slot's (a reviewer's is its own)
2691
3362
  // a close its session's own command asked for is carried out first, so its pane is gone before the slot's next
@@ -2695,16 +3366,39 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2695
3366
  // The slot's own last session whose close was refused for good is not waited on: it runs on, and is continued below
2696
3367
  // (continuationOf), never started beside.
2697
3368
  const last = view(board, (db) => lastSession(db, task.id, slot));
3369
+ await readNativeHome(last);
3370
+ // The slot's own last session, whose close a transition asked for as its run ended (a handoff the slot's next run
3371
+ // resumes, ADR 0019) and which is still open only because it is in a turn, is working as the slot's role: this run takes
3372
+ // it over rather than waiting for the turn to end and failing its launch window on that wait. The close is withdrawn
3373
+ // and the ended run retains the session, so it is kept below (continuationOf) and the binding transfers it
3374
+ // (retireSupersededRuns). An explicit start still settles the former slot first, and another agent's session or a
3375
+ // forgotten or incident-held one is never taken over.
3376
+ if (last?.session_id && last.pane && last.machine === machine && (last.harness ?? 'claude-code') === harness && last.close_requested
3377
+ && !last.closed_at && !last.close_confirmed_at && !last.close_transferred_to && !last.forgotten && !last.monitor_held
3378
+ && /^close deferred: its session \S+ is in a turn \(/.test(last.close_error ?? '') && !(meta.agent_id && otherAgent(board, last, meta))
3379
+ && !view(board, (db) => currentRun(db, task.id)?.metadata?.supercode?.restart_authorized)) {
3380
+ tx(board, (db) => {
3381
+ 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 });
3382
+ for (const r of db.prepare('SELECT id, ended_at, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId)) {
3383
+ const s = json(r.metadata, {})?.supercode;
3384
+ if (!r.ended_at || !sameNativeSession(s, last) || s.pane !== last.pane || s.machine !== machine || (s.slot ?? 'worker') !== slot
3385
+ || !s.close_requested || s.closed_at || s.close_confirmed_at || s.close_transferred_to || claimHolds(s.close_claim)) continue;
3386
+ setRunSession(db, r.id, { close_requested: null, close_error: null, close_claim: null, close_unread: null, retain_after_run: true });
3387
+ event(db, task.id, 'session_close_withdrawn', { successor: runId, session_id: s.session_id, pane: s.pane, reason: s.close_error }, r.id);
3388
+ }
3389
+ });
3390
+ }
2698
3391
  const pending = view(board, (db) => db.prepare('SELECT id, ended_at, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId))
2699
3392
  .map((r) => ({ ...r, session: json(r.metadata, {})?.supercode }))
2700
3393
  .find((r) => (r.session?.pane || r.session?.launch_key || r.session?.surface === 'native') && !r.session.close_transferred_to && (r.session.slot ?? 'worker') === slot && (r.ended_at || r.session.close_requested) && !r.session.close_confirmed_at
2701
3394
  // Deliberate retention owes no close. Only this slot's latest identity may reach the liveness/continuation
2702
3395
  // checks below; an explicit close or another unresolved identity still holds the slot.
2703
3396
  && !(r.session.retain_after_run && !r.session.close_requested && !r.session.forgotten && last?.session_id
2704
- && r.session.session_id === last.session_id && r.session.pane === last.pane && r.session.machine === machine && (r.session.harness ?? 'claude-code') === harness)
2705
- && !(closeRefused(r.session) && last?.session_id && r.session.session_id === last.session_id));
3397
+ && sameNativeSession(r.session, last) && r.session.pane === last.pane && r.session.machine === machine && (r.session.harness ?? 'claude-code') === harness)
3398
+ && !(closeRefused(r.session) && last?.session_id && sameNativeSession(r.session, last)));
2706
3399
  if (pending) throw unavailable(`slot ${slot} still owns pane ${pending.session.pane} from run ${pending.id}; closure must be confirmed before another session starts: ${pending.session.close_error ?? 'close pending'}`);
2707
3400
  let prev = view(board, (db) => lastSession(db, task.id, slot));
3401
+ await readNativeHome(prev);
2708
3402
  if (prev?.session_id && !prev.forgotten && (prev.harness ?? 'claude-code') === harness && prev.machine === machine) {
2709
3403
  // the round's own fleet reading: a launch never reads the fleet again
2710
3404
  const fresh = fleet;
@@ -2712,9 +3406,8 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2712
3406
  // a machine whose record is not current lists no session (continuationPane reads fleet.sessions): no continuation
2713
3407
  // can be ruled out there, so nothing is resumed beside one
2714
3408
  if (fresh.unknown?.has(normalMachine(machine))) throw unavailable(`cannot check for a manual continuation of ${prev.session_id}: machine ${machine}'s fleet record is not current: ${fresh.errors?.get(normalMachine(machine)) ?? 'no current record'}`);
2715
- const r = await doorOn(machine, 'harness.v1.panes.list',{}, { timeout: 60_000 });
2716
- const listed = r.code === 0 ? json(r.stdout.trim(), null) : null;
2717
- if (!Array.isArray(listed)) throw unavailable(`cannot read panes before continuing ${prev.session_id}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
3409
+ const { list: listed, error } = await paneRows(machine);
3410
+ if (!listed) throw unavailable(`cannot read panes before continuing ${prev.session_id}: ${error}`);
2718
3411
  const pane = continuationPane(fresh, prev, new Set(listed.filter(p => !p.dead).map((p) => p.pane)));
2719
3412
  if (pane && pane !== prev.pane) prev = { ...prev, pane, close_requested: null, closed_at: null, close_confirmed_at: null, close_error: null };
2720
3413
  fleet = fresh;
@@ -2723,15 +3416,15 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2723
3416
  const report = { undelivered: [] };
2724
3417
  await closeRequested(board, report, { taskId: task.id });
2725
3418
  prev = view(board, (db) => lastSession(db, task.id, slot));
3419
+ await readNativeHome(prev);
2726
3420
  // a pane that could not be closed still holds the session: no second process is started on it
2727
3421
  if (prev?.close_requested && !prev.closed_at) throw unavailable(`the slot's session ${prev.session_id} asked to be closed and its pane ${prev.pane} could not be: ${report.undelivered.map((u) => u[2]).join('; ') || 'no reason given'}`);
2728
3422
  }
2729
3423
  // The machine was checked before the card was claimed (launcherCheck: its board process guard and keyed launch door,
2730
- // from the round's fleet reading); the launch asks no door of it. Open's reply names the pane; a session named at
2731
- // launch (Claude Code's) is bound with it, one that names itself (Codex) is bound from a later round's fleet reading.
2732
- // The pane id is chosen from the launch key and recorded before open runs, so a launch whose answer is lost (a
2733
- // timeout, a closed stream) is still found by its pane in a later round's fleet reading, and by its session id.
3424
+ // from the round's fleet reading). The pane id is recorded before open runs; the keyed native receipt binds
3425
+ // every harness's session, including when open's reply is lost.
2734
3426
  const openOwned = async (key, sid, mode, args) => {
3427
+ assertRunCurrent(board, task.id, runId);
2735
3428
  // Keeping a live pane never prepares or mutates a different route/home.
2736
3429
  if (harness === 'codex') {
2737
3430
  const profile = meta.agent_profile_intent;
@@ -2740,43 +3433,69 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2740
3433
  refreshVault(orchestration, vault);
2741
3434
  const selected = { ...profile, name: lane.name, dir: lane.dir };
2742
3435
  const worker = { ...profile.worker, harness: 'codex', model: model ?? undefined };
2743
- let resume = null;
2744
- if (mode === 'resume') {
2745
- const rows = await readNative(machine, 'codex', sid, prev?.codex_home ?? null);
2746
- const paths = [...new Set(rows.filter(row => row.locator?.harness === 'codex' && row.locator?.session_id === sid
2747
- && row.locator.storage?.kind === 'file').map(row => row.locator.storage.path))];
2748
- if (paths.length !== 1) throw unavailable('the resumed Codex has no unique physical transcript source; no pane was launched');
2749
- resume = { source: paths[0], id: sid };
2750
- }
2751
3436
  let route;
2752
- if (machine === localMachine()) {
3437
+ if (mode === 'resume') {
3438
+ route = codexPaneRouteArguments(selected, worker, vault);
3439
+ route.env = { ...route.env, ...nativeHome(prev).env };
3440
+ } else if (machine === localMachine()) {
2753
3441
  route = codexPaneRoute(selected, worker, vault);
2754
- if (resume) projectCodexPaneResume(route.env.CODEX_HOME, resume.source, resume.id);
2755
3442
  } else {
2756
3443
  const prepared = await doorOn(machine, 'harness.v1.codex.pane.prepare', {
2757
3444
  scope: `${localMachine()}:${board.root}:${lane.name}`, machine: localMachine(),
2758
- cwd: task.workspace_path,
2759
- ...codexPaneInputs(selected, worker, vault), ...(resume ? { resume } : {}),
3445
+ cwd: workspace,
3446
+ ...codexPaneInputs(selected, worker, vault),
2760
3447
  }, { timeout: 30_000, as: boardAddress(board.label) });
2761
3448
  route = prepared.code === 0 ? json(prepared.stdout.trim(), null) : null;
2762
3449
  if (!route?.env?.CODEX_HOME || !Array.isArray(route.args))
2763
3450
  throw unavailable(`the destination did not prepare the login-free Codex route: ${prepared.stderr.trim() || 'invalid route receipt'}`);
2764
3451
  }
2765
3452
  meta.codex_home = route.env.CODEX_HOME;
3453
+ meta.native_home = mode === 'resume' ? nativeHome(prev) : null;
2766
3454
  const separator = args.indexOf('--');
2767
3455
  if (separator < 0) throw unavailable('the headful Codex launch has no native argument boundary');
2768
3456
  args = [...args.slice(0, separator), ...Object.entries(route.env).flatMap(([name, value]) => ['--env', `${name}=${value}`]),
2769
3457
  ...args.slice(separator), ...route.args];
2770
- if (machine === localMachine()) declareWorkerHome({ harness: 'codex', env: route.env, cwd: task.workspace_path });
3458
+ if (machine === localMachine()) declareWorkerHome({ harness: 'codex', env: route.env, cwd: workspace });
3459
+ }
3460
+ if (mode === 'resume' && harness !== 'codex') {
3461
+ meta.native_home = nativeHome(prev);
3462
+ const separator = args.indexOf('--');
3463
+ args = [...args.slice(0, separator), ...Object.entries(meta.native_home.env).flatMap(([name, value]) => ['--env', `${name}=${value}`]), ...args.slice(separator)];
2771
3464
  }
2772
3465
  const chosen = `p_${createHash('sha256').update(key).digest('hex').slice(0, 16)}`;
2773
3466
  tx(board, (db) => {
2774
3467
  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 });
3468
+ if (getTask(db, task.id)?.backlog) throw superseded(task.id, runId);
3469
+ const currentDecision = continuationDecision(board, getTask(db, task.id), runId, slot, lane, meta.role ?? slot, meta.agent_id);
3470
+ if (mode === 'fresh' && currentDecision.prior) {
3471
+ if (!currentDecision.authorization) throw continuationRefusal(currentDecision.prior, 'no current replacement decision was retained');
3472
+ requireReplacementTeardown(db, task.id, slot, runId);
3473
+ issueReplacement(db, task.id, slot, currentDecision.authorization, { run: runId, key, session: sid, harness,
3474
+ cardMachine: getTask(db, task.id).machine, agent: meta.agent_id, assignment: currentDecision.assignment });
3475
+ }
2775
3476
  setRunSession(db, runId, { ...meta, surface: 'pane', machine, harness, slot,
2776
3477
  session_id: sid, mode, launch_key: key, pane: chosen, launch_pending: true, launched_at: now() });
2777
3478
  });
2778
3479
  const result = await sc([...args.slice(0, args.indexOf('--key')), '--pane', chosen, ...args.slice(args.indexOf('--key'))], { timeout: 180_000, as: boardAddress(board.label) });
2779
- const pane = lastJson(result.stdout)?.pane ?? chosen;
3480
+ const reply = lastJson(result.stdout), pane = reply?.pane ?? chosen;
3481
+ // Every launch records its native home before its session is bound, local or remote: a reply that omits it (a
3482
+ // remote or recovered answer) is completed from the machine's own launch receipt, which the daemon recorded with
3483
+ // the home before it created the session.
3484
+ const launchedHome = reply?.native_home
3485
+ ?? (await launchReceipt(board, { machine, launch_key: key }, 10_000).catch(() => null))?.native_home ?? null;
3486
+ const changedHome = launchedHome && tx(board, db => {
3487
+ const saved = json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(runId)?.metadata, {})?.supercode;
3488
+ if (nativeHome(saved) && JSON.stringify(nativeHome(saved).homes) !== JSON.stringify(launchedHome.homes)) {
3489
+ requestSessionClose(db, task.id, { runId });
3490
+ return true;
3491
+ }
3492
+ setRunSession(db, runId, { native_home: launchedHome });
3493
+ return false;
3494
+ });
3495
+ if (changedHome) {
3496
+ try { await stopSession(board, { surface: 'pane', machine, harness, pane, session_id: sid, launch_key: key }); } catch { /* retained close request retries */ }
3497
+ throw unavailable(`launch ${key} returned a different native home; continuation was not bound`);
3498
+ }
2780
3499
  // the run ended while its pane opened: the pane it opened is closed, never left working for an ended run
2781
3500
  if (!view(board, (db) => runCurrent(db, task.id, runId))) {
2782
3501
  try { await stopSession(board, { surface: 'pane', machine, harness, pane, session_id: sid, launch_key: key }); } catch { /* the slot's next launch settles it */ }
@@ -2786,12 +3505,8 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2786
3505
  const before = json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(runId)?.metadata, {})?.supercode ?? {};
2787
3506
  setRunSession(db, runId, { ...before, pane });
2788
3507
  });
2789
- if (pane && sid && result.code === 0) {
2790
- return { surface: 'pane', session_id: sid, machine, harness, pane, mode, launch_key: key,
2791
- launch_pending: false, launch_error: null, launched_at: now() };
2792
- }
2793
- const reason = result.code === 0 ? `launch ${key} opened pane ${pane}; its ${harness} session is bound from the fleet reading` :
2794
- `launch ${key} (pane ${pane}) gave no answer; it is bound from the fleet reading if it opened: ${(result.stderr || result.stdout).trim().slice(-300)}`;
3508
+ const reason = result.code === 0 ? `launch ${key} opened pane ${pane}; its ${harness} session awaits its native launch receipt` :
3509
+ `launch ${key} (pane ${pane}) gave no answer; its native launch receipt will reconcile it: ${(result.stderr || result.stdout).trim().slice(-300)}`;
2795
3510
  tx(board, (db) => {
2796
3511
  if (!runCurrent(db, task.id, runId)) return;
2797
3512
  setRunSession(db, runId, { launch_pending: true, launch_error: reason });
@@ -2800,105 +3515,55 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2800
3515
  throw Object.assign(new Error(reason), { launchPending: true });
2801
3516
  };
2802
3517
  let session = null;
2803
- let replaced = null, predecessor = null;
2804
- // A session in no pane is read on its machine before anything replaces it. One the board released (an adopted
2805
- // session it did not launch, left running at its run's end) is never resumed beside itself: while it runs it is kept,
2806
- // and the card's next run is delivered into it, as into any kept session.
2807
- if (prev?.surface === 'native' && !prev.pane && (!prev.close_confirmed_at || prev.released)) {
2808
- const reading = nativeReading(await readNative(prev.machine, prev.harness, prev.session_id, prev.codex_home ?? null), prev);
2809
- if (reading.alive == null) throw unavailable(`cannot establish native continuation liveness for ${addressOf(prev)}`);
2810
- fleet.native ??= new Map(); fleet.native.set(addressOf(prev), reading);
2811
- if (reading.alive && !prev.released && (prev.closed_at || prev.close_requested || prev.forgotten)) throw unavailable(`native session ${addressOf(prev)} is still live; its close must finish before replacement`);
2812
- }
2813
- // A forgotten session the board released (an adopted one, left running) is still the arc's while it runs: only the
2814
- // manager's explicit replace_session lets a live one go. Every other forget (a retarget recorded before openCoder kept
2815
- // the coder) leaves it kept below, as any released session is, and the run is delivered into it; never a fresh
2816
- // implementer beside it (VO-3755 t_c2d5d21b run 2736). One that is gone is replaced as before.
2817
- const liveForgotten = prev?.forgotten && prev.released && prev.machine === machine && (prev.harness ?? 'claude-code') === harness
2818
- && !view(board, (db) => Number(latestEvent(db, task.id, ['session_forgotten'], 'created_at')?.created_at ?? 0) >= Number(prev.forgotten))
2819
- && (prev.pane ? await panePresent(prev.machine, prev.pane) : sessionAlive(fleet, prev)) === true;
2820
- if (prev?.forgotten && !liveForgotten) {
2821
- if (!prev.close_confirmed_at && prev.pane && await panePresent(prev.machine, prev.pane)) throw new Error(`forgotten session ${prev.session_id} still has live pane ${prev.pane}; close it before replacement`);
2822
- replaced = { session_id: prev.session_id, why: 'the workflow let it go' };
2823
- }
2824
- else if (prev && meta.agent_id && (predecessor = otherAgent(board, prev, meta))) {
2825
- // A session is continued only as the agent it belongs to: the daemon refuses any other ("session … is agent …'s;
2826
- // nothing resumes it as agent …"), and keeping it would deliver this run into another agent's session. A card's role
2827
- // agent ends with the card (finishCardAgentAssignment) and an ended agent is never started again (agents.mjs
2828
- // localAgent: the key's next generation is minted in its place, with fresh memory), so a reopened card's run is a new
2829
- // agent, as is one the manager reassigned to another agent. Its predecessor's session is let go and the run starts
2830
- // fresh from the card's text and thread, as a lane of another harness does (VO-2858 t_cc590655: reopened at 22:57Z on
2831
- // 2026-10-17, runs 1545 and 1546 ended spawn_failed resuming codex 01a14bac, agent_21e274c9's, as agent_00246aaa).
2832
- // Nothing is closed here: every ended run's pane was confirmed closed above (`pending`), so a session still open at
2833
- // this point is one the board does not close (a close refused for good, which runs on; a pane a person continued by
2834
- // hand, swapped in above), and is released as an adopted one is (closeRequested), never closed. The release is
2835
- // written to the runs that hold it before the fresh session starts, so the slot's next launch, which reads the fresh
2836
- // session as the slot's last, finds no pane of them still owed a close (`pending`); a Codex launch binds from a later
2837
- // round's reading, so the release cannot wait for this launch's own record.
2838
- tx(board, (db) => {
2839
- 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 });
2840
- for (const r of db.prepare('SELECT id, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId)) {
2841
- const held = json(r.metadata, {})?.supercode;
2842
- if (held?.session_id !== prev.session_id || (held.slot ?? 'worker') !== slot || held.close_confirmed_at || held.close_transferred_to) continue;
2843
- const at = now();
2844
- 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 });
2845
- 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);
2846
- }
2847
- });
2848
- replaced = { session_id: prev.session_id, why: `it is agent ${predecessor}'s, and this run is agent ${meta.agent_id}'s`, letGo: true };
2849
- }
2850
- else if (prev && (prev.harness ?? 'claude-code') !== harness) {
2851
- // the card was reassigned to a lane of another harness: that harness starts it fresh (the card's text and thread
2852
- // are its context), and the last session, if still open, is closed so two harnesses never work one card
2853
- if (prev.pane && !prev.closed_at && !adoptedOnly(prev)) await stopSession(board, prev); // an adopted session is released, never closed
2854
- replaced = { session_id: prev.session_id, why: `the card's lane runs ${harness}, not ${prev.harness ?? 'claude-code'}`, letGo: true };
2855
- } else if (prev && prev.machine === machine) {
2856
- // a session the board closed, or one whose close its own command asked for (the next tick's sweep closes that
2857
- // run's pane), is not kept, whatever a fleet reading taken before the close still lists
2858
- const next = continuationOf(prev, prev.pane ? await panePresent(prev.machine, prev.pane) : sessionAlive(fleet, prev));
2859
- if (next === 'refuse') throw new Error(`session ${prev.session_id} still has live pane ${prev.pane}; cannot resume or replace it`);
3518
+ const decision = continuationDecision(board, getTaskNow(board, task.id), runId, slot, lane, meta.role ?? slot, meta.agent_id);
3519
+ if (!prev && decision.prior) prev = decision.prior.session;
3520
+ await readNativeHome(prev);
3521
+ let replaced = null;
3522
+ if (decision.authorization) {
3523
+ view(board, db => requireReplacementTeardown(db, task.id, slot, runId));
3524
+ replaced = { session_id: decision.prior.session.session_id ?? null, why: decision.authorization.reason,
3525
+ authorization: decision.authorization.id, source: decision.authorization.source };
3526
+ } else if (prev) {
3527
+ if (!prev.session_id) throw continuationRefusal(decision.prior, 'the previous launch never supplied a native conversation id');
3528
+ if (meta.agent_id && otherAgent(board, prev, meta)) throw continuationRefusal(decision.prior, 'the conversation belongs to another agent');
3529
+ // A released adopted instance is still kept while alive. An unread native result is never absence.
3530
+ if (prev.surface === 'native' && !prev.pane && (!prev.close_confirmed_at || prev.released)) {
3531
+ const reading = nativeReading(await readNative(prev.machine, prev.harness, prev.session_id, nativeHomes(prev)), prev);
3532
+ if (reading.alive == null) throw unavailable(`cannot establish native continuation liveness for ${addressOf(prev)}`);
3533
+ if (reading.alive && !prev.released && (prev.closed_at || prev.close_requested))
3534
+ throw unavailable(`native session ${addressOf(prev)} is still live; its close must finish before continuation`);
3535
+ }
3536
+ await readBefore(fleet, prev);
3537
+ const alive = sessionAlive(fleet, prev);
3538
+ if (alive == null) throw unavailable(`cannot establish continuation liveness for ${addressOf(prev)}`);
3539
+ if (alive && prev.pane && !fleetSessionRows(fleet, prev).some(row => row.pane === prev.pane))
3540
+ throw unavailable(`pane ${prev.pane} has no verified binding to ${addressOf(prev)} in its native home`);
3541
+ const next = continuationOf(prev, alive);
3542
+ if (next === 'wait') throw unavailable(`pane ${prev.pane} of ${addressOf(prev)} is still live; its close must be confirmed before continuation`);
3543
+ // the round's reading predates a close confirmed since: the pane door, read now, decides (ADR 0003)
3544
+ if (next === 'closed' && await panePresent(prev.machine, prev.pane))
3545
+ throw continuationRefusal(decision.prior, `its pane ${prev.pane} is still live after its close was confirmed`);
2860
3546
  if (next === 'keep') {
2861
- // the session is kept; the heartbeat's count belongs to the run that ended, so the new run starts it again
2862
- const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _is, idle_notice: _in, unknown_since: _us, unknown_notice: _un, work_progress: _wp, idle_observation: _io, idle_below_threshold: _ib, idle_eligible: _ie, close_requested: _q, closed_at: _c, close_confirmed_at: _cc, close_error: _ce, close_attempts: _ca, close_attempted_at: _ct, close_failed_at: _cf, close_unread: _cu, close_claim: _cl, monitor_held: _mh, released: _rl, review_held: _rh, retain_after_run: _rr, handed_back_from: _hf, handed_back_run: _hr, forgotten: _fg, resumable: _rs, ...kept } = prev;
2863
- session = { ...kept, mode: 'adopt', launched_at: now(), seen_at: now() };
2864
- // The common binding below transfers ownership and subscriptions before filing the continuation prompt.
2865
- } else if (prev.resumable !== false && await nativeAbsent(prev)) {
2866
- // its transcript is gone (never written, or removed): nothing to resume and no context to keep, so the slot's next
2867
- // session starts fresh, and the run's session_replaced event says why
2868
- replaced = { session_id: prev.session_id, why: `its transcript is gone: ${prev.machine}'s complete ${prev.harness ?? 'claude-code'} discovery lists no session of that id`, letGo: true };
2869
- } else if (prev.resumable !== false) {
3547
+ const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _is, idle_notice: _in, unknown_since: _us, unknown_notice: _un, work_progress: _wp, idle_observation: _io, idle_below_threshold: _ib, idle_eligible: _ie, close_requested: _q, closed_at: _c, close_confirmed_at: _cc, close_error: _ce, close_attempts: _ca, close_attempted_at: _ct, close_failed_at: _cf, close_unread: _cu, close_claim: _cl, monitor_held: _mh, released: _rl, review_held: _rh, retain_after_run: _rr, handed_back_from: _hf, handed_back_run: _hr, forgotten: _fg, resumable: _rs, replacement_authorization: _ra, ...kept } = prev;
3548
+ session = { ...kept, native_home: nativeHome(prev), mode: 'adopt', launched_at: now(), seen_at: now() };
3549
+ } else {
3550
+ nativeHomes(prev);
2870
3551
  const authorization = view(board, db => currentRun(db,task.id)?.metadata?.supercode?.restart_authorized);
2871
- const released = view(board,db=>{
2872
- const last = latestEvent(db, task.id, ['dispatch_held', 'dispatch_released', 'restart_authorization_consumed'], 'id,kind');
2873
- return last?.kind === 'dispatch_released';
2874
- });
2875
- // Only a session whose run ended in a monitored incident waits for its manager or creator; one a workflow
2876
- // transition closed (a reviewer after its verdict, an implementer whose card came back) is the slot's deliberate
2877
- // handoff, resumed for the slot's next run: a card has one session per slot, and every round of that slot runs in
2878
- // it with the context of the rounds before
2879
- if (monitorsRuns(workflowFor(board.root)) && prev.monitor_held && !authorization && !released) throw new Error(`session ${prev.session_id} is stopped by incident ${prev.incident ?? 'unknown'}; only its manager or creator may restart it with workflow start`);
2880
- // a resumed session is told what this run is for, as a kept one is: the run's own prompt (a reviewer resumed for
2881
- // its next round gets that round's brief and PR heads), not only that it was resumed
2882
- const told = input ? `The board resumed this session for this run of card ${task.id}. ${input}` : `The board resumed this session after it was lost; you are still on card ${task.id}: ${task.title}. Carry on.`;
2883
- // a session is resumed in its own harness, whatever the lane's is now
2884
- const its = HARNESSES[prev.harness ?? 'claude-code'] ?? HARNESSES['claude-code'];
3552
+ const released = view(board,db => latestEvent(db, task.id, ['dispatch_held', 'dispatch_released', 'restart_authorization_consumed'], 'id,kind')?.kind === 'dispatch_released');
3553
+ if (monitorsRuns(workflowFor(board.root)) && prev.monitor_held && !authorization && !released)
3554
+ throw continuationRefusal(decision.prior, `incident ${prev.incident ?? 'unknown'} still needs its manager's restart decision`);
3555
+ const told = input ? `The board resumed this session for this run of card ${task.id}. ${input}` : `The board resumed this session after it stopped; you are still on card ${task.id}: ${task.title}. Carry on.`;
3556
+ const its = HARNESSES[prev.harness ?? 'claude-code'];
3557
+ if (!its?.resume) throw continuationRefusal(decision.prior, 'its harness publishes no native resume door');
2885
3558
  const launchKey = `board-${task.id}-r${runId}-resume`;
2886
3559
  session = await openOwned(launchKey, prev.session_id, 'resume', ['open', '--new', its.program, ...base.slice(3),
2887
3560
  '--key', launchKey, '--input', told, '--', ...its.resume(prev.session_id), ...tail]);
2888
- } else replaced = { session_id: prev.session_id, why: 'its resume never came up',
2889
- letGo: recoversRuns(workflowFor(board.root)) && Boolean(prev.resume_failed_before_live) && Boolean(prev.close_confirmed_at) };
2890
- } else if (prev) replaced = { session_id: prev.session_id, why: `the card moved from ${prev.machine} to ${machine}` };
2891
- // a card keeps its context: a slot's session that cannot be kept or resumed stops the launch, and only a session the
2892
- // workflow let go (`forget_session`), its manager did (a reassignment to another harness's lane), or that belongs to
2893
- // another agent than the run's (otherAgent) is replaced
2894
- if (replaced && !prev?.forgotten && !replaced.letGo) {
2895
- 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`);
2896
- failed.resumeFailed = { session_id: replaced.session_id, slot, why: replaced.why };
2897
- throw failed;
3561
+ }
2898
3562
  }
2899
3563
  if (!session) {
2900
- const sid = how.namesItself ? null : randomUUID();
2901
- const launchKey = `board-${task.id}-r${runId}-${(sid ?? randomUUID()).slice(0,8)}`;
3564
+ const issued = decision.authorization?.issued;
3565
+ const sid = issued ? issued.session : how.namesItself ? null : randomUUID();
3566
+ const launchKey = issued?.key ?? `board-${task.id}-r${runId}-${(sid ?? randomUUID()).slice(0,8)}`;
2902
3567
  // A fresh session is named for its card by its readable id (`voice-calls-3f`; a reviewer's `voice-calls-reviewer-3f`),
2903
3568
  // else its handle (`vo-856-3f`), where its harness takes a name, with the start of its id after it as the name's own
2904
3569
  // suffix; a kept or resumed session keeps its name.
@@ -2917,13 +3582,14 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2917
3582
  const kind = { adopt: 'session_adopted', resume: 'session_resumed', fresh: replaced ? 'session_replaced' : 'session_started' }[session.mode];
2918
3583
  const recorded = tx(board, (db) => {
2919
3584
  const row = db.prepare('SELECT ended_at, metadata FROM task_runs WHERE id = ?').get(runId);
2920
- if (row?.ended_at || getTask(db, task.id)?.current_run_id !== runId) {
3585
+ if (row?.ended_at || getTask(db, task.id)?.current_run_id !== runId || getTask(db, task.id)?.backlog) {
2921
3586
  requestSessionClose(db, task.id, { runId });
2922
3587
  return false;
2923
3588
  }
3589
+ continuationDecision(board, getTask(db, task.id), runId, slot, lane, meta.role ?? slot, meta.agent_id);
2924
3590
  // this session recorded on the run already (by a binding that got here first) is said once
2925
3591
  const saved = json(row?.metadata, {})?.supercode;
2926
- if (saved?.address === session.address && !saved.launch_pending) return true;
3592
+ if (sameNativeSession(saved, session) && !saved.launch_pending) return true;
2927
3593
  setRunSession(db, runId, session);
2928
3594
  transferRetainedSession(db, task.id, runId, session);
2929
3595
  event(db, task.id, kind, { session_id: session.session_id, machine, pane: session.pane, address: session.address, mode: session.mode, ...(replaced ? { replaced } : {}) }, runId);
@@ -2937,9 +3603,11 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2937
3603
  return true;
2938
3604
  });
2939
3605
  if (!recorded) return { ...session, mode: 'finished_during_launch' };
3606
+ // A kept session takes this run's input as the run's instruction, as a launched or resumed one takes it as its first
3607
+ // prompt: never as a notice, which its harness reads as no instruction.
2940
3608
  if (session.mode === 'adopt' && input) {
2941
3609
  assertRunCurrent(board, task.id, runId);
2942
- await fileMail(board, session.address, input, `board-run-${task.id}-r${runId}`);
3610
+ await fileMail(board, session.address, input, `board-run-${task.id}-r${runId}`, { as: 'instruction' });
2943
3611
  }
2944
3612
  return session;
2945
3613
  }
@@ -2951,7 +3619,7 @@ function transferRetainedSession(db, id, runId, session) {
2951
3619
  for (const row of db.prepare('SELECT id,metadata FROM task_runs WHERE task_id=? AND id<? AND ended_at IS NOT NULL').all(id, runId)) {
2952
3620
  const old = json(row.metadata, {})?.supercode;
2953
3621
  if (!old?.retain_after_run || old.close_requested || old.close_confirmed_at || old.close_transferred_to || old.forgotten
2954
- || old.session_id !== session.session_id || old.machine !== session.machine || (old.slot ?? 'worker') !== (session.slot ?? 'worker')) continue;
3622
+ || !sameNativeSession(old, session) || (old.slot ?? 'worker') !== (session.slot ?? 'worker')) continue;
2955
3623
  setRunSession(db, row.id, { close_transferred_to: runId });
2956
3624
  event(db, id, 'session_transferred', { successor: runId, session_id: old.session_id, pane: old.pane ?? null }, row.id);
2957
3625
  }
@@ -2969,36 +3637,76 @@ function otherAgent(board, prev, meta) {
2969
3637
 
2970
3638
  /**
2971
3639
  * 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
2972
- * its native process): an open session that is live is kept, and the run is delivered into it; a live pane holding a
2973
- * session the board closed refuses the launch; anything else is resumed. A released session is open: its release only
2974
- * freed the slot, and the session was left running. So is one whose close the machine refused for good.
3640
+ * its native process): an open session that is live is kept, and the run is delivered into it; a live pane whose close
3641
+ * is requested and not yet confirmed waits for it ('wait'); a live pane whose close was confirmed is judged by its
3642
+ * machine's pane door read after that close ('closed'), since the reading it was found live in may predate it; anything
3643
+ * else is resumed. A released session is open: its release only freed the slot, and the session was left running. So is
3644
+ * one whose close the machine refused for good.
2975
3645
  */
2976
3646
  export function continuationOf(prev, live) {
2977
3647
  const open = Boolean(prev.released) || closeRefused(prev) || (!prev.closed_at && !prev.close_requested);
2978
3648
  if (open) return live ? 'keep' : 'resume';
2979
- return live && prev.pane ? 'refuse' : 'resume';
3649
+ if (!live || !prev.pane) return 'resume';
3650
+ return prev.close_confirmed_at ? 'closed' : 'wait';
3651
+ }
3652
+
3653
+ /** The live panes on `machine` working at or under folder `path`, read from the machine now (a closing card's worktree
3654
+ * is retired only when none is: engine cleanupWorkspace). `also` names the same folder as its machine resolved it (its
3655
+ * links followed there), so a pane whose directory reads resolved still matches. Throws when the machine could not be
3656
+ * read. */
3657
+ export async function panesUnder(machine, path, also = []) {
3658
+ const r = await doorOn(machine, 'harness.v1.panes.list', {}, { timeout: 60_000 });
3659
+ const panes = r.code === 0 ? json(r.stdout.trim(), null) : null;
3660
+ if (!Array.isArray(panes)) throw new Error(`cannot read the panes on ${normalMachine(machine ?? localMachine())}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
3661
+ const local = normalMachine(machine ?? localMachine()) === localMachine();
3662
+ const real = (at) => { if (local) { try { return realpathSync(at); } catch { /* read as written */ } } return String(at).replace(/\/+$/, ''); };
3663
+ const roots = [...new Set([path, ...also].filter(Boolean).map(real))];
3664
+ return panes.filter((p) => !p.dead && typeof p.cwd === 'string' && p.cwd && roots.some((root) => real(p.cwd) === root || real(p.cwd).startsWith(`${root}/`))).map((p) => p.pane);
3665
+ }
3666
+
3667
+ /** Whether a card session whose record never said it closed (held after its run, monitored, or closed by hand outside
3668
+ * the board) is gone, as its machine reads it now: its pane not live there, and its process not running (a headless
3669
+ * worker's pid on this machine; a native session's own activity reading, `persisted` and not `running`). Anything not
3670
+ * read, or read alive, is not gone. */
3671
+ export async function sessionGone(session) {
3672
+ await readNativeHome(session);
3673
+ const machine = session?.machine ?? localMachine();
3674
+ if (session?.launch_pending) return false;
3675
+ if (session?.surface === 'headless' && session.pid) return normalMachine(machine) === localMachine() && !pidAlive(session.pid);
3676
+ if (session?.pane) {
3677
+ const r = await doorOn(machine, 'harness.v1.panes.list', {}, { timeout: 60_000 });
3678
+ const panes = r.code === 0 ? json(r.stdout.trim(), null) : null;
3679
+ if (!Array.isArray(panes) || panes.some((p) => p.pane === session.pane && !p.dead)) return false;
3680
+ }
3681
+ if (session?.session_id) {
3682
+ const harness = session.harness ?? 'claude-code';
3683
+ try {
3684
+ const { sessions } = await discoverNative(machine, harness, session.session_id, nativeHomes(session), { activity: true });
3685
+ const reading = nativeReading(sessions, { ...session, harness });
3686
+ return reading.alive === false;
3687
+ } catch { return false; }
3688
+ }
3689
+ return Boolean(session?.pane);
2980
3690
  }
2981
3691
 
2982
3692
  /** A machine reading, not a missing catalog entry, establishes whether a pane is alive. */
2983
3693
  async function panePresent(machine, pane) {
2984
- const r = await doorOn(machine, 'harness.v1.panes.list',{}, { timeout: 60_000 });
3694
+ // a liveness decision about one pane (a resume, a replacement, a launch that must have survived) asks the daemon now:
3695
+ // the held record (paneRows) lags the host by its cycle, and a pane the board itself closed this round still reads
3696
+ // live there, which refused the resume of a timed-out card twice and gave the card up
3697
+ const r = await doorOn(machine, 'harness.v1.panes.list', {}, { timeout: 60_000 });
2985
3698
  const panes = r.code === 0 ? json(r.stdout.trim(), null) : null;
2986
3699
  if (!Array.isArray(panes)) throw unavailable(`cannot establish pane ${pane} liveness on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
2987
3700
  return panes.some((p) => p.pane === pane && !p.dead);
2988
3701
  }
2989
3702
 
2990
- /** Whether a pane just launched is still there after the harness had a moment to start. */
2991
- async function paneSurvives(machine, pane) {
2992
- // a timer, never a blocking wait: the owner answers verbs while the harness starts
2993
- await new Promise((resolve) => setTimeout(resolve, 6000));
2994
- return await panePresent(machine, pane);
2995
- }
2996
-
2997
3703
  /** Hermes's own worker argv and task environment, run on this machine; watched by its pid. */
2998
- async function launchHeadless(board, task, runId, lane, settings, { prompt = null, skills: runSkills = [], meta = {} } = {}) {
2999
- const workspace = await resolveLocalWorkspace(board, task);
3704
+ async function launchHeadless(board, task, runId, lane, settings, { prompt = null, skills: runSkills = [], meta = {}, workspaceOptions } = {}) {
3705
+ const workspace = await prepareRunWorkspace(board, task, runId, meta, workspaceOptions);
3706
+ assertRunCurrent(board, task.id, runId);
3707
+ await closeRequested(board, { undelivered: [] }, { taskId: task.id });
3708
+ assertRunCurrent(board, task.id, runId);
3000
3709
  if(meta.agent_profile_intent)meta.agent_profile_intent.worker={...meta.agent_profile_intent.worker,harness:lane.harness,model:task.model_override??lane.model??null,cwd:workspace};
3001
- runTx(board, task.id, runId, (db) => db.prepare('UPDATE tasks SET workspace_path=?,branch_name=COALESCE(branch_name,?) WHERE id=?').run(workspace, task.workspace_kind === 'worktree' ? `wt/${task.id}` : null, task.id));
3002
3710
  // the card's own skills and the ones its status's run names (a reviewer's, for one)
3003
3711
  const skills = [...new Set([...task.skills, ...runSkills])];
3004
3712
  const args = ['-p', lane.name, '--cli', '--accept-hooks', ...skills.flatMap((s) => ['--skills', s]),
@@ -3008,40 +3716,99 @@ async function launchHeadless(board, task, runId, lane, settings, { prompt = nul
3008
3716
  mkdirSync(logDir, { recursive: true });
3009
3717
  const logFile = join(logDir, `${task.id}.log`);
3010
3718
  rotateLog(logFile, settings.logRotateBytes, settings.logBackups);
3011
- const out = openSync(logFile, 'a');
3012
3719
  const exitFile = join(logDir, `${task.id}.r${runId}.exit`);
3720
+ await readNativeHome(view(board, db => lastSession(db, task.id, meta.slot)));
3013
3721
  // the launch's secret, given to this worker alone: the run keeps its hash, and the worker's session record must name it
3014
3722
  const launchSecret = randomUUID() + randomUUID();
3723
+ const session = tx(board, db => {
3724
+ assertRunCurrent(board, task.id, runId);
3725
+ const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(runId)?.metadata, {})?.supercode;
3726
+ if (current?.launch_key || current?.pid) throw continuationRefusal({ run: runId, session: current }, 'this attempt already issued its headless worker; its retained launch must be reconciled');
3727
+ const decision = continuationDecision(board, getTask(db, task.id), runId, meta.slot, lane, meta.role ?? meta.slot, meta.agent_id);
3728
+ if (decision.authorization) requireReplacementTeardown(db, task.id, meta.slot, runId);
3729
+ else if (decision.prior) {
3730
+ const previous = decision.prior.session;
3731
+ nativeHomes(previous);
3732
+ // a close under way is waited for; only one that is not, or was refused for good, needs the manager (ADR 0003)
3733
+ const closing = previous.close_requested && !previous.close_confirmed_at && !closeRefused(previous);
3734
+ if (previous.surface === 'headless' && previous.pid && pidAlive(previous.pid))
3735
+ throw closing ? unavailable(`its previous headless worker ${previous.pid} is still closing; its close must finish before headless resume`)
3736
+ : continuationRefusal(decision.prior, 'its previous headless worker is still present');
3737
+ if (previous.surface !== 'headless' && !previous.close_confirmed_at && !previous.close_transferred_to)
3738
+ throw closing ? unavailable(`its previous execution ${addressOf(previous) ?? previous.pane ?? previous.launch_key} is still closing; its close must be confirmed before headless resume`)
3739
+ : continuationRefusal(decision.prior, 'its previous execution has no confirmed close before headless resume');
3740
+ }
3741
+ const launchKey = randomUUID();
3742
+ if (decision.authorization) issueReplacement(db, task.id, meta.slot, decision.authorization, { run: runId,
3743
+ key: launchKey, session: null, harness: lane.harness, cardMachine: task.machine, agent: meta.agent_id, assignment: decision.assignment });
3744
+ const prepared = { ...meta, surface: 'headless', harness: lane.harness,
3745
+ ...(decision.prior && !decision.authorization ? { native_home: nativeHome(decision.prior.session) } : {}),
3746
+ machine: executionHost(lane, task.machine, localMachine(), normalMachine), mode: decision.prior && !decision.authorization ? 'resume' : 'fresh',
3747
+ session_id: decision.prior && !decision.authorization ? decision.prior.session.session_id : null,
3748
+ launch_key: launchKey, launch_pending: false, launched_at: now(), exit_file: exitFile,
3749
+ continuation_failure_file: `${exitFile}.continuation`,
3750
+ launch_secret: createHash('sha256').update(launchSecret).digest('hex'),
3751
+ replacement_authorization_id: decision.authorization?.id ?? null };
3752
+ setRunSession(db, runId, prepared);
3753
+ return prepared;
3754
+ });
3755
+ const out = openSync(logFile, 'a');
3015
3756
  // the worker runs under a shell that records its exit code, so a later tick can tell a clean exit from a crash
3016
- const child = spawnChild('/bin/sh', ['-c', '"$0" "$@"; echo $? > "$SUPERCODE_BOARD_EXIT_FILE"', process.execPath, WORKER, ...args], {
3757
+ let child;
3758
+ try { child = spawnChild('/bin/sh', ['-c', '"$0" "$@"; echo $? > "$SUPERCODE_BOARD_EXIT_FILE"', process.execPath, WORKER, ...args], {
3017
3759
  cwd: workspace, detached: true, stdio: ['ignore', out, out],
3018
3760
  env: {
3019
- SUPERCODE_BOARD_EXIT_FILE: exitFile,
3020
3761
  ...(task.goal_mode ? { HERMES_KANBAN_GOAL_MODE: '1', ...(task.goal_max_turns ? { HERMES_KANBAN_GOAL_MAX_TURNS: String(task.goal_max_turns) } : {}) } : {}),
3021
3762
  ...process.env, ...meta.agent_env, SUPERCODE_ORCHESTRATOR_HOME: board.root, HERMES_HOME: meta.agent_env?.HERMES_HOME??lane.dir, HERMES_PROFILE: lane.name, HERMES_KANBAN_TASK: task.id, HERMES_KANBAN_WORKSPACE: workspace,
3763
+ SUPERCODE_BOARD_EXIT_FILE: exitFile, SUPERCODE_BOARD_FAILURE_FILE: session.continuation_failure_file,
3022
3764
  HERMES_KANBAN_RUN_ID: String(runId), SUPERCODE_RUN_LAUNCH: launchSecret, HERMES_KANBAN_DB: board.path, HERMES_KANBAN_BOARD: board.slug, HERMES_SESSION_SOURCE: 'kanban',
3023
3765
  HERMES_KANBAN_CLAIM_LOCK: claimer(), ...(prompt ? { SUPERCODE_BOARD_PROMPT: prompt } : {}), ...(task.tenant ? { HERMES_TENANT: task.tenant } : {}),
3024
3766
  ...(task.branch_name ? { HERMES_KANBAN_BRANCH: task.branch_name } : {}),
3767
+ SUPERCODE_BOARD_RESUME_SESSION: session.mode === 'resume' ? session.session_id : '',
3768
+ SUPERCODE_BOARD_NATIVE_HOME: session.mode === 'resume' ? JSON.stringify(session.native_home) : '',
3025
3769
  },
3026
- });
3770
+ }); } finally { closeSync(out); }
3771
+ child.once('error', error => tx(board, db => setRunSession(db, runId, { launch_error: `spawning headless worker: ${error.message}` })));
3027
3772
  child.unref();
3028
- const session = { surface: 'headless', pid: child.pid, machine: executionHost(lane, task.machine, localMachine(), normalMachine), mode: 'fresh', launched_at: now(), exit_file: exitFile, launch_secret: createHash('sha256').update(launchSecret).digest('hex'), ...meta };
3773
+ session.pid = child.pid;
3774
+ // Keep the physical process reference even when a concurrent manager act invalidated this attempt.
3775
+ tx(board, db => setRunSession(db, runId, { pid: child.pid }));
3029
3776
  // recorded against this run only while it is still the card's current one; a run ended meanwhile gets no worker
3777
+ let refused;
3030
3778
  const recorded = tx(board, (db) => {
3031
- if (!runCurrent(db, task.id, runId)) return false;
3779
+ if (!runCurrent(db, task.id, runId) || getTask(db, task.id)?.backlog) return false;
3780
+ try { continuationDecision(board, getTask(db, task.id), runId, meta.slot, lane, meta.role ?? meta.slot, meta.agent_id); }
3781
+ catch (error) { refused = error; return false; }
3032
3782
  db.prepare('UPDATE tasks SET worker_pid = ? WHERE id = ? AND current_run_id = ?').run(child.pid, task.id, runId);
3033
3783
  db.prepare('UPDATE task_runs SET worker_pid = ? WHERE id = ?').run(child.pid, runId);
3034
- setRunSession(db, runId, session);
3035
3784
  event(db, task.id, 'spawned', { pid: child.pid }, runId);
3036
3785
  return true;
3037
3786
  });
3038
3787
  if (!recorded) {
3039
- try { process.kill(-child.pid, 'SIGTERM'); } catch { try { process.kill(child.pid, 'SIGTERM'); } catch { /* already gone */ } }
3788
+ try { stopWorkChild(child, () => process.kill(-child.pid, 'SIGTERM'), { signal: 'SIGTERM' }); } catch { try { stopWorkChild(child, () => process.kill(child.pid, 'SIGTERM'), { signal: 'SIGTERM' }); } catch { /* already gone */ } }
3789
+ if (refused) throw refused;
3040
3790
  throw Object.assign(new Error(`run ${runId} of ${task.id} ended while its worker started; the worker was stopped`), { launchSuperseded: true });
3041
3791
  }
3042
3792
  return session;
3043
3793
  }
3044
3794
 
3795
+ /** The worker's native identity is bound only while this exact role/configuration still owns its launch. */
3796
+ export function assertHeadlessBinding(board, taskId, runId, harness, sessionId) {
3797
+ assertRunCurrent(board, taskId, runId);
3798
+ const task = getTaskNow(board, taskId), entry = workflowFor(board.root);
3799
+ const session = currentRun(board.db, taskId)?.metadata?.supercode;
3800
+ const spec = runSpec(entry, statusOf(board.db, task), task);
3801
+ const scope = scopeOf(board.db, board, task, { params: entry.params });
3802
+ const profile = spec ? evaluate(entry.workflow.roles?.[spec.role] ?? 'card.assignee', scope) : null;
3803
+ const lane = lanesOf(entry.profiles)[profile];
3804
+ if (!lane || lane.harness !== harness || session?.harness !== harness) throw continuationRefusal({ run: runId, session }, 'the worker harness/configuration changed before native binding');
3805
+ const decision = continuationDecision(board, task, runId, session.slot ?? 'worker', lane, session.role ?? spec?.role ?? 'implementer', session.agent_id);
3806
+ if (decision.prior && !decision.authorization && sessionId !== decision.prior.session.session_id)
3807
+ throw continuationRefusal(decision.prior, 'the native resume door returned a different conversation id');
3808
+ if (decision.authorization && decision.authorization.issued?.key !== session.launch_key)
3809
+ throw continuationRefusal(decision.prior, 'the native binding belongs to a different replacement issuance');
3810
+ }
3811
+
3045
3812
  /** Hermes's worker log rotation: past `max` bytes the log moves to `.1` (… `.n`), the oldest dropped. */
3046
3813
  function rotateLog(file, max, backups) {
3047
3814
  try {
@@ -3052,19 +3819,48 @@ function rotateLog(file, max, backups) {
3052
3819
  } catch { /* a log that cannot rotate keeps growing */ }
3053
3820
  }
3054
3821
 
3055
- /** A headless card's directory on this machine, made as Hermes makes it (scratch, dir, or a git worktree). */
3056
- async function resolveLocalWorkspace(board, task) {
3822
+ /** Select the slot's native launch directory before both admission and launch, without consulting the author's branch. */
3823
+ async function workspaceForRun(board, task, { role, slot, lane, runId = null }) {
3824
+ if (role !== 'reviewer' || task.workspace_kind !== 'worktree') return { role };
3825
+ const decision = continuationDecision(board, task, runId, slot, lane, role);
3826
+ const previous = decision.authorization ? null : decision.prior?.session;
3827
+ let cwd = previous?.cwd ?? previous?.agent_profile_intent?.worker?.cwd ?? null;
3828
+ if (previous && !cwd) {
3829
+ await readNativeHome(previous);
3830
+ cwd = nativeReading(await readNative(previous.machine, previous.harness ?? 'claude-code', previous.session_id, nativeHomes(previous)), previous).row?.cwd;
3831
+ if (!cwd) throw unavailable(`reviewer session ${addressOf(previous)} has no readable launch directory; its continuation waits for its native cwd`);
3832
+ }
3833
+ const reviewWorkspace = view(board, db => reviewWorkspaces(db, task.id)).find(row =>
3834
+ row.slot === slot && normalMachine(row.machine ?? localMachine()) === normalMachine(task.machine ?? localMachine()) && row.repository === task.workspace_anchor) ?? null;
3835
+ return { role, cwd, reviewWorkspace };
3836
+ }
3837
+
3838
+ /** Prepare and retain the run's directory before open/spawn. A reviewer's allocation never retargets its author. */
3839
+ async function prepareRunWorkspace(board, task, runId, meta, options) {
3840
+ const made = await resolveLocalWorkspace(board, task, options);
3841
+ Object.assign(meta, { cwd: made.path, review_workspace: made.reviewWorkspace ?? null });
3842
+ runTx(board, task.id, runId, db => {
3843
+ assertWorkspaceOwner(db, task, { ...made, workspaceRoot: task.workspace_kind === 'worktree' ? made.path : made.root });
3844
+ setRunSession(db, runId, { cwd: meta.cwd, review_workspace: meta.review_workspace });
3845
+ if (!(task.workspace_kind === 'worktree' && meta.role === 'reviewer'))
3846
+ db.prepare('UPDATE tasks SET workspace_path=?,branch_name=COALESCE(branch_name,?) WHERE id=?').run(made.path, task.workspace_kind === 'worktree' ? `wt/${task.id}` : null, task.id);
3847
+ });
3848
+ return made.path;
3849
+ }
3850
+
3851
+ /** A run's directory on its execution machine, made through that machine's workspace door. */
3852
+ async function resolveLocalWorkspace(board, task, options) {
3057
3853
  const root = board.root;
3058
3854
  if (task.workspace_kind !== 'scratch') {
3059
3855
  // A branch the board named for the card is claimed until its worktree is made, and the making is recorded once.
3060
3856
  const facts = view(board, () => workspaceTask(board, task, { claims: true })), claim = facts.branch_claim;
3061
- const made = await resolveWorkspace(facts, true);
3062
- if (claim) tx(board, (db) => { if (branchClaim(db, task.id, task.branch_name)) event(db, task.id, 'worktree_made', { branch: task.branch_name, repository: task.workspace_anchor ?? null, path: made.path }); });
3063
- return made.path;
3857
+ const made = await resolveWorkspace(facts, true, options);
3858
+ if (claim && options.role !== 'reviewer') tx(board, (db) => { if (branchClaim(db, task.id, task.branch_name)) event(db, task.id, 'worktree_made', { branch: task.branch_name, repository: task.workspace_anchor ?? null, path: made.path }); });
3859
+ return made;
3064
3860
  }
3065
3861
  const dir = task.workspace_path || (board.slug === 'default' ? join(root, 'kanban', 'workspaces', task.id) : join(root, 'kanban', 'boards', board.slug, 'workspaces', task.id));
3066
3862
  mkdirSync(dir, { recursive: true });
3067
- return dir;
3863
+ return { path: dir, root: dir };
3068
3864
  }
3069
3865
 
3070
3866
  // ------------------------------------------------------------------ attach
@@ -3080,26 +3876,34 @@ async function resolveLocalWorkspace(board, task) {
3080
3876
  * launched), and the dispatcher's next tick verifies the session from the fleet reading it already takes and binds
3081
3877
  * it (`completeAdoptions`). The verb does no process I/O, so it answers as fast as any board write.
3082
3878
  */
3083
- export function requestAdoption(board, id, who, { workspace = null, assignee = null, role = null } = {}) {
3084
- if (!who) throw new Error('name the session to adopt');
3085
- if (boardCopyOf(board)) throw copyRefusal(board, 'adopts no session');
3086
- tx(board, db => {
3087
- if (!getTask(db, id)) throw new Error(`no card ${id}`);
3088
- // a session named by its address or id is checked now; one named otherwise is checked when it is bound
3089
- const named = /^sc:[^:]+:[^:]+:(.+)$/.exec(String(who))?.[1] ?? (/^[0-9a-f-]{8,}$/i.test(String(who)) ? String(who) : null);
3090
- const why = coderAsReviewer(db, board, id, { sessionId: named, assignee, role });
3091
- if (why) throw new Error(coderAsReviewerRefusal(id, statusOf(db, getTask(db, id)), why));
3092
- event(db, id, 'dispatch_held', { session: who, reason: 'adoption requested', workspace, assignee, role });
3093
- });
3879
+ export async function requestAdoption(board, id, who, { workspace = null, assignee = null, role = null } = {}) {
3880
+ try {
3881
+ if (!who) throw requestDetail('name the session to adopt');
3882
+ if (boardCopyOf(board)) throw requestDetail(copyRefusal(board, 'adopts no session').message);
3883
+ tx(board, db => {
3884
+ const task = getTask(db, id);
3885
+ if (!task) throw requestDetail(`no card ${id}`);
3886
+ if (task.backlog) throw requestDetail(`${id} is in backlog; plan it before adopting a session`);
3887
+ // A session named by its address or id is checked now; one named otherwise is checked when it is bound.
3888
+ const named = /^sc:[^:]+:[^:]+:(.+)$/.exec(String(who))?.[1] ?? (/^[0-9a-f-]{8,}$/i.test(String(who)) ? String(who) : null);
3889
+ const why = coderAsReviewer(db, board, id, { sessionId: named, assignee, role });
3890
+ if (why) throw ownerRule(coderAsReviewerRefusal(id, statusOf(db, task), why));
3891
+ event(db, id, 'dispatch_held', { session: who, reason: 'adoption requested', workspace, assignee, role });
3892
+ });
3893
+ return { requested: who, held: true };
3894
+ } catch (error) {
3895
+ if (!error.ownerRule && !error.boardDetail) throw error;
3896
+ return (error.ownerRule ? routeRequest : acceptRequest)(board, { id, name: 'adopt', payload: { session: who ?? '', workspace, assignee, role }, reason: error });
3897
+ }
3094
3898
  }
3095
3899
 
3096
3900
  /**
3097
3901
  * Why adopting a session onto card `id` would make the card's own coder its reviewer, or null. A session adopted onto a
3098
3902
  * card whose run is its reviewer's (a card in review) is bound as that reviewer, and a reviewer is never the card's own
3099
- * coder (ADR 0001 rule 8; ADR 0019: an implementer adopted as reviewer is refused). Its coder is a worker-slot session of
3903
+ * coder (ADR 0001 rule 8; ADR 0019). Its coder is a worker-slot session of
3100
3904
  * any of its runs or one of its side sessions (`sessionId`), a profile any of its worker runs ran as or its implementer
3101
- * (`assignee`), or a request for another role (`role`). The adoption is refused, never turned into a return: taking a
3102
- * card out of review is the manager's own act (`return-review`), recorded with its reason.
3905
+ * (`assignee`), or a request for another role (`role`). An adoption that needs this decision is handed to the manager;
3906
+ * it never returns the card from review implicitly. The manager's `return-review` records that decision and reason.
3103
3907
  */
3104
3908
  function coderAsReviewer(db, board, id, { sessionId = null, assignee = null, role = null, assigneeBefore = undefined } = {}) {
3105
3909
  const card = getTask(db, id);
@@ -3108,7 +3912,7 @@ function coderAsReviewer(db, board, id, { sessionId = null, assignee = null, rol
3108
3912
  const held = assigneeBefore === undefined ? card.assignee : assigneeBefore;
3109
3913
  const entry = workflowFor(board.root);
3110
3914
  const bound = currentRun(db, id)?.metadata?.supercode;
3111
- const slot = bound?.slot ?? String(templateValue(runSpec(entry, statusOf(db, card), card)?.session ?? 'worker', scopeOf(db, board, card, { params: entry.params })));
3915
+ const slot = bound?.slot ?? String(templateValue(runSpec(entry, statusOf(db, card), card)?.session ?? 'worker', scopeOf(db, board, card, { params: entry.params, lazy: true })));
3112
3916
  if (slot !== 'reviewer') return null;
3113
3917
  if (role && role !== 'reviewer') return `the adoption asks for its ${role} role`;
3114
3918
  if (sessionId && ownCoder(db, id, sessionId)) return `${sessionId} is its own coder (a worker or side session of ${id})`;
@@ -3132,8 +3936,8 @@ const ADOPTION_LISTING_WINDOW_S = 180;
3132
3936
  * The events that answer an adoption request, or hold it: a request is open while it is the card's latest of these, and
3133
3937
  * a round that acts on it first records `adoption_claimed` (the request, its claim lock and when the claim expires).
3134
3938
  */
3135
- const ADOPTION_ANSWERS = "('dispatch_held','dispatch_released','session_adopted','adoption_failed','adoption_claimed')";
3136
- const latestAnswer = (db, id) => db.prepare(`SELECT id, kind, payload FROM task_events WHERE task_id = ? AND kind IN ${ADOPTION_ANSWERS} ORDER BY id DESC LIMIT 1`).get(id) ?? null;
3939
+ const ADOPTION_ANSWERS = ['dispatch_held', 'dispatch_released', 'session_adopted', 'adoption_failed', 'adoption_claimed'];
3940
+ const latestAnswer = (db, id) => latestEvent(db, id, ADOPTION_ANSWERS, 'id,kind,payload') ?? null;
3137
3941
 
3138
3942
  /**
3139
3943
  * Adoptions the manager requested and no tick has answered yet, completed from this tick's fleet reading. A round claims
@@ -3146,7 +3950,7 @@ export async function completeAdoptions(board, fleet, settings, report) {
3146
3950
  FROM (SELECT DISTINCT task_id FROM task_events) cards CROSS JOIN task_events e ON e.task_id=cards.task_id
3147
3951
  WHERE e.kind IN ('dispatch_held','adoption_claimed')
3148
3952
  AND ((e.kind = 'dispatch_held' AND json_extract(e.payload, '$.reason') = 'adoption requested') OR e.kind = 'adoption_claimed')
3149
- AND e.id = (SELECT MAX(id) FROM task_events l WHERE l.task_id = e.task_id AND l.kind IN ${ADOPTION_ANSWERS})`).all());
3953
+ AND e.id = ${latestEventIdSql('e.task_id', ADOPTION_ANSWERS)}`).all());
3150
3954
  for (const row of pending) {
3151
3955
  // a request another round has claimed is that round's while its claim holds; a claim whose round died is open again
3152
3956
  let request = row;
@@ -3201,7 +4005,7 @@ export async function completeAdoptions(board, fleet, settings, report) {
3201
4005
  */
3202
4006
  const ADOPTION_HOLDS = new Set(['adoption requested', 'awaiting adoption']);
3203
4007
  export function restoreHoldBefore(db, id, request) {
3204
- const before = db.prepare("SELECT kind, payload FROM task_events WHERE task_id = ? AND id < ? AND kind IN ('dispatch_held','dispatch_released','session_adopted') ORDER BY id DESC LIMIT 1").get(id, request);
4008
+ const before = latestEvent(db, id, ['dispatch_held', 'dispatch_released', 'session_adopted'], 'kind,payload', 'id < ?', [request]);
3205
4009
  const held = before?.kind === 'dispatch_held' ? json(before.payload, {}) ?? {} : null;
3206
4010
  if (held && !ADOPTION_HOLDS.has(held.reason)) { const { by: _by, ...kept } = held; event(db, id, 'dispatch_held', { ...kept, restored_after: request }); }
3207
4011
  else event(db, id, 'dispatch_released', { reason: 'adoption failed', request });
@@ -3222,8 +4026,14 @@ async function nativeListing(who) {
3222
4026
  if (!m || !HARNESSES[m[2]]) return null;
3223
4027
  const [, host, harness, session_id] = m;
3224
4028
  // a discovery its machine did not answer is unknown, not absent: it is thrown (unread), never taken as no listing
3225
- try { return nativeReading(await readNative(host, harness, session_id), { session_id, harness }).alive === true ? { address: who, machine: normalMachine(host), harness, session_id, pane: null, name: null } : null; }
3226
- catch (error) { if (error.launchDeferred) throw error; return null; }
4029
+ try {
4030
+ const rows = (await readNative(host, harness, session_id)).filter(row => row.locator?.harness === harness && row.locator.session_id === session_id);
4031
+ const stores = new Set(rows.map(row => JSON.stringify(locatorHome(row.locator)?.homes ?? null)));
4032
+ if (stores.size > 1) throw Object.assign(new Error(`${who} exists in multiple native homes; an address alone cannot select its conversation`), { ambiguous: true });
4033
+ const reading = nativeReading(rows, { session_id, harness });
4034
+ return reading.alive === true ? { address: who, machine: normalMachine(host), harness, session_id, pane: null, name: null, locator: reading.row.locator } : null;
4035
+ }
4036
+ catch (error) { if (error.launchDeferred || error.ambiguous) throw error; return null; }
3227
4037
  }
3228
4038
 
3229
4039
  /** The verb an adoption with no current run fires: the workflow's own `adopt` where it declares one, else its dispatch start. */
@@ -3236,7 +4046,9 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
3236
4046
  // A session no current machine lists may be one of a machine whose record is not current: the one its address names,
3237
4047
  // or any, for a bare name. Its native listing is not taken there (it knows no pane, and the session may run in one).
3238
4048
  const named = /^sc:([^:]+):/.exec(String(who))?.[1], machines = named ? [normalMachine(named)].filter((machine) => fleet.unknown?.has(machine)) : [...(fleet.unknown ?? [])];
3239
- const found = fleet.sessions.find((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who) ?? (named && machines.length ? null : await nativeListing(who));
4049
+ const matches = fleet.sessions.filter((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who);
4050
+ if (matches.length > 1) throw new Error(`${who} matches ${matches.length} live sessions or panes (${matches.map((s) => s.address).join(', ')}); select one by its address`);
4051
+ const found = matches[0] ?? (named && machines.length ? null : await nativeListing(who));
3240
4052
  if (!found) {
3241
4053
  // said, and waited on as an unlisted session is, within the listing window
3242
4054
  const unread = machines.map((machine) => `machine ${machine}'s record is not current: ${fleet.errors?.get(machine) ?? 'no current record'}`).join('; ');
@@ -3244,43 +4056,50 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
3244
4056
  }
3245
4057
  const [, host, harness, sessionId] = found.address.split(':');
3246
4058
  if (!HARNESSES[harness]) throw new Error(`${found.address} is ${harness}; the board keeps and resumes ${Object.keys(HARNESSES).join(' and ')} sessions`);
3247
- const native = found.pane ? null : nativeReading(await readNative(host, harness, sessionId), { session_id: sessionId, harness });
4059
+ const home = locatorHome(found.locator);
4060
+ if (!home) throw unavailable(`native home is unknown for ${found.address}; adoption did not bind an unspecified store`);
4061
+ const native = found.pane ? null : nativeReading(await readNative(host, harness, sessionId, home?.homes), { session_id: sessionId, harness });
3248
4062
  if (found.pane ? !await panePresent(host, found.pane) : native.alive !== true) throw new Error(`${found.address} has no verified live native process or pane`);
3249
4063
  const beforeRun = view(board, (db) => currentRun(db, id));
3250
4064
  const beforeCustody = view(board, db => JSON.stringify(detachedCard(db, id)));
3251
4065
  const beforeSession = beforeRun?.metadata?.supercode;
3252
4066
  const entry0 = workflowFor(board.root), card0 = view(board, (db) => getTask(db, id));
3253
4067
  const adoptionSlot = beforeSession?.slot ?? view(board, db => {
3254
- const scope = scopeOf(db, board, card0, { params: entry0.params });
4068
+ const scope = scopeOf(db, board, card0, { params: entry0.params, lazy: true });
3255
4069
  return String(templateValue(runSpec(entry0, statusOf(db, card0), card0)?.session ?? 'worker', scope));
3256
4070
  });
3257
4071
  const predecessor = view(board, db => lastSession(db, id, adoptionSlot));
3258
- const sameIdentity = session => addressOf(session) === found.address;
4072
+ await readNativeHome(beforeSession);
4073
+ await readNativeHome(predecessor);
4074
+ const incoming = { ...found, native_home: home };
4075
+ const sameIdentity = session => sameNativeSession(session, incoming);
3259
4076
  // A deferred run has issued no different identity. Bind its own slot's live predecessor, or the session
3260
4077
  // discovered in its keyed pane, without ending the run or opening another process.
3261
4078
  const unissued = !beforeSession?.session_id && !beforeSession?.pane && !beforeSession?.launch_key && !beforeSession?.pid && !beforeSession?.launch_pending;
3262
4079
  const keyedPane = !beforeSession?.session_id && found.pane && beforeSession?.pane === found.pane
3263
- && beforeSession?.machine === host && beforeSession?.harness === harness;
4080
+ && beforeSession?.machine === host && beforeSession?.harness === harness
4081
+ && homeIdentity(beforeSession) === homeIdentity(incoming);
3264
4082
  if (beforeRun && !sameIdentity(beforeSession) && !keyedPane && !(unissued && sameIdentity(predecessor))) throw new Error(`${id} has another active run/session; end it before adopting a different identity`);
3265
4083
  if (beforeSession?.pane && beforeSession.pane !== found.pane && await panePresent(beforeSession.machine, beforeSession.pane)) throw new Error(`${id} still has live pane ${beforeSession.pane}; close the extra pane before rebinding ${sessionId}`);
3266
4084
 
3267
4085
  // the session's own working directory, as discovery records it (where `--resume` finds it), else its pane's
3268
4086
  let dir = workspace ?? native?.row?.cwd;
3269
4087
  if (!dir) {
3270
- dir = (await readNative(host, harness, sessionId)).find((x) => x.locator?.session_id === sessionId)?.cwd ?? null;
4088
+ dir = (await readNative(host, harness, sessionId, home?.homes, { activity: false })).find((x) => x.locator?.session_id === sessionId)?.cwd ?? null;
3271
4089
  }
3272
4090
  if (!dir && found.pane) {
3273
- const r = await runtimeDoor(normalMachine(host)===localMachine()?null:host,'harness.v1.panes.list',{}, { timeout: 60_000 });
3274
- dir = (json(r.stdout.trim(), []) ?? []).find((p) => p.pane === found.pane)?.cwd ?? null;
4091
+ dir = ((await paneRows(host)).list ?? []).find((p) => p.pane === found.pane)?.cwd ?? null;
3275
4092
  }
3276
4093
  // Agents are started, not attached (owner, VO-3751). Adoption records card work only: the session
3277
4094
  // keeps its original identity, profile, hooks, MCP and folder, including when another card's agent owns it.
3278
4095
  // Claim the request before reading its slot again, so another round cannot bind the same request meanwhile.
3279
4096
  const stale = (db) => {
4097
+ if (getTask(db, id)?.backlog) return new Error(`${id} moved to backlog during adoption; plan it before binding a session`);
3280
4098
  if (request !== null && !adoptionOpenTo(db, id, request, open)) return Object.assign(new Error(`${id}'s adoption request was answered or claimed meanwhile`), { superseded: true });
3281
4099
  if ((getTask(db, id)?.current_run_id ?? null) !== (beforeRun?.id ?? null)) return new Error(`${id} changed runs during adoption; retry against its current run`);
3282
4100
  const binding = currentRun(db, id)?.metadata?.supercode;
3283
- if (['session_id', 'pane', 'launch_key', 'launch_pending', 'launch_deferred'].some(key => (binding?.[key] ?? null) !== (beforeSession?.[key] ?? null))) return new Error(`${id}'s session binding changed during adoption; retry against its current session`);
4101
+ if (['session_id', 'pane', 'launch_key', 'launch_pending', 'launch_deferred'].some(key => (binding?.[key] ?? null) !== (beforeSession?.[key] ?? null))
4102
+ || homeIdentity(binding) !== homeIdentity(beforeSession)) return new Error(`${id}'s session binding changed during adoption; retry against its current session`);
3284
4103
  if (JSON.stringify(detachedCard(db, id)) !== beforeCustody) return new Error(`${id} changed session custody during adoption; request adoption again`);
3285
4104
  return null;
3286
4105
  };
@@ -3305,13 +4124,11 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
3305
4124
  const was = lastSession(db, id, adoptionSlot);
3306
4125
  // a session whose close the board confirmed is not live, whatever a fleet reading still lists (a released one was
3307
4126
  // left running, and is read)
3308
- if (was && !sameIdentity(was) && !(was.close_confirmed_at && !was.released) && sessionAlive(fleet, was)) throw new Error(`${id} already has a live session, ${was.address}`);
4127
+ if (was && !sameIdentity(was) && !(was.close_confirmed_at && !was.released) && sessionAlive(fleet, was) !== false)
4128
+ throw new Error(`${id} already has a live or unread session, ${was.address}`);
3309
4129
  if (!task.machine) db.prepare('INSERT INTO supercode_cards (task_id, machine) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET machine = excluded.machine').run(id, host);
3310
4130
  // the lane that resumes it if it is lost, set in the same transaction (assigning first would let a tick start another)
3311
4131
  if (assignee && assignee !== task.assignee) { db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(assignee, id); event(db, id, 'assigned', { assignee }); }
3312
- if (dir && !task.workspace_anchor && !(task.workspace_kind === 'dir' && task.workspace_path)) {
3313
- db.prepare("UPDATE tasks SET workspace_kind = 'dir', workspace_path = ? WHERE id = ?").run(dir.replace(/^dir:/, ''), id);
3314
- }
3315
4132
  const entry = workflowFor(board.root);
3316
4133
  takeSessionCustody(db, id, found.address);
3317
4134
  event(db, id, 'dispatch_released', { reason: 'verified adoption', session: found.address });
@@ -3332,9 +4149,13 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
3332
4149
  const why = coderAsReviewer(db, board, id, { sessionId, assignee, role, assigneeBefore: task.assignee ?? null });
3333
4150
  if (why) throw new Error(coderAsReviewerRefusal(id, statusOf(db, task), why));
3334
4151
  }
3335
- const session = { surface: found.pane ? 'pane' : 'native', session_id: sessionId, harness, machine: host, pane: found.pane ?? null, address: found.address, mode: 'adopt', launched_at: now(), seen_at: now() };
4152
+ if (runRole !== 'reviewer' && dir && !task.workspace_anchor && !(task.workspace_kind === 'dir' && task.workspace_path)) {
4153
+ db.prepare("UPDATE tasks SET workspace_kind = 'dir', workspace_path = ? WHERE id = ?").run(dir.replace(/^dir:/, ''), id);
4154
+ }
4155
+ const session = { surface: found.pane ? 'pane' : 'native', session_id: sessionId, harness, machine: host, pane: found.pane ?? null, address: found.address, mode: 'adopt', launched_at: now(), seen_at: now(), native_home: home, cwd: dir?.replace(/^dir:/, '') ?? null };
3336
4156
  // a deferred launch was never issued: the adopted session is the run's, and no later round launches another
3337
4157
  const owned = [beforeSession, was].find(s => s && s.pane === session.pane && (sameIdentity(s) || s === beforeSession && keyedPane));
4158
+ session.native_home ??= nativeHome(owned);
3338
4159
  setRunSession(db, runId, { ...(currentRun(db, id)?.metadata?.supercode ?? {}), ...session,
3339
4160
  // A deferred or rebound run may retain launch intent. It must not make the delivery pass bind this
3340
4161
  // attached session to that intent's agent; the session's existing ownership stays with its starter.
@@ -3366,21 +4187,22 @@ async function reconnectContinuedSessions(board, fleet, entry, report) {
3366
4187
  const saved = json(row.metadata,{})?.supercode;
3367
4188
  if (!saved?.session_id || saved.forgotten || saved.close_transferred_to || view(board, db => detachedCard(db, row.task_id) || detachedSession(db, saved))) continue;
3368
4189
  if (!saved.monitor_held && !['crashed','closed','reclaimed','failed'].includes(row.outcome)) continue;
3369
- const live = fleet.sessions?.find(s => s.address === addressOf(saved) && s.state === 'running' && s.pane);
4190
+ await readNativeHome(saved);
4191
+ const candidates = fleetSessionRows(fleet, saved).filter(s => s.state === 'running' && s.pane);
4192
+ const live = new Set(candidates.map(s => s.pane)).size === 1 ? candidates[0] : null;
3370
4193
  if (!live) continue;
3371
4194
  if (!panes.has(saved.machine)) {
3372
- const answer = await doorOn(saved.machine,'harness.v1.panes.list',{}, {timeout:60000});
3373
- const list = answer.code === 0 ? json(answer.stdout,null) : null;
3374
- panes.set(saved.machine,Array.isArray(list) ? new Set(list.filter(p=>!p.dead).map(p=>p.pane)) : null);
4195
+ const { list } = await paneRows(saved.machine);
4196
+ panes.set(saved.machine,list ? new Set(list.filter(p=>!p.dead).map(p=>p.pane)) : null);
3375
4197
  }
3376
4198
  const pane = live.pane;
3377
4199
  if (!pane || !panes.get(saved.machine)?.has(pane)) continue;
3378
4200
  // A surviving timed-out process is not a manual restart. It needs a new pane.
3379
4201
  if (pane === saved.pane && !saved.closed_at && !['crashed','closed','reclaimed'].includes(row.outcome)) continue;
3380
4202
  tx(board, db => {
3381
- const task = getTask(db,row.task_id), scope = scopeOf(db,board,task,{params:entry.params});
4203
+ const task = getTask(db,row.task_id), scope = scopeOf(db,board,task,{params:entry.params,lazy:true});
3382
4204
  const automaticPause = scope.card.paused?.reason?.startsWith('its session ended cleanly (');
3383
- if (task.detached || detachedSession(db, saved) || task.current_run_id || (scope.card.paused && !automaticPause) || scope.card.blocked_by.length || scope.card.archived || statusOf(db,task) !== saved.status) return;
4205
+ if (task.backlog || task.detached || detachedSession(db, saved) || task.current_run_id || (scope.card.paused && !automaticPause) || scope.card.blocked_by.length || scope.card.archived || statusOf(db,task) !== saved.status) return;
3384
4206
  if (automaticPause) db.prepare('UPDATE supercode_cards SET paused=NULL WHERE task_id=?').run(row.task_id);
3385
4207
  const session = { ...saved,pane,mode:'manual-continuation',state:entry.workflow.run?.initial ?? 'active',monitor_held:false,
3386
4208
  prior_intervals:[...(saved.prior_intervals??[]),{ended_at:row.ended_at,outcome:row.outcome,error:row.error}],
@@ -3408,31 +4230,48 @@ async function reconnectContinuedSessions(board, fleet, entry, report) {
3408
4230
  * writes it through the mailbox's filing door, idempotent by the message id `key` fixes. Nothing here waits on the
3409
4231
  * event loop. The mailbox delivers it: the machine daemon's watch loop hands it to its session by the session's own
3410
4232
  * door, and carries mail for another machine there through Teams. `wake: false` leaves it for the session's next turn.
3411
- * A question the board waits on (`ask`) is answered by a reply to the board's address; a notice is not. Throws only
3412
- * when the filing failed.
4233
+ * `as` says what it is: a `notice` (the default) is filed as the mailbox's notice, which its receiver reads as no
4234
+ * instruction and answers nowhere; a `question` the board waits on, and a run's `instruction` (an adopted session's run
4235
+ * input or brief, the text a launched or resumed session takes as its first prompt, carried as it is), are ordinary mail
4236
+ * a session can reply to; the board's mailbox takes a reply and marks it read (readMailbox).
4237
+ * Throws only when the filing failed.
3413
4238
  */
3414
- async function fileMail(board, to, body, key, { ask = false, wake = true, subject, eligible = null } = {}) {
4239
+ async function fileMail(board, to, body, key, { as = 'notice', wake = true, subject, eligible = null } = {}) {
3415
4240
  if (boardCopyOf(board)) throw copyRefusal(board, 'files no mail for sessions or managers');
4241
+ // Every filing is recorded with where its time went (ADR 0066's delivery latency): its custody transactions around
4242
+ // the door, the door's round trip, and the daemon's own phases (its answer's `timing`).
4243
+ const began = performance.now();
4244
+ let doorMs = null, daemon = null;
3416
4245
  const perform = async () => {
3417
4246
  if (eligible && !eligible()) return { filed: false, new: false, skipped: true };
3418
4247
  const request = {
3419
- from: boardAddress(board.label), from_name: `${board.label} board`, to, id: key, wake, notice: !ask, subject: String(subject ?? String(body).split('\n')[0]).slice(0, 160),
3420
- body: ask ? `[A question from the ${board.label} board] ${body}` : `[Board notice from the ${board.label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)`,
4248
+ from: boardAddress(board.label), from_name: `${board.label} board`, to, id: key, wake, notice: as === 'notice', subject: String(subject ?? String(body).split('\n')[0]).slice(0, 160),
4249
+ body: as === 'instruction' ? String(body) : as === 'question' ? `[A question from the ${board.label} board] ${body}` : `[Board notice from the ${board.label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)`,
3421
4250
  // the board's own Teams context (the call it files in): the sender is observed in that team, never the daemon's
3422
4251
  context: current().facts.SUPERCODE_CONTEXT ?? null,
3423
4252
  };
3424
- const r = await runtimeDoor(null, 'harness.v1.mail.file', request, { timeout: 30_000 });
4253
+ const doorBegan = performance.now();
4254
+ const r = await runtimeDoor(null, 'harness.v1.mail.file', { ...request, sent_at_ms: Date.now() }, { timeout: 30_000 });
4255
+ doorMs = performance.now() - doorBegan;
3425
4256
  const answer = r.code === 0 ? lastJson(r.stdout) : null;
4257
+ daemon = answer?.timing ?? null;
3426
4258
  if (answer?.code !== 0) throw new Error((answer?.text || (lastJson(r.stderr)?.message ?? r.stderr) || 'no answer from the mailbox\'s filing door').trim().slice(-300));
3427
4259
  return { filed: true, message_id: answer.message_id, new: answer.new === true };
3428
4260
  };
3429
4261
  // Dispatcher mail reserves this exact identity before entering the mailbox door.
3430
4262
  if (!board.db) throw new Error('mail filing requires the board custody store');
4263
+ let outcome = 'ok';
3431
4264
  try { return await sessionOperation(board, { address: to, kind: 'mail filing' }, perform); }
3432
4265
  catch (error) {
4266
+ outcome = error.sessionDetached ? 'detached' : 'failed';
3433
4267
  if (!error.sessionDetached) throw error;
3434
4268
  return { filed: false, new: false, detached: true };
3435
4269
  }
4270
+ finally {
4271
+ const ms = performance.now() - began;
4272
+ recordSlow({ kind: 'call', name: 'board mail.file', ms, outcome, includeFast: true, extra: { as,
4273
+ door_ms: doorMs === null ? null : Math.round(doorMs), custody_ms: doorMs === null ? null : Math.round(ms - doorMs), daemon } });
4274
+ }
3436
4275
  }
3437
4276
 
3438
4277
  /** Fleet listener mail uses the owner's ordinary filing door, including board-copy refusal and sender custody. */
@@ -3475,8 +4314,7 @@ export async function fileBoardMail(board, report, taskId = null) {
3475
4314
  // upgrade or a long stop): it is passed over, recorded once, never filed
3476
4315
  const maxAge = Number(workflowFor(board.root)?.params?.notice_max_age_seconds) || null;
3477
4316
  if (maxAge) tx(board, db => {
3478
- for (const row of db.prepare(`SELECT id, task_id FROM task_events original WHERE kind='board_message' AND created_at < ?
3479
- AND NOT EXISTS (SELECT 1 FROM task_events done WHERE done.kind IN ('board_message_sent', 'board_message_passed') AND done.task_id=original.task_id AND json_extract(done.payload,'$.event')=original.id)`).all(now() - maxAge))
4317
+ for (const row of pendingRequestRows(db).pending.filter(row => row.created_at < now() - maxAge))
3480
4318
  event(db, row.task_id, 'board_message_passed', { event: row.id, reason: `older than notice_max_age_seconds (${maxAge})` });
3481
4319
  });
3482
4320
  const planned = tx(board, db => pendingRequestMail(db, taskId));
@@ -3488,13 +4326,13 @@ export async function fileBoardMail(board, report, taskId = null) {
3488
4326
  const task = view(board, db => getTask(db, first.task_id));
3489
4327
  const several = mail.requester && !mail.message_id && new Set(mail.members.map((m) => m.task_id)).size > 1;
3490
4328
  const subject = numbered(boardKey(board.root, board.slug), several ? null : task?.number, mail.requester ? (mail.message_id ? `State requests · ${mail.members.length} request(s) · ${task?.title || first.task_id}` : `${mail.title ?? mail.requester} · ${mail.members.length} card(s)`) : `${task?.title || first.task_id} · ${mail.message_id ? 'Question' : 'Update'}`, several ? null : noticeName(task));
3491
- const filed = await fileMail(board, mail.to, mail.text, mail.message_id ?? `board-message-${first.id}`, { ask: Boolean(mail.message_id), subject });
4329
+ const filed = await fileMail(board, mail.to, mail.text, mail.message_id ?? `board-message-${first.id}`, { as: mail.message_id ? 'question' : 'notice', subject });
3492
4330
  tx(board, db => {
3493
4331
  if (filed.detached) {
3494
4332
  for (const member of mail.members) event(db, member.task_id, 'board_message_passed', { event: member.id, to: mail.to, reason: 'session detached to a person' });
3495
4333
  return;
3496
4334
  }
3497
- for (const member of mail.members) if (!db.prepare("SELECT 1 FROM task_events WHERE kind='board_message_sent' AND task_id=? AND json_extract(payload,'$.event')=?").get(member.task_id, member.id)) event(db, member.task_id, 'board_message_sent', { event: member.id, to: mail.to, message_id: filed.message_id, ...(mail.message_id ? { request_id: member.message_id ?? mail.message_id } : {}) });
4335
+ for (const member of mail.members) if (!db.prepare("SELECT 1 FROM task_events WHERE kind IN ('board_message_sent','board_message_passed') AND task_id=? AND json_extract(CASE WHEN json_valid(payload) THEN payload END,'$.event')=?").get(member.task_id, member.id)) event(db, member.task_id, 'board_message_sent', { event: member.id, to: mail.to, message_id: filed.message_id, ...(mail.message_id ? { request_id: member.message_id ?? mail.message_id } : {}) });
3498
4336
  });
3499
4337
  if (filed.filed) (report.mailed ??= []).push([first.task_id, mail.to]);
3500
4338
  } catch (error) {
@@ -3506,14 +4344,12 @@ export async function fileBoardMail(board, report, taskId = null) {
3506
4344
  /** The board's own address: where its notices come from and where card updates are mailed. */
3507
4345
  export const boardAddress = (label) => `sc:${localMachine()}:board:${boardName(label)}`;
3508
4346
 
3509
- /** Consecutive failed sends after which a chat-platform subscription is dropped (Hermes's notifier drops a dead chat after 12). */
3510
- const MAX_SEND_FAILURES = 12;
3511
-
3512
4347
  /**
3513
4348
  * What an audience is told of an event: the workflow's `notify[audience][kind]` template over the card, the event's
3514
4349
  * payload and (for a comment) the comment; null when the workflow names no such notice.
3515
4350
  */
3516
4351
  export function noticeOf(entry, audience, kind, task, payload, comment = null, recipient = null) {
4352
+ if (kind === 'session_stalled' && payload?.turn_readable === false) return null;
3517
4353
  const template = entry.workflow.notify?.[audience]?.[kind];
3518
4354
  if (template === undefined || template === null) return null;
3519
4355
  const cut = (text, n) => String(text ?? '').slice(0, Number(n));
@@ -3528,36 +4364,59 @@ export function noticeOf(entry, audience, kind, task, payload, comment = null, r
3528
4364
  * launch or a missed fleet read lost comes back), and each of the workflow's managers (`params.managers`) hears every
3529
4365
  * open arc (not its subtasks) as the `manager` audience, whoever filed it, when the workflow's notify names one.
3530
4366
  */
3531
- function ensureSubscriptions(board, entry) {
4367
+ const inheritedSubtaskFollower = `platform = 'supercode' AND thread_id = ''
4368
+ AND task_id IN (SELECT task_id FROM supercode_cards WHERE subtask_of IS NOT NULL)
4369
+ AND ((json_extract(delivery_metadata, '$.role') = 'manager' AND json_extract(delivery_metadata, '$.own') IS NULL)
4370
+ OR (COALESCE(json_extract(delivery_metadata, '$.role'), 'creator') = 'creator'
4371
+ AND json_extract(delivery_metadata, '$.own') IS NULL
4372
+ AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.id = kanban_notify_subs.task_id AND t.created_by IN (kanban_notify_subs.chat_id, 'session:' || kanban_notify_subs.chat_id))
4373
+ AND NOT EXISTS (SELECT 1 FROM task_events e WHERE e.task_id = kanban_notify_subs.task_id AND e.kind = 'created'
4374
+ AND json_extract(e.payload, '$.creator') IN (kanban_notify_subs.chat_id, 'session:' || kanban_notify_subs.chat_id))
4375
+ AND EXISTS (SELECT 1 FROM kanban_notify_subs arc JOIN supercode_cards c ON c.subtask_of = arc.task_id
4376
+ WHERE c.task_id = kanban_notify_subs.task_id AND arc.chat_id = kanban_notify_subs.chat_id AND arc.platform = 'supercode')))`;
4377
+ async function ensureSubscriptions(board, entry) {
3532
4378
  const managers = Array.isArray(entry.params.managers) ? entry.params.managers.map(String) : [];
3533
4379
  const toManagers = Object.keys(entry.workflow.notify?.manager ?? {}).length > 0;
3534
- tx(board, (db) => {
3535
- reconcileDependencySubscriptions(db);
3536
- // A subtask's watchers are its own (its creator, its worker, whoever comments on it or is mentioned there), as an
3537
- // issue tracker's sub-task's are: the manager follows arcs, and an arc's follower is not copied onto its subtasks.
3538
- // Such copies made before are removed here.
3539
- db.prepare(`DELETE FROM kanban_notify_subs WHERE platform = 'supercode' AND thread_id = ''
3540
- AND task_id IN (SELECT task_id FROM supercode_cards WHERE subtask_of IS NOT NULL)
3541
- AND ((json_extract(delivery_metadata, '$.role') = 'manager' AND json_extract(delivery_metadata, '$.own') IS NULL)
3542
- OR (COALESCE(json_extract(delivery_metadata, '$.role'), 'creator') = 'creator'
3543
- AND json_extract(delivery_metadata, '$.own') IS NULL
3544
- AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.id = kanban_notify_subs.task_id AND t.created_by IN (kanban_notify_subs.chat_id, 'session:' || kanban_notify_subs.chat_id))
3545
- AND NOT EXISTS (SELECT 1 FROM task_events e WHERE e.task_id = kanban_notify_subs.task_id AND e.kind = 'created'
3546
- AND json_extract(e.payload, '$.creator') IN (kanban_notify_subs.chat_id, 'session:' || kanban_notify_subs.chat_id))
3547
- AND EXISTS (SELECT 1 FROM kanban_notify_subs arc JOIN supercode_cards c ON c.subtask_of = arc.task_id
3548
- WHERE c.task_id = kanban_notify_subs.task_id AND arc.chat_id = kanban_notify_subs.chat_id AND arc.platform = 'supercode')))`).run();
3549
- for (const t of db.prepare(`SELECT t.id, t.current_run_id, c.subtask_of FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id
3550
- WHERE t.status NOT IN ('done', 'archived')`).all()) {
3551
- if (toManagers && !t.subtask_of) for (const m of managers) subscribeIn(db, t.id, m, 'manager');
3552
- if (!t.current_run_id) continue;
3553
- const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(t.current_run_id)?.metadata, {})?.supercode;
3554
- const address = addressOf(session);
3555
- if (!address || (session.slot ?? 'worker') !== 'worker') continue;
3556
- replaceWorkerSubscriptions(db, t.id, address);
3557
- }
3558
- });
4380
+ const cards = view(board, db => db.prepare(`SELECT DISTINCT task_id AS id FROM kanban_notify_subs
4381
+ WHERE platform='supercode' AND json_extract(delivery_metadata,'$.dependent_card') IS NOT NULL
4382
+ UNION SELECT DISTINCT task_id AS id FROM kanban_notify_subs WHERE ${inheritedSubtaskFollower}
4383
+ UNION SELECT t.id FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id
4384
+ WHERE ? AND t.status NOT IN ('done','archived') AND c.subtask_of IS NULL`).all(toManagers ? 1 : 0));
4385
+ for (const { id } of cards) {
4386
+ await yieldToOwner();
4387
+ if (stopping) return;
4388
+ const needed = view(board, db => {
4389
+ if (db.prepare(`SELECT 1 FROM kanban_notify_subs WHERE task_id=? AND ${inheritedSubtaskFollower} LIMIT 1`).get(id)
4390
+ || reconcileDependencySubscriptions(db, id, { check: true })) return true;
4391
+ const task = toManagers && getTask(db, id);
4392
+ return task && !task.subtask_of && !['done', 'archived'].includes(task.status)
4393
+ && managers.some(address => subscribeIn(db, id, address, 'manager', { check: true }));
4394
+ });
4395
+ if (!needed) continue;
4396
+ tx(board, (db) => {
4397
+ reconcileDependencySubscriptions(db, id);
4398
+ // A subtask's watchers are its own (its creator, its worker, whoever comments on it or is mentioned there), as an
4399
+ // issue tracker's sub-task's are: the manager follows arcs, and an arc's follower is not copied onto its subtasks.
4400
+ // Such copies made before are removed here.
4401
+ db.prepare(`DELETE FROM kanban_notify_subs WHERE task_id=? AND ${inheritedSubtaskFollower}`).run(id);
4402
+ const task = getTask(db, id);
4403
+ if (toManagers && task && !task.subtask_of && !['done', 'archived'].includes(task.status))
4404
+ for (const m of managers) subscribeIn(db, id, m, 'manager');
4405
+ });
4406
+ }
4407
+ for (const { id, run_id } of view(board, db => db.prepare('SELECT id, current_run_id AS run_id FROM tasks WHERE current_run_id IS NOT NULL').all())) {
4408
+ await yieldToOwner();
4409
+ if (stopping) return;
4410
+ const binding = db => {
4411
+ if (!runCurrent(db, id, run_id)) return null;
4412
+ const session = currentRun(db, id)?.metadata?.supercode;
4413
+ return (session?.slot ?? 'worker') === 'worker' ? addressOf(session) : null;
4414
+ };
4415
+ const needed = view(board, db => { const address = binding(db); return address && replaceWorkerSubscriptions(db, id, address, { check: true }); });
4416
+ if (!needed) continue;
4417
+ tx(board, db => { const address = binding(db); if (address) replaceWorkerSubscriptions(db, id, address); });
4418
+ }
3559
4419
  }
3560
-
3561
4420
  /**
3562
4421
  * Whether the session at `address` is known to have ended: one of card `taskId`'s runs carried it, and no run (of any
3563
4422
  * card) carrying it is unended or keeps it open after its run (retain_after_run, not since closed or handed on). One
@@ -3623,15 +4482,21 @@ async function noticeSessions() {
3623
4482
  /** Retain a subscription's wait across passes and owner restarts; only transitions enter the log report. */
3624
4483
  function noticeWait(board, sub, report, reason = null) {
3625
4484
  if (reason === null && !sub.delivery_metadata?.notice_wait) return;
3626
- const changed = tx(board, db => {
3627
- const where = "WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=?";
3628
- const identity = [sub.task_id, sub.chat_id, sub.thread_id ?? ''];
4485
+ const where = "WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=?";
4486
+ const identity = [sub.task_id, sub.chat_id, sub.thread_id ?? ''];
4487
+ // the retained wait, when this reason would change it (ADR 0247: an unchanged wait opens no transaction)
4488
+ const transition = db => {
3629
4489
  const row = db.prepare(`SELECT delivery_metadata FROM kanban_notify_subs ${where}`).get(...identity);
3630
- if (!row) return false;
4490
+ if (!row) return null;
3631
4491
  const metadata = json(row.delivery_metadata, {}) ?? {};
3632
- if ((metadata.role ?? 'creator') !== (sub.delivery_metadata?.role ?? 'creator')) return false;
4492
+ if ((metadata.role ?? 'creator') !== (sub.delivery_metadata?.role ?? 'creator')) return null;
4493
+ return (metadata.notice_wait?.reason ?? null) === reason ? null : metadata;
4494
+ };
4495
+ if (!view(board, transition)) return;
4496
+ const changed = tx(board, db => {
4497
+ const metadata = transition(db);
4498
+ if (!metadata) return false;
3633
4499
  const previous = metadata.notice_wait;
3634
- if ((previous?.reason ?? null) === reason) return false;
3635
4500
  if (reason === null) delete metadata.notice_wait;
3636
4501
  else metadata.notice_wait = { reason, since: now() };
3637
4502
  db.prepare(`UPDATE kanban_notify_subs SET delivery_metadata=? ${where}`).run(JSON.stringify(metadata), ...identity);
@@ -3705,15 +4570,13 @@ const followerRole = (sub) => ['worker', 'dependency', 'manager', 'participant']
3705
4570
  async function endEndedSubscriptions(board, fleet, report) {
3706
4571
  const followers = "platform = 'supercode' AND json_extract(delivery_metadata, '$.dependent_card') IS NULL AND COALESCE(json_extract(delivery_metadata, '$.role'), 'creator') != 'dependency'";
3707
4572
  // each native subscriber's liveness is read before the write
3708
- const aliveNow = new Map(), goneNow = new Map();
4573
+ const aliveNow = new Map();
3709
4574
  if (fleet.ok) for (const { chat_id: address } of board.db.prepare(`SELECT DISTINCT chat_id FROM kanban_notify_subs WHERE ${followers}`).all()) {
3710
4575
  if (!sessionAt(address)) continue;
3711
4576
  aliveNow.set(address, await subscriberAlive(fleet, address));
3712
- if (aliveNow.get(address) === false && subscriberGone(fleet, address)) goneNow.set(address, now());
3713
4577
  }
3714
- tx(board, (db) => {
3715
- const remove = db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?");
3716
- const ended = new Map(), records = new Map();
4578
+ const pending = db => {
4579
+ const selected = [], records = new Map();
3717
4580
  for (const row of db.prepare(`SELECT task_id, chat_id, thread_id, delivery_metadata FROM kanban_notify_subs WHERE ${followers}`).all()) {
3718
4581
  const address = row.chat_id;
3719
4582
  if (!sessionAt(address)) continue;
@@ -3722,10 +4585,19 @@ async function endEndedSubscriptions(board, fleet, report) {
3722
4585
  const record = latest.get(address);
3723
4586
  // a session alive, or whose liveness could not be read (unknown, never taken as ended), keeps its subscription
3724
4587
  if (record?.session.released || held.has(address) || (fleet.ok && aliveNow.get(address) !== false)) continue;
3725
- // the board closed it, or it is gone (no transcript, process or pane, read whole: subscriberGone)
3726
- const evidence = record?.session.close_confirmed_at ? { by: 'board_close', run: record.run, closed_at: record.session.close_confirmed_at }
3727
- : goneNow.has(address) ? { by: 'gone', run: record?.run ?? null, read_at: goneNow.get(address), machine: sessionAt(address)?.machine ?? null } : null;
3728
- if (!evidence || !remove.run(row.task_id, address, row.thread_id).changes) continue;
4588
+ // Only a confirmed native close ends a retained subscription.
4589
+ const evidence = record?.session.close_confirmed_at ? { by: 'board_close', run: record.run, closed_at: record.session.close_confirmed_at } : null;
4590
+ if (evidence) selected.push({ row, record, evidence });
4591
+ }
4592
+ return selected;
4593
+ };
4594
+ if (!view(board, db => pending(db).length)) return;
4595
+ tx(board, (db) => {
4596
+ const remove = db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?");
4597
+ const ended = new Map();
4598
+ for (const { row, record, evidence } of pending(db)) {
4599
+ const address = row.chat_id;
4600
+ if (!remove.run(row.task_id, address, row.thread_id).changes) continue;
3729
4601
  if (!ended.has(address)) ended.set(address, { record, evidence, cards: [], roles: new Set() });
3730
4602
  ended.get(address).cards.push(row.task_id);
3731
4603
  ended.get(address).roles.add(json(row.delivery_metadata, {})?.role ?? 'creator');
@@ -3753,13 +4625,14 @@ async function foldAgentSessions(board, entry, report) {
3753
4625
  for (const session of agentMains.get(agent)?.sessions ?? []) if (sessionAt(session) && !agentOf.has(session)) agentOf.set(session, agent);
3754
4626
  }
3755
4627
  if (!agentOf.size) return;
4628
+ const sessions = [...agentOf.keys()];
4629
+ const pending = db => db.prepare(`SELECT task_id, chat_id, thread_id, delivery_metadata FROM kanban_notify_subs WHERE platform = 'supercode' AND chat_id IN (${sessions.map(() => '?').join(', ')})`).all(...sessions)
4630
+ .map(row => ({ row, metadata: json(row.delivery_metadata, {}) ?? {} }))
4631
+ .filter(({ metadata }) => !['worker', 'dependency'].includes(metadata.role) && !metadata.dependent_card);
4632
+ if (!view(board, db => pending(db).length)) return;
3756
4633
  tx(board, (db) => {
3757
- const sessions = [...agentOf.keys()];
3758
- const rows = db.prepare(`SELECT task_id, chat_id, thread_id, delivery_metadata FROM kanban_notify_subs WHERE platform = 'supercode' AND chat_id IN (${sessions.map(() => '?').join(', ')})`).all(...sessions);
3759
4634
  const moved = new Map();
3760
- for (const row of rows) {
3761
- const metadata = json(row.delivery_metadata, {}) ?? {};
3762
- if (['worker', 'dependency'].includes(metadata.role) || metadata.dependent_card) continue;
4635
+ for (const { row, metadata } of pending(db)) {
3763
4636
  const agent = agentOf.get(row.chat_id);
3764
4637
  const head = db.prepare('SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?').get(row.task_id).c;
3765
4638
  const { notice_wait: _wait, failures: _failures, ...kept } = metadata;
@@ -3827,7 +4700,7 @@ async function notify(board, fleet, report, entry) {
3827
4700
  const NOTICE_CEILING = Math.max(Number(entry.params.notice_ceiling) || NOTICE_CEILING_DEFAULT, 1);
3828
4701
  const where = "WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?";
3829
4702
  const identityOf = (sub) => [sub.task_id, sub.chat_id, sub.thread_id ?? ''];
3830
- const forward = (db, sub, to) => db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND last_event_id < ?`).run(to, ...identityOf(sub), to);
4703
+ const forward = (db, sub, to) => db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND created_at = ? AND last_event_id < ?`).run(to, ...identityOf(sub), sub.created_at, to);
3831
4704
  const rank = (sub) => NOTICE_RANK.indexOf(followerRole(sub));
3832
4705
  // cursors moved past what was read with nothing to tell, written together after the pass
3833
4706
  const quiet = [];
@@ -3842,8 +4715,6 @@ async function notify(board, fleet, report, entry) {
3842
4715
  for (const sub of subscriptions(board.db).filter((s) => s.platform === 'supercode').sort((a, b) => rank(a) - rank(b))) {
3843
4716
  await yieldToOwner();
3844
4717
  if (stopping) break;
3845
- if (view(board, db => personCustody(db, sub.chat_id))) continue;
3846
- if (sub.delivery_metadata?.dependent_card && sub.delivery_metadata.waiting_recipient) continue;
3847
4718
  // an unsubscribed follower is told nothing until a mention or its own comment brings it back (engine.follow)
3848
4719
  if (sub.delivery_metadata?.unsubscribed) continue;
3849
4720
  const role = followerRole(sub);
@@ -3854,8 +4725,9 @@ async function notify(board, fleet, report, entry) {
3854
4725
  const toldAs = (kind) => roles.find((r) => heardBy(entry, r).includes(kind)) ?? role;
3855
4726
  // the card's last event, read before its news: the cursor moves to it past every event, heard or not
3856
4727
  const head = Number(board.db.prepare('SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?').get(sub.task_id).c);
3857
- if (!kinds.length) { if (head > Number(sub.last_event_id)) quiet.push({ sub, through: head }); noticeWait(board, sub, report); continue; }
3858
- // Read pending news before liveness: a subscription with nothing to file is not waiting on delivery.
4728
+ if (!kinds.length) { if (head > Number(sub.last_event_id)) quiet.push({ sub, through: head }); continue; }
4729
+ // Read pending news before liveness: a subscription with nothing to file is not waiting on delivery, and its
4730
+ // retained wait stays as it was until a filing clears it.
3859
4731
  // the page starts past every event already history (older than liveSeconds), so a long backlog never holds back
3860
4732
  // what is news now
3861
4733
  // A follower's news is history past liveSeconds; a worker's or a dependency's (a verdict it must act on, its run
@@ -3866,7 +4738,10 @@ async function notify(board, fleet, report, entry) {
3866
4738
  const events = board.db.prepare(`SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? AND id > ? AND id <= ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id LIMIT 64`)
3867
4739
  .all(sub.task_id, Math.max(Number(sub.last_event_id), historyThrough), head, ...kinds);
3868
4740
  const through = events.length === 64 ? events[63].id : head;
3869
- if (!events.length) { if (head > Number(sub.last_event_id)) quiet.push({ sub, through: head }); noticeWait(board, sub, report); continue; }
4741
+ if (!events.length) { if (head > Number(sub.last_event_id)) quiet.push({ sub, through: head }); continue; }
4742
+ // The first obstruction this pass reads is the wait it records; the subscription keeps its cursor.
4743
+ if (view(board, db => personCustody(db, sub.chat_id))) { noticeWait(board, sub, report, "its session is in a person's custody; its notices wait"); continue; }
4744
+ if (sub.delivery_metadata?.dependent_card && sub.delivery_metadata.waiting_recipient) { noticeWait(board, sub, report, 'its dependency recipient is not bound; its notices wait'); continue; }
3870
4745
  const record = view(board, db => sessionRecord(db, sub.chat_id));
3871
4746
  // Events name the acting native session; subscriptions may name its agent.
3872
4747
  // Use the daemon's retained binding, including former mains, for both
@@ -3904,7 +4779,7 @@ async function notify(board, fleet, report, entry) {
3904
4779
  if (alive === false) { noticeWait(board, sub, report, 'its session is not running; its notices wait'); continue; }
3905
4780
  // unknown is not absent: its notices wait, and the wait is said, until its liveness can be read
3906
4781
  if (alive === null) { noticeWait(board, sub, report, 'its liveness could not be read; its notices wait until it can be'); continue; }
3907
- } else if (!sessionAt(sub.chat_id) && ended) continue; // native sessions already required a current live observation
4782
+ } else if (!sessionAt(sub.chat_id) && ended) { noticeWait(board, sub, report, 'its session has ended; its notices wait'); continue; } // native sessions already required a current live observation
3908
4783
  // the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's;
3909
4784
  // `person` the person it is assigned to, if any)
3910
4785
  const task = view(board, (db) => { const t = getTask(db, sub.task_id); return t ? { ...t, key: boardKey(board.root, board.slug), status: statusOf(db, t), stored_as: t.status, person: personOf(t.assignee) } : t; });
@@ -3945,7 +4820,7 @@ async function notify(board, fleet, report, entry) {
3945
4820
  }
3946
4821
  read.push({ sub, role, task, kinds });
3947
4822
  // nothing to tell: its cursor moves past what was read
3948
- if (!items.length) { quiet.push({ sub, through }); noticeWait(board, sub, report); continue; }
4823
+ if (!items.length) { quiet.push({ sub, through }); continue; }
3949
4824
  const digest = digests.get(recipient) ?? { recipient, to: sub.chat_id, cards: new Map() };
3950
4825
  // a recipient that follows as its agent is mailed at its agent's address, as it subscribed
3951
4826
  if (agentAt(sub.chat_id) && !agentAt(digest.to)) digest.to = sub.chat_id;
@@ -3964,23 +4839,22 @@ async function notify(board, fleet, report, entry) {
3964
4839
  try {
3965
4840
  const filed = await fileMail(board, digest.to, body, key, { wake, subject,
3966
4841
  eligible: () => view(board, db => subs.some(({ sub, through }) => {
3967
- const row = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where}`).get(...identityOf(sub));
4842
+ const row = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where} AND created_at = ?`).get(...identityOf(sub), sub.created_at);
3968
4843
  return Boolean(row) && Number(row.last_event_id) < through;
3969
4844
  })) });
3970
4845
  tx(board, (db) => {
3971
4846
  for (const { sub, through } of subs) forward(db, sub, through);
3972
- if (filed.filed) {
3973
- state.sent.push(passAt);
3974
- for (const c of cards) state.cards[c.task_id] = passAt;
3975
- db.prepare('INSERT INTO supercode_board_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(state.key, JSON.stringify({ sent: state.sent, cards: state.cards }));
3976
- }
3977
- if (filed.new) for (const i of items) event(db, i.sub.task_id, 'notice_filed', { source_event: i.ev.id, kind: i.ev.kind, address: digest.to, message_id: filed.message_id, wake, ...(items.length > 1 ? { batched: items.length } : {}), ...(kind === 'summary' ? { summary: true } : {}) });
4847
+ state.sent.push(passAt);
4848
+ for (const c of cards) state.cards[c.task_id] = passAt;
4849
+ db.prepare('INSERT INTO supercode_board_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(state.key, JSON.stringify({ sent: state.sent, cards: state.cards }));
4850
+ if (filed.new) for (const i of items) event(db, i.sub.task_id, 'notice_filed', { source_event: i.ev.id, kind: i.ev.kind, address: digest.to, message_id: filed.message_id, wake,
4851
+ ...(items.length > 1 ? { batched: items.length } : {}), ...(kind === 'summary' ? { summary: true } : {}) });
3978
4852
  if (filed.new && kind === 'summary') event(db, cards[0].task_id, 'notice_flood_collapsed', { address: digest.to, recipient: digest.recipient, notices: items.length,
3979
4853
  cards: cards.map((c) => c.task_id).slice(0, 50), card_count: cards.length, ceiling: NOTICE_CEILING, window_seconds: window, message_id: filed.message_id,
3980
4854
  reason: `more than ${NOTICE_CEILING} notice messages in ${window} s for one recipient: the rest are one summary` });
3981
4855
  });
3982
- if (filed.filed) for (const i of items) report.notified.push([i.sub.task_id, i.ev.kind, digest.to]);
3983
- if (filed.filed && kind === 'summary') (report.notices_collapsed ??= []).push([digest.to, items.length, cards.length]);
4856
+ for (const i of items) report.notified.push([i.sub.task_id, i.ev.kind, digest.to]);
4857
+ if (kind === 'summary') (report.notices_collapsed ??= []).push([digest.to, items.length, cards.length]);
3984
4858
  for (const { sub } of subs) noticeWait(board, sub, report);
3985
4859
  } catch (error) {
3986
4860
  for (const { sub } of subs) noticeWait(board, sub, report, String(error.message).slice(-200));
@@ -4028,7 +4902,11 @@ async function notify(board, fleet, report, entry) {
4028
4902
  await file(digest, state, { cards: rest, body, subject: numbered(boardKey(board.root, board.slug), null, `${count} updates on ${rest.length} cards`), kind: 'summary' });
4029
4903
  }
4030
4904
  }
4031
- if (quiet.length) tx(board, (db) => { for (const { sub, through } of quiet) forward(db, sub, through); });
4905
+ const quietPending = (db, { sub, through }) => Boolean(db.prepare(`SELECT 1 FROM kanban_notify_subs ${where} AND created_at = ? AND last_event_id < ?`)
4906
+ .get(...identityOf(sub), sub.created_at, through));
4907
+ if (view(board, db => quiet.some(item => quietPending(db, item)))) tx(board, db => {
4908
+ for (const item of quiet) if (quietPending(db, item)) forward(db, item.sub, item.through);
4909
+ });
4032
4910
  // a subscription past its card's last event ends with the card: a worker's when the card is done or archived (its
4033
4911
  // work is over, and its session may never run again), anyone's when it is archived
4034
4912
  for (const { sub, role, task, kinds } of read) {
@@ -4047,86 +4925,6 @@ async function notify(board, fleet, report, entry) {
4047
4925
  }
4048
4926
  }
4049
4927
 
4050
- // Hermes's terminal kinds for a chat subscriber, in its order of silence: archived and unblocked move the cursor and say nothing
4051
-
4052
- /**
4053
- * A chat-platform subscription's events, sent through the orchestrator's own channel adapters (the daemon hosts them;
4054
- * `serve` hosts none, as a Hermes CLI without its gateway delivers none). Answers what was sent and what failed.
4055
- */
4056
- export async function notifyChats(root, adapters, log = () => {}, wake = null) {
4057
- const sent = [];
4058
- // what a chat is told is the workflow's (notify.chat)
4059
- const entry = await loadWorkflow(root);
4060
- const kinds = heardBy(entry, 'chat');
4061
- if (!kinds.length) return sent;
4062
- for (const slug of listBoards(root)) {
4063
- const board = openBoard(root, slug);
4064
- board.label = boardLabel(root, slug);
4065
- try {
4066
- for (const sub of subscriptions(board.db)) {
4067
- const adapter = adapters?.[sub.platform];
4068
- if (sub.platform === 'supercode' || !adapter) continue;
4069
- 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);
4070
- if (!events.length) continue;
4071
- const task = view(board, (db) => { const t = getTask(db, sub.task_id); return t ? { ...t, key: boardKey(board.root, board.slug) } : t; });
4072
- let cursor = Number(sub.last_event_id);
4073
- let failed = false;
4074
- for (const ev of events) {
4075
- const payload = json(ev.payload, {}) ?? {};
4076
- const text = noticeOf(entry, 'chat', ev.kind, task, payload);
4077
- if (text) {
4078
- // a completed card's deliverables go with it as uploads, as Hermes's notifier sends them
4079
- const files = ev.kind === 'completed' ? artifactsOf(payload, task).map((path) => ({ path })) : [];
4080
- const mode = sub.delivery_mode || 'notify';
4081
- // `wake`/`notify+wake`: the chat's own session gets the event as a turn (Hermes's wake), through the daemon
4082
- if (mode !== 'notify' && wake) {
4083
- try { await wake(sub, `${slug !== 'default' ? `[${slug}] ` : ''}${text}`); } catch (error) { log('warn', `board ${slug}: waking ${sub.platform}:${sub.chat_id} for ${sub.task_id} failed: ${error.message}`); }
4084
- }
4085
- if (mode === 'wake') { sent.push([sub.task_id, ev.kind, `${sub.platform}:${sub.chat_id} (wake)`]); cursor = ev.id; continue; }
4086
- try {
4087
- await adapter.send({ kind: 'explicit', platform: sub.platform, chat_id: sub.chat_id, ...(sub.thread_id ? { thread_id: sub.thread_id } : {}) }, { text: `${slug !== 'default' ? `[${slug}] ` : ''}${text}`, ...(files.length ? { files } : {}) });
4088
- sent.push([sub.task_id, ev.kind, `${sub.platform}:${sub.chat_id}`]);
4089
- } catch (error) {
4090
- failed = true;
4091
- log('warn', `board ${slug}: a notice for ${sub.task_id} to ${sub.platform}:${sub.chat_id} failed: ${error.message}`);
4092
- break;
4093
- }
4094
- }
4095
- cursor = ev.id;
4096
- }
4097
- tx(board, (db) => {
4098
- const where = 'WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?';
4099
- const key = [sub.task_id, sub.platform, sub.chat_id, sub.thread_id ?? ''];
4100
- db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND last_event_id < ?`).run(cursor, ...key, cursor);
4101
- const failures = failed ? Number(sub.delivery_metadata?.failures ?? 0) + 1 : 0;
4102
- if (failures >= MAX_SEND_FAILURES) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...key);
4103
- else if (failures !== Number(sub.delivery_metadata?.failures ?? 0)) db.prepare(`UPDATE kanban_notify_subs SET delivery_metadata = ? ${where}`).run(JSON.stringify({ ...sub.delivery_metadata, failures }), ...key);
4104
- if (task?.status === 'archived' && !failed) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...key);
4105
- });
4106
- }
4107
- } finally {
4108
- board.close();
4109
- }
4110
- }
4111
- return sent;
4112
- }
4113
-
4114
- /** A completed card's artifact files: its completion's `artifacts`, then absolute paths its summary or result names that exist. */
4115
- function artifactsOf(payload, task) {
4116
- const seen = new Set();
4117
- const out = [];
4118
- const add = (p) => {
4119
- const path = String(p ?? '').replace(/^~(?=\/)/, process.env.HOME ?? '~');
4120
- if (!path.startsWith('/') || seen.has(path)) return;
4121
- try { if (!statSync(path).isFile()) return; } catch { return; }
4122
- seen.add(path);
4123
- out.push(path);
4124
- };
4125
- for (const p of Array.isArray(payload.artifacts) ? payload.artifacts : []) add(p);
4126
- for (const text of [payload.summary, task?.result]) for (const m of String(text ?? '').matchAll(/(?:^|\s)(~?\/[^\s'"`]+\.[A-Za-z0-9]{1,8})/g)) add(m[1]);
4127
- return out;
4128
- }
4129
-
4130
4928
  // ------------------------------------------------------------------ hooks
4131
4929
 
4132
4930
  // Hermes's lifecycle hooks, from the board's own events (so a change any writer made fires them, Hermes's CLI's too)
@@ -4369,9 +5167,8 @@ function outcomeTicked(body) {
4369
5167
  /** An opened PR asks for the declared review transition once the arc's tasks are closed. */
4370
5168
  // A task written in the card's own Tasks section that did not become a subtask holds its review too.
4371
5169
  /** The arc's session still in a turn: its review, and the close of that session the request brings (end_run), wait for
4372
- * the turn to end, so work in flight is never cut and the reviewer never starts in the coder's checkout beside a turn
4373
- * still running there (t_f34710bc's coder had a proof take running when its review was requested at 15:01:13Z on
4374
- * 2026-10-04, and the close ended it). A session the reading cannot place waits on nothing.
5170
+ * the turn to end so work in flight is never cut. The reviewer owns a separate checkout (ADR 0262); the transition's
5171
+ * close still must not interrupt the author's turn. A session the reading cannot place waits on nothing.
4375
5172
  *
4376
5173
  * A Codex session with an active thread goal is never read between turns: the goal starts the next turn the moment one
4377
5174
  * ends (t_374d7f70, codex 01a108b7, 2026-10-14). For it the turn boundary is the one its harness records: the open turn
@@ -4384,9 +5181,10 @@ async function turnInFlight(board, id, fleet, heldSince = null) {
4384
5181
  if (!fleet?.ok) return null;
4385
5182
  const session = reviewTurnRun(board.db, id)?.metadata?.supercode;
4386
5183
  if (!session?.session_id || session.surface === 'headless' || session.close_requested) return null;
4387
- let live = fleet.sessions?.find((x) => x.address === addressOf(session)) ?? null;
5184
+ await readNativeHome(session);
5185
+ let live = fleetSessionRow(fleet, session);
4388
5186
  if (live?.turn == null && session.surface === 'native') try {
4389
- const activity = nativeReading(await readNative(session.machine, session.harness ?? 'claude-code', session.session_id, session.codex_home ?? null), session).row?.activity;
5187
+ const activity = nativeReading(await readNative(session.machine, session.harness ?? 'claude-code', session.session_id, nativeHomes(session)), session).row?.activity;
4390
5188
  live = activity ? { turn: activity.turn ?? null, turn_trigger: activity.evidence?.turn_trigger ?? null, turn_since_ms: activity.evidence?.native_state_since_ms ?? null } : live;
4391
5189
  } catch { /* unread: waits on nothing */ }
4392
5190
  const turn = live?.turn ?? null;
@@ -4409,25 +5207,175 @@ function reviewDueKey(board, id, payload) {
4409
5207
 
4410
5208
  const PASSED_WAITS = 'its review passed and no PR head is unjudged since: no review is due; the arc waits on `complete`';
4411
5209
 
5210
+ /** The latest wait recorded for queued review `queued` (`review_queue_waiting`), with its event id, or null. */
5211
+ function queueWait(db, id, queued) {
5212
+ const row = latestEvent(db, id, ['review_queue_waiting'], 'id,payload', "json_extract(payload,'$.queued_event')=?", [queued]);
5213
+ return row ? { ...(json(row.payload, {}) ?? {}), event: row.id } : null;
5214
+ }
5215
+ /** A queued request's PR as discovery last saw it: what a wait on that PR compares. */
5216
+ const observedOf = pr => pr ? { state: pr.state ?? null, head: pr.headRefOid ?? null, draft: Boolean(pr.isDraft), base: pr.baseRefName ?? null } : null;
5217
+ const sameObservation = (observed, pr) => JSON.stringify(observed ?? null) === JSON.stringify(observedOf(pr));
5218
+ // Refusal kinds (ci.mjs) by the input that can change them (ADR 0252).
5219
+ const PR_STATE_REFUSALS = new Set(['moved', 'draft', 'state', 'release']);
5220
+ const SOURCE_REFUSALS = new Set(['undeclared', 'head', 'unpushed', 'uncommitted']);
5221
+ const FINAL_REFUSALS = new Set(['foreign', 'judged']);
5222
+ /** What a failed admission waits on (ADR 0252), or `{ refused }` for a request that can never be admitted. */
5223
+ async function admissionWait(result, url, seen) {
5224
+ const kinds = Array.isArray(result?.refusals) ? result.refusals.map(refusal => refusal?.kind) : [];
5225
+ if (kinds.some(kind => FINAL_REFUSALS.has(kind))) return { refused: kinds.filter(kind => FINAL_REFUSALS.has(kind)) };
5226
+ if (kinds.length && kinds.every(kind => PR_STATE_REFUSALS.has(kind))) return { on: 'pr', observed: observedOf(seen) };
5227
+ if (kinds.length && kinds.every(kind => PR_STATE_REFUSALS.has(kind) || SOURCE_REFUSALS.has(kind))) return { on: 'source', observed: observedOf(seen) };
5228
+ if (Number(result?.retry_at) > 0) return { on: 'budget', until: Number(result.retry_at) };
5229
+ return { on: 'list', listed_at: url ? await openListObservedAt(url) : 0 };
5230
+ }
5231
+ /** Whether the one input a recorded wait names has changed since it was recorded. */
5232
+ async function waitOver(board, id, wait, url, seen) {
5233
+ if (wait.on === 'pr') return !sameObservation(wait.observed, seen);
5234
+ if (wait.on === 'source') return !sameObservation(wait.observed, seen) || Boolean(view(board, db => redeclaredSince(db, id, wait.event)));
5235
+ if (wait.on === 'budget') return Date.now() >= Number(wait.until);
5236
+ if (wait.on === 'list') return (url ? await openListObservedAt(url) : 0) > Number(wait.listed_at ?? 0);
5237
+ return true;
5238
+ }
5239
+ function recordQueueWait(db, id, queued, reason, wait) {
5240
+ const prior = queueWait(db, id, queued);
5241
+ const { event: _event, queued_event: _queued, reason: priorReason, ...priorWait } = prior ?? {};
5242
+ if (prior && priorReason === reason && JSON.stringify(priorWait) === JSON.stringify(wait)) return;
5243
+ event(db, id, 'review_queue_waiting', { queued_event: queued, reason, ...wait });
5244
+ }
5245
+ /** A queued PR that discovery sees merged or closed unmerged is settled once a fresh reading confirms it. */
5246
+ async function settleQueuedPullRequest(board, id, url, report) {
5247
+ const confirmed = await discoverPullRequest(url, { fresh: true });
5248
+ const disposition = confirmed.state === 'MERGED' ? 'merged' : confirmed.state === 'CLOSED' ? 'closed' : null;
5249
+ if (!disposition) { report.queued.push([id, `${url} reads ${confirmed.state} on a fresh reading; its queued requests remain pending`]); return; }
5250
+ const settled = tx(board, db => {
5251
+ const ids = [];
5252
+ for (const request of pendingReviewRequests(db, id)) {
5253
+ const named = [].concat(request.pr ?? []);
5254
+ if (named.length !== 1 || prIdentity(named[0]) !== prIdentity(url)) continue;
5255
+ event(db, id, 'review_queue_settled', { queued_event: request.id, disposition, pr: url, head: confirmed.headRefOid });
5256
+ ids.push(request.id);
5257
+ }
5258
+ // a queue settled empty closes the reviewer its last verdict kept (ADR 0019)
5259
+ if (ids.length && !pendingReviewRequests(db, id).length) closeKeptReviewer(db, id);
5260
+ return ids;
5261
+ });
5262
+ if (settled.length) (report.queue_settled ??= []).push([id, url, disposition, settled]);
5263
+ }
5264
+
4412
5265
  /** Review selection shared by rounds and read-only inspection (`dry: true`). */
4413
- export async function activatePullRequestReviews(board, entry, report, effects, { only = null, dry = false, fleet = null } = {}) {
5266
+ export async function activatePullRequestReviews(board, entry, report, effects, { only = null, dry = false, fleet = null, held = null, lanes = null } = {}) {
5267
+ // Capacity for an arc's next review, from this round's holdings: a queue whose review could not start reads nothing.
5268
+ const caps = held && lanes ? capacity(entry.workflow.dispatch.capacity, held, evaluate) : null;
5269
+ const reviewCapacity = caps ? (db, task) => {
5270
+ const scope = scopeOf(db, board, task, { params: entry.params, lazy: true });
5271
+ const transition = transitionsFor(entry.workflow, statusOf(db, task), 'request_review').find(t => t.target);
5272
+ const target = transition ? String(templateValue(transition.target, scope)) : null;
5273
+ const spec = target ? runSpec(entry, target, task) : null;
5274
+ const profile = spec ? evaluate(entry.workflow.roles?.[spec.role] ?? 'card.assignee', scope) : null;
5275
+ const lane = profile ? lanes[profile] : null;
5276
+ if (!lane) return null;
5277
+ const candidate = { card: task.id, board: board.slug, machine: executionHost(lane, task.machine, localMachine(), normalMachine), assignee: profile,
5278
+ dispatch: true, slot: String(templateValue(spec.session ?? 'worker', scope) ?? 'worker'), status: target, stored: target,
5279
+ model: lane.model ?? held.defaults?.[lane.harness] ?? null, harness: lane.harness ?? null };
5280
+ return caps.admit(candidate, { ...scope, lane, run: { surface: lane.surface, local: candidate.machine === localMachine() } }, [candidate])?.why ?? null;
5281
+ } : null;
4414
5282
  const candidates = view(board, db => db.prepare(`SELECT t.id FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
4415
5283
  WHERE c.subtask_of IS NULL AND c.every IS NULL
4416
5284
  ${only ? 'AND t.id=?' : ''}`).all(...(only ? [only] : [])));
4417
5285
  // Review waits only someone's act clears, and the cards read this round in a state that is no such wait (surfaceReviewStalls)
4418
5286
  const stalls = [], settled = [];
4419
5287
  for (const { id } of candidates) {
5288
+ // The arc's one review queue (ADR 0252), oldest first. Every local check comes before any provider read: the arc's
5289
+ // status and holds, its reviewer free (no review runs), capacity for that review and no native turn in flight. Only
5290
+ // the round that can admit an entry reads it fresh; an entry refused for its PR's own state waits on that
5291
+ // observation and is read again only when discovery sees its PR change (ADR 0247).
5292
+ if (view(board, db => pendingReviewRequests(db, id).length)) {
5293
+ const local = view(board, db => {
5294
+ const task = getTask(db, id);
5295
+ if (!task) return { why: 'the card no longer exists' };
5296
+ const status = statusOf(db, task), card = scopeOf(db, board, task, { params: entry.params, lazy: true }).card;
5297
+ if (status === 'review') return { why: "the arc's review in progress" };
5298
+ if (status !== 'running' || card.backlog || card.dispatch_hold || card.paused || card.blocked_by.length || card.archived) return { why: "the arc's status or holds" };
5299
+ return { task, why: reviewCapacity?.(db, task) ?? null };
5300
+ });
5301
+ const why = local.why ?? (await turnInFlight(board, id, fleet))?.why ?? null;
5302
+ if (why) {
5303
+ report.queued.push([id, `its queued reviews wait on ${why}`]);
5304
+ settled.push(id); continue;
5305
+ }
5306
+ let admitted = false;
5307
+ for (const queued of view(board, db => pendingReviewRequests(db, id))) {
5308
+ await yieldToOwner();
5309
+ if (stopping) return;
5310
+ if (!view(board, db => pendingReviewRequests(db, id).some(row => row.id === queued.id))) continue;
5311
+ const url = [].concat(queued.pr ?? [])[0] ?? null;
5312
+ let seen = null;
5313
+ try { seen = url ? await discoverQueuedPullRequest(url) : null; }
5314
+ catch (error) { report.queued.push([id, `queued review ${queued.id} waits on its PR's observation: ${String(error.message).slice(-200)}`]); continue; }
5315
+ if (seen && ['MERGED', 'CLOSED'].includes(seen.state)) {
5316
+ if (dry) (report.would_settle ??= []).push({ id, pr: url, disposition: seen.state === 'MERGED' ? 'merged' : 'closed' });
5317
+ else await settleQueuedPullRequest(board, id, url, report);
5318
+ continue;
5319
+ }
5320
+ const waiting = view(board, db => queueWait(db, id, queued.id));
5321
+ if (waiting && !await waitOver(board, id, waiting, url, seen)) {
5322
+ report.queued.push([id, `queued review ${queued.id} waits on its ${waiting.on === 'pr' ? 'PR' : waiting.on}: ${waiting.reason}`]);
5323
+ if (['budget', 'list'].includes(waiting.on)) break;
5324
+ continue;
5325
+ }
5326
+ if (dry) {
5327
+ (report.would_review ??= []).push({ id, prs: [].concat(queued.pr ?? []), heads: [].concat(queued.pr_head ?? []), queued_event: queued.id });
5328
+ admitted = true; break;
5329
+ }
5330
+ // The board acts as itself; the request's sender and requester travel in its payload, never as the board's actor.
5331
+ const previousActor = actorOf();
5332
+ let result;
5333
+ try {
5334
+ setActor(boardAddress(board.label));
5335
+ result = await requestReview(board, id, { ...queued, roles: Array.isArray(queued.sender) ? queued.sender : [],
5336
+ requester: queued.requester ?? queued.by ?? null, expectedRunId: local.task.current_run_id, queuedEvent: queued.id });
5337
+ } catch (error) { result = { reason: String(error.message).slice(-300), retry_at: error.retry_at ?? null, refusals: error.refusals ?? null }; }
5338
+ finally { setActor(previousActor); }
5339
+ if (!result.routed && !result.accepted && !result.pending && !result.queued && result.moved && result.to === 'review') {
5340
+ effects.push(...(result.effects ?? []));
5341
+ (report.review_requested ??= []).push({ id, prs: [].concat(queued.pr ?? []), heads: [].concat(queued.pr_head ?? []), queued_event: queued.id });
5342
+ admitted = true; break;
5343
+ }
5344
+ // A failed admission waits on the one input that can change its answer, never on the round; one that can never
5345
+ // be admitted settles refused by name (ADR 0252). A budget or transport wait holds every later request too.
5346
+ const reason = result.reason ?? result.disposition ?? 'its candidate could not be admitted';
5347
+ const wait = await admissionWait(result, url, seen);
5348
+ if (wait.refused) {
5349
+ tx(board, db => {
5350
+ event(db, id, 'review_queue_settled', { queued_event: queued.id, disposition: 'refused', refusals: wait.refused, reason, pr: url });
5351
+ if (!pendingReviewRequests(db, id).length) closeKeptReviewer(db, id);
5352
+ });
5353
+ report.queued.push([id, `queued review ${queued.id} settled refused: ${reason}`]);
5354
+ continue;
5355
+ }
5356
+ report.queued.push([id, `queued review ${queued.id} waits on its ${wait.on === 'pr' ? 'PR' : wait.on}: ${reason}`]);
5357
+ tx(board, db => recordQueueWait(db, id, queued.id, reason, wait));
5358
+ if (['budget', 'list'].includes(wait.on)) break;
5359
+ }
5360
+ settled.push(id);
5361
+ if (admitted) continue;
5362
+ // The final review starts only once nothing is pending.
5363
+ if (view(board, db => pendingReviewRequests(db, id).length)) { report.queued.push([id, 'final review waits on pending PR review requests']); continue; }
5364
+ }
4420
5365
  const eligible = db => {
4421
5366
  const task = getTask(db, id);
4422
5367
  if (!task) return false;
4423
5368
  // a status the platform cannot request a review from needs no scope built to say so
4424
5369
  if (!transitionsFor(entry.workflow, statusOf(db, task), 'request_review').some(t => !t.by || t.by.includes('platform'))) return false;
4425
5370
  const scope = scopeOf(db, board, task, { params: entry.params, lazy: true });
4426
- if (scope.card.dispatch_hold || scope.card.paused || scope.card.blocked_by.length) return false;
5371
+ if (scope.card.backlog || scope.card.dispatch_hold || scope.card.paused || scope.card.blocked_by.length) return false;
4427
5372
  // A person's card needs no review: it closes on that person's completion.
4428
5373
  if (scope.card.person) return false;
4429
- // The card's Tasks are its subtasks; an open one holds the review, and says so. A paused one holds nothing.
4430
- const open = scope.card.subtasks.filter(t => !['done', 'cancelled'].includes(t.status) && !t.paused);
5374
+ // Final review closes recorded work: a taskless card has not planned its work yet, even if its branch has a PR.
5375
+ const subtasks = scope.card.subtasks;
5376
+ if (!subtasks.length) return false;
5377
+ // An open task holds the final review, and says so, paused or not: the review comes when every task is done or cancelled.
5378
+ const open = subtasks.filter(t => !['done', 'cancelled'].includes(t.status));
4431
5379
  return open.length ? open : true;
4432
5380
  };
4433
5381
  const ready = view(board, eligible);
@@ -4460,20 +5408,45 @@ export async function activatePullRequestReviews(board, entry, report, effects,
4460
5408
  }
4461
5409
  // An arc whose every PR merged at the head its PR review passed (`pr_reviewed`) still gets its final review, of its
4462
5410
  // Acceptance against them, once that is due (settledReviewDue).
4463
- const settledOnly = !payload.no_code && !payload.pr.length && !payload.stacked?.length && Boolean(payload.pr_reviewed?.length);
4464
- if (settledOnly && !settledReviewDue(board, id)) { report.queued.push([id, passedReview(board, id) ? PASSED_WAITS : `review waits: every PR of the arc (${payload.pr_reviewed.map((p) => p.url).join(', ')}) merged at the head its PR review passed, and no final review is due since its last one`]); continue; }
4465
- // A PR of the arc proposed to a branch other than main is refused below, whatever the others' verdicts.
4466
- if (!settledOnly && !payload.no_code && !payload.stacked?.length && !await reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
5411
+ const settledOnly = !payload.no_code && !payload.pr.length && Boolean(payload.pr_reviewed?.length);
5412
+ if (settledOnly && !settledReviewDue(board, id, payload.pr_reviewed)) { report.queued.push([id, passedReview(board, id) ? PASSED_WAITS : `review waits: every PR of the arc (${payload.pr_reviewed.map((p) => p.url).join(', ')}) merged at the head its PR review passed, and no final review is due since its last one`]); continue; }
5413
+ if (!settledOnly && !payload.no_code && !await reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
4467
5414
  // An arc whose last verdict is a pass, with nothing unjudged since, waits on no review: what is left is its
4468
5415
  // session's done request and the manager's or creator's `complete`, and the round says that (volter-org
4469
5416
  // t_b499d0cf and t_a2bd2a5f were logged as waiting for a review every round for hours after theirs passed).
4470
5417
  if (passedReview(board, id)) { report.queued.push([id, PASSED_WAITS]); continue; }
4471
5418
  // a review the manager returned waits on its coder's next pushed head, which the coder is due to push
4472
- if (returnedReview(board, id)) { report.queued.push([id, 'review waits: the manager returned it from review with no verdict; the next review starts once a pushed PR head changes']); continue; }
4473
- // a PR whose PR review passed at its head waits on its coder's merge at that head, which settles it
5419
+ if (returnedReview(board, id)) { report.queued.push([id, 'review waits: the manager returned it from review with no verdict; the next review starts once a pushed PR head changes or the arc re-declares its sources']); continue; }
5420
+ // A PR whose PR review passed at its head waits on its coder's merge at that head, which settles it: an act due
5421
+ // from the arc's implementer, whatever its tasks say, so its session is woken and told to merge (tellMergeDue).
5422
+ // Only an arc with no implementer session to tell, or one told MERGE_TOLD_WAIT_SECONDS ago that has not merged,
5423
+ // is the manager's to hear.
4474
5424
  const passedOpen = payload.pr.filter((url, index) => prReviewedHead(board, id, payload.pr_head[index]));
4475
- const why = passedOpen.length ? `${passedOpen.join(', ')} passed its PR review and waits on its merge at its reviewed head; every other PR head has a verdict, carries the source tree its verdict judged, or predates it`
4476
- : `every PR head (${payload.pr.join(', ')}) has a verdict, carries the source tree its verdict judged, or predates it; push a commit that changes the source, or close the tasks that own the last verdict's findings`;
5425
+ if (passedOpen.length) {
5426
+ const due = passedOpen.map((url) => { const head = payload.pr_head[payload.pr.indexOf(url)]; return { url, head, ...prDisposition(board,id,url,head) }; });
5427
+ // read first (a dry plan and a round with nothing new to tell write nothing), then file in a write of its own
5428
+ const read = view(board, (db) => mergeDue(db, id, due));
5429
+ const told = read?.address && !read.prior && !dry ? tx(board, (db) => tellMergeDue(db, board, id, due)) : read && { ...read, at: read.prior?.at ?? null };
5430
+ const waiting = due.map(d => `${d.url} passed its PR review and waits on ${d.disposition === 'release-only' ? `its verified release readback on ${d.target}` : 'its merge'} at its reviewed head`).join('; ');
5431
+ if (told?.address && !told.overdue) {
5432
+ report.queued.push([id, `review waits: ${waiting}; its implementer ${told.address} is ${told.now ? 'woken and told' : read.prior ? 'told' : 'to be woken and told'} to settle it`]);
5433
+ if (told.now) (report.merges_told ??= []).push([id, told.address]);
5434
+ settled.push(id);
5435
+ continue;
5436
+ }
5437
+ // The pass ends the reviewer's run in the round's own review step, before startCards gives the implementer its
5438
+ // next run: an arc whose pass is newer than MERGE_TOLD_WAIT_SECONDS with no implementer session yet is waiting
5439
+ // on that start, not on anyone's act.
5440
+ if (!told?.address && now() - lastPrReviewPass(board, id) <= MERGE_TOLD_WAIT_SECONDS) {
5441
+ report.queued.push([id, `review waits: ${waiting}; its implementer's next run is told to settle it once it starts`]);
5442
+ continue;
5443
+ }
5444
+ const why = told?.address ? `${waiting}; its implementer ${told.address} was told to settle it at ${new Date(told.at * 1000).toISOString()} and has not` : `${waiting}; the arc has no implementer session to tell`;
5445
+ report.queued.push([id, `review waits: ${why}`]);
5446
+ if (!dry) stalls.push({ id, key: `merge:${due.map((d) => `${d.url}@${d.head}`).sort().join(' ')}:${told?.address ?? 'none'}`, why: `its review waits: ${why}.` });
5447
+ continue;
5448
+ }
5449
+ const why = `every PR head (${payload.pr.join(', ')}) has a verdict, carries the source tree its verdict judged, or predates it; push a commit that changes the source, or close the tasks that own the last verdict's findings`;
4477
5450
  report.queued.push([id, `review waits: ${why}`]);
4478
5451
  // A verdict other than a pass that nothing answered waits on an act
4479
5452
  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.` });
@@ -4503,7 +5476,7 @@ export async function activatePullRequestReviews(board, entry, report, effects,
4503
5476
  if (!dry) await refreshCardInputs(board, id, { commits: true });
4504
5477
  // Cached draft/closed exclusions are useful while waiting, but cannot define a committed review's set.
4505
5478
  const observed = await withFreshReviewReads(async () => {
4506
- const candidate = await openReviewPullRequests(board, id, { fresh: true, refuseStacked: true, repositories: payload.review_repositories ?? [] })
5479
+ const candidate = await openReviewPullRequests(board, id, { fresh: true, repositories: payload.review_repositories ?? [] })
4507
5480
  ?? (noCodeReviewDue(board, id) ? await noCodeChange(board, id, { fresh: true }) : null);
4508
5481
  const evidence = !candidate || candidate.waits ? null : candidate.no_code
4509
5482
  ? { required: false, ready: true, reason: null, phase: 'source', no_code: candidate.no_code }
@@ -4542,6 +5515,47 @@ export async function activatePullRequestReviews(board, entry, report, effects,
4542
5515
  if (!dry) surfaceReviewStalls(board, entry, stalls, settled, report);
4543
5516
  }
4544
5517
 
5518
+ /** How long an implementer told to merge a passed PR has before its arc's wait is the manager's to hear. */
5519
+ const MERGE_TOLD_WAIT_SECONDS = 1800;
5520
+ /** When the arc's latest PR review pass was recorded (seconds), or 0. */
5521
+ const lastPrReviewPass = (board, id) => Number(board.db.prepare("SELECT MAX(created_at) AS at FROM task_events WHERE task_id = ? AND kind = 'pr_review_pass'").get(id)?.at ?? 0);
5522
+
5523
+ /**
5524
+ * Who is told that `due` (each a PR and the head its PR review passed) waits on its merge: the arc's current, unended
5525
+ * run's worker that the board has not closed or been asked to close, as a read. `prior` is the telling already filed to
5526
+ * that session for those heads (a `merge_due_told` whose notice was filed, not dropped as not required). Answers
5527
+ * { address, run, key, prior, overdue }, or null when there is no such session.
5528
+ */
5529
+ function mergeDue(db, id, due) {
5530
+ const run = currentRun(db, id), session = run?.metadata?.supercode, address = addressOf(session);
5531
+ if (!run || run.ended_at != null || !address || (session.slot ?? 'worker') !== 'worker' || session.close_requested || session.close_confirmed_at || session.released || detachedSession(db, session)) return null;
5532
+ const key = `${due.map((d) => `${d.url}@${d.head}:${d.disposition ?? 'merge'}:${d.event ?? ''}`).sort().join(' ')} ${address}`;
5533
+ const told = db.prepare(`SELECT t.id, t.created_at FROM task_events t WHERE t.task_id = ? AND t.kind = 'merge_due_told' AND json_extract(t.payload, '$.key') = ?
5534
+ AND NOT EXISTS (SELECT 1 FROM task_events n WHERE n.task_id = t.task_id AND n.kind = 'session_notice_not_required' AND n.id > t.id AND json_extract(n.payload, '$.verb') = 'merge_due'
5535
+ AND n.run_id IS t.run_id) ORDER BY t.id DESC LIMIT 1`).get(id, key);
5536
+ const prior = told ? { at: Number(told.created_at) } : null;
5537
+ return { address, run, key, prior, overdue: Boolean(prior) && now() - prior.at > MERGE_TOLD_WAIT_SECONDS };
5538
+ }
5539
+
5540
+ /**
5541
+ * Tell the arc's implementer session (mergeDue) that `due` waits on its merge at its reviewed heads, in the caller's
5542
+ * transaction: a session notice through the effect queue (sessionNotice files it with a wake), once per set of heads and
5543
+ * session, recorded as `merge_due_told`. Answers { address, at, now, overdue }, or null when there is no such session.
5544
+ */
5545
+ function tellMergeDue(db, board, id, due) {
5546
+ const task = getTask(db, id), read = mergeDue(db, id, due);
5547
+ if (!task || !read) return null;
5548
+ if (read.prior) return { address: read.address, at: read.prior.at, now: false, overdue: read.overdue };
5549
+ const lines = due.map((d) => d.disposition === 'release-only'
5550
+ ? `- ${d.url}: its PR review passed at ${d.head}. It is release-only; record the verified release readback for ${d.target} at that head through workflow comment --pr ${d.url} --pr-head ${d.head} --release-target ${d.target} --released-artifact ${d.head} --release-evidence POINTER --reason TEXT (a non-machine target requires the manager's --attest-release). Its release permission is separate.`
5551
+ : `- ${d.url}: its PR review passed at ${d.head}. Merge it at that head now: gh pr merge ${d.url} --merge --match-head-commit ${d.head}`);
5552
+ const text = `${numbered(boardKey(board.root, board.slug), cardNumber(db, id), id, noticeName(task))} ${task.title}: a PR review passed and its reviewed obligation is outstanding.\n${lines.join('\n')}\nOnce every PR is merged or settled by its verified release-only readback and every task is closed, the board starts the arc's final review of its Acceptance.`;
5553
+ const source = event(db, id, 'merge_due_told', { key: read.key, address: read.address, prs: due }, read.run.id);
5554
+ db.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify({ kind: 'session_notice', id, runId: read.run.id, address: read.address, text, sourceEvent: source, verb: 'merge_due',
5555
+ messageKey: `board-merge-due-${id}-${createHash('sha256').update(read.key).digest('hex').slice(0, 16)}` }));
5556
+ return { address: read.address, at: now(), now: true, overdue: false };
5557
+ }
5558
+
4545
5559
  /** The id of the arc's last review event (requested, changes requested, passed, voided, returned), or 0: a review wait's key
4546
5560
  * carries it, so the wait a new verdict or request leaves is a new wait. */
4547
5561
  const lastReviewEvent = (board, id) => board.db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass', 'review_voided', 'review_returned')").get(id)?.id ?? 0;
@@ -4601,6 +5615,77 @@ function surfaceReviewStalls(board, entry, stalls, settled, report) {
4601
5615
  });
4602
5616
  }
4603
5617
 
5618
+ /** The board's record of each machine whose fleet record reads not current: since when, the error, whether it was told. */
5619
+ const FLEET_STALE_KEY = 'fleet_records_stale';
5620
+ /**
5621
+ * A machine whose fleet record stays not current reaches the board's manager through the board's own mail
5622
+ * (`board_message`, filed by the delivery pass's fileBoardMail), once per stretch. While its record is not current the
5623
+ * board launches nothing there and checks none of its sessions (readOne, launcherCheck), and before this only the
5624
+ * board's log said why: volter-desktop's record read not current from 09:22:56Z 2026-10-29, and a person noticed about
5625
+ * 20 minutes later.
5626
+ *
5627
+ * A stretch runs across rounds that read the machine not current without a break (a gap longer than an unread clock's,
5628
+ * max(3 × interval, idleQuiet / 2), starts a new one); once it has lasted the board's idle bound (`settings.idleQuiet`,
5629
+ * the same bound an unreadable session turn waits before its unknown diagnostic), the manager is told once, on the oldest open
5630
+ * card that names the machine (its own machine or its current run's), with every such card listed. Nothing is told
5631
+ * again until the machine reads current (which ends the stretch) and a new stretch lasts the bound. A machine no open
5632
+ * card names is kept untold. A round that read no fleet (no pane lanes) neither tells nor ends a stretch.
5633
+ * A reading this machine's daemon did not give at all (`fleet.ok` false: every machine reads unknown with that one
5634
+ * local error) is one stretch, this machine's, told once with every open card that names any machine; the other
5635
+ * machines' stretches are kept as they stood, neither told nor ended by it.
5636
+ */
5637
+ export function surfaceStaleRecords(board, entry, fleet, settings, report) {
5638
+ const to = [].concat(entry.params.managers ?? []).find(Boolean);
5639
+ if (!to || !(fleet?.unknown instanceof Set)) return;
5640
+ const at = now(), gap = Math.max(3 * settings.interval, settings.idleQuiet / 2);
5641
+ let stored = {};
5642
+ // a board that never kept state has no table yet: it holds no stretch
5643
+ try { stored = view(board, (db) => json(db.prepare('SELECT value FROM supercode_board_state WHERE key = ?').get(FLEET_STALE_KEY)?.value, null)) ?? {}; } catch { /* none kept */ }
5644
+ const local = localMachine(), whole = fleet.ok === false;
5645
+ if (!whole && !fleet.unknown.size && !Object.keys(stored).length) return;
5646
+ const next = {}, due = [];
5647
+ // no reading at all: this machine's daemon is the one stretch; the others' stretches stand as they were, read again
5648
+ // (their seen_at moves on), so an outage longer than the gap neither restarts them nor tells them twice
5649
+ if (whole) for (const [machine, kept] of Object.entries(stored)) if (machine !== local) next[machine] = { ...kept, seen_at: at };
5650
+ for (const machine of whole ? [local] : fleet.unknown) {
5651
+ const kept = stored[machine];
5652
+ const since = kept && at - Number(kept.seen_at) <= gap && Number(kept.since) > 0 ? Number(kept.since) : at;
5653
+ const error = String((whole ? fleet.error : fleet.errors?.get(machine)) ?? fleet.error ?? 'no current record').split('\n')[0].slice(0, 300);
5654
+ // a stretch that changes kind (no reading at all, then this machine's own record not current) keeps its clock (the
5655
+ // machine was never current) but not its telling: what is told now is a different failure
5656
+ const told = kept?.told_at && Number(kept.since) === since && Boolean(kept.whole) === whole && Boolean(kept.targets) === Boolean(whole && fleet.targetsUnread);
5657
+ next[machine] = { since, seen_at: at, error, ...(whole ? { whole: true } : {}), ...(whole && fleet.targetsUnread ? { targets: true } : {}), ...(told ? { told_at: kept.told_at, told_on: kept.told_on } : {}) };
5658
+ if (!next[machine].told_at && at - since >= settings.idleQuiet) due.push(machine);
5659
+ }
5660
+ const iso = (seconds) => new Date(seconds * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
5661
+ tx(board, (db) => {
5662
+ db.exec('CREATE TABLE IF NOT EXISTS supercode_board_state (key TEXT PRIMARY KEY, value TEXT)');
5663
+ const open = db.prepare(`SELECT t.id, t.current_run_id, c.machine FROM tasks t JOIN supercode_cards c ON c.task_id = t.id
5664
+ WHERE c.subtask_of IS NULL AND NOT COALESCE(c.archived, 0) AND t.status NOT IN ('done', 'cancelled', 'archived') ORDER BY t.created_at, t.id`).all();
5665
+ for (const machine of due) {
5666
+ const runMachine = (card) => card.current_run_id ? currentRun(db, card.id)?.metadata?.supercode?.machine ?? null : null;
5667
+ // this machine's daemon gave no reading: every card that names a machine waits on it
5668
+ const cards = open.filter((card) => next[machine].whole ? Boolean(card.machine || runMachine(card))
5669
+ : (card.machine && normalMachine(card.machine) === machine) || normalMachine(runMachine(card) ?? '') === machine);
5670
+ if (!cards.length) continue;
5671
+ const stale = next[machine], minutes = Math.round((at - stale.since) / 60);
5672
+ const names = cards.map((card) => { const task = getTask(db, card.id); return numbered(boardKey(board.root, board.slug), task?.number, card.id, noticeName(task)); });
5673
+ event(db, cards[0].id, 'board_message', { to, fleet_stale: `${machine}:${stale.since}`,
5674
+ text: stale.targets
5675
+ ? `The board could not read its fleet targets since ${iso(stale.since)} (${minutes} min): ${stale.error}. Every machine reads not current: until a reading completes, the board launches nothing and checks no session anywhere. Open cards on machines: ${names.slice(0, 20).join(', ')}${names.length > 20 ? `, and ${names.length - 20} more` : ''}.`
5676
+ : stale.whole
5677
+ ? `This machine's (${machine}) daemon has given no fleet records since ${iso(stale.since)} (${minutes} min): ${stale.error}. Every machine reads not current: until it answers, the board launches nothing and checks no session anywhere. Open cards on machines: ${names.slice(0, 20).join(', ')}${names.length > 20 ? `, and ${names.length - 20} more` : ''}. Told once for this stretch.`
5678
+ : `Machine ${machine}'s fleet record has not been current since ${iso(stale.since)} (${minutes} min): ${stale.error}. Until it is, the board launches nothing there and checks none of its sessions. Open cards on it: ${names.slice(0, 20).join(', ')}${names.length > 20 ? `, and ${names.length - 20} more` : ''}. Told once for this stretch.` }, null);
5679
+ Object.assign(stale, { told_at: at, told_on: cards[0].id });
5680
+ (report.fleet_records_told ??= []).push([machine, iso(stale.since), cards[0].id]);
5681
+ }
5682
+ db.prepare('INSERT INTO supercode_board_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(FLEET_STALE_KEY, JSON.stringify(next));
5683
+ });
5684
+ const stretches = Object.entries(next);
5685
+ if (stretches.length) report.fleet_records_stale = stretches.map(([machine, stale]) => [machine, iso(stale.since), stale.told_at ? 'told' : 'untold', stale.error]);
5686
+ for (const machine of Object.keys(stored)) if (!next[machine] || (stored[machine].whole && !next[machine].whole)) (report.fleet_records_current ??= []).push(machine);
5687
+ }
5688
+
4604
5689
  /**
4605
5690
  * A cap's max for one of its groups, for the capacity view: what a card in that group would be held to. A max read
4606
5691
  * from the card or its lane (a machine's or a profile's own capacity) is evaluated for that group's machine or profile.
@@ -4621,8 +5706,14 @@ const deliveries = new Map();
4621
5706
  export function startDelivery(root, { slugs = null, log = () => {} } = {}) {
4622
5707
  // the controller's delivery pass, run here by the owner (no process of its own), never two at once for a home; the
4623
5708
  // round never waits for it
4624
- if (deliveries.has(root)) return false;
4625
- const pass = ownCall(() => deliver({ root, slugs, controller: true }), 'a delivery pass').then((reports) => {
5709
+ // Every pass is recorded, fast ones included (its start, end, duration and steps), and so is a round that started
5710
+ // none because one still ran: a delivery bar is read from these rows, never from the round count.
5711
+ if (deliveries.has(root)) {
5712
+ recordSlow({ kind: 'call', name: 'board delivery pass skipped', ms: 0, includeFast: true, extra: { running_since: deliveries.get(root).started } });
5713
+ return false;
5714
+ }
5715
+ const started = new Date().toISOString();
5716
+ const pass = timed('board delivery pass', () => ownCall(() => deliver({ root, slugs, controller: true }), 'a delivery pass'), { scope: 'delivery', includeFast: true }).then((reports) => {
4626
5717
  for (const r of reports) {
4627
5718
  if (r.error) log('warn', `delivery on board ${r.board} failed: ${r.error}`);
4628
5719
  for (const [task, to, why] of r.undelivered ?? []) log('warn', `board ${r.board}: a delivery for ${task} to ${to} waits: ${why}`);
@@ -4630,7 +5721,7 @@ export function startDelivery(root, { slugs = null, log = () => {} } = {}) {
4630
5721
  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(', ')}`);
4631
5722
  }
4632
5723
  }, (error) => error?.code === GENERATION_STOPPED ? log('info', `delivery pass not run: ${error.message}`) : log('warn', `delivery pass failed: ${error?.message ?? error}`)).finally(() => deliveries.delete(root));
4633
- deliveries.set(root, pass);
5724
+ deliveries.set(root, { pass, started });
4634
5725
  return true;
4635
5726
  }
4636
5727
 
@@ -4660,28 +5751,27 @@ export async function deliver({ root, slugs = null, controller = false }) {
4660
5751
  const entry = await loadWorkflow(root);
4661
5752
  // the owner's controller delivers only for boards whose dispatching is on, read as each board is reached (a pass
4662
5753
  // already under way when a board is switched off leaves it); `workflow deliver`, an operator's act, delivers as asked
4663
- const boards = (slugs ?? listBoards(root)).filter((slug) => !controller || dispatchingOf(root, slug).on);
5754
+ const boards = slugs ?? listBoards(root);
4664
5755
  if (!boards.length) return [];
4665
5756
  const fleet = await observeFleet();
4666
5757
  fleet.noticeAlive = await noticeSessions();
4667
5758
  const out = [];
4668
5759
  for (const slug of boards) {
4669
- if (controller && !dispatchingOf(root, slug).on) continue;
4670
5760
  const report = { board: slug, ms: {}, mailed: [], queued: [], failed: [], notified: [], undelivered: [], fleet: fleet.ok ? 'read' : fleet.error };
4671
5761
  let board;
4672
5762
  try {
4673
5763
  board = openBoard(root, slug);
4674
5764
  board.label = boardLabel(root, slug);
4675
5765
  setActor(boardAddress(board.label));
4676
- let mark = Date.now();
4677
- const phase = (name) => { const t = Date.now(); report.ms[name] = t - mark; mark = t; roundStep(`${slug} ${name}`); };
5766
+ const phase = phaseClock(report, `delivery ${slug}`);
4678
5767
  // board mail and notices are filed in their receivers' mailboxes (local writes); the mailbox delivers them
4679
5768
  // a controller's pass reads the board's switch before each phase and each effect: switched off meanwhile, what had
4680
5769
  // not begun is left (an operator's `workflow deliver` delivers as asked)
4681
5770
  const on = () => !controller || dispatchingOf(root, slug).on;
4682
5771
  if (on()) { await fileBoardMail(board, report); phase('mail'); }
4683
5772
  if (on()) { report.effects = await runEffects(board, [], { pending: true, eligible: controller ? on : null }); phase('effects'); }
4684
- if (on()) { await notify(board, fleet, report, entry); phase('notify'); }
5773
+ // A due resume is announced even with dispatching off. This read/delivery never launches work.
5774
+ await notify(board, fleet, report, entry); phase('notify');
4685
5775
  if (on()) { await synchronizeAgents(board, report, fleet); phase('agents'); }
4686
5776
  report.effects_failed = Number(board.db.prepare('SELECT COUNT(*) AS n FROM supercode_effects WHERE failed_at IS NOT NULL').get().n);
4687
5777
  report.effects_waiting = Number(board.db.prepare('SELECT COUNT(*) AS n FROM supercode_effects WHERE failed_at IS NULL').get().n);
@@ -4702,6 +5792,7 @@ export async function deliver({ root, slugs = null, controller = false }) {
4702
5792
  export async function closeEnded({ root, slugs, settings }) {
4703
5793
  fleetRoot = root;
4704
5794
  if (stopping || !slugs.length || boardCopyOf({ root })) return [];
5795
+ const entry = await loadWorkflow(root);
4705
5796
  // a session's native discovery is read once a pass, as once a round (readNative)
4706
5797
  discoveries.clear();
4707
5798
  // the fleet reading settles a launch that never bound a pane; one unread leaves that close waiting, as a round's does
@@ -4718,6 +5809,10 @@ export async function closeEnded({ root, slugs, settings }) {
4718
5809
  board = openBoard(root, slug);
4719
5810
  board.label = boardLabel(root, slug);
4720
5811
  setActor(boardAddress(board.label));
5812
+ const expiry = tx(board, db => { const effects = []; const count = settleExpiringFlags(db, board, entry, effects); return { effects, count }; });
5813
+ queueEffects(board, expiry.effects);
5814
+ report.resumed = expiry.count;
5815
+ report.next_pause_at = nextFlagExpiry(board.db, entry);
4721
5816
  await closeRequested(board, report, { ttl: settings.ttl, fleet });
4722
5817
  } catch (error) { report.error = error.message; }
4723
5818
  finally { board?.close(); }
@@ -4750,21 +5845,35 @@ export async function closeEnded({ root, slugs, settings }) {
4750
5845
  // nothing settles is not. It is the round's own: a card verb this process answers meanwhile says nothing of it.
4751
5846
  const roundProgress = new AsyncLocalStorage();
4752
5847
  const roundStep = (step) => { try { roundProgress.getStore()?.(step); } catch { /* the round goes on unsaid */ } };
5848
+ const phaseClock = (report, prefix) => {
5849
+ let mark = performance.now();
5850
+ return (name) => {
5851
+ const ended = performance.now(), ms = ended - mark;
5852
+ report.ms[name] = Math.round(ms); mark = ended;
5853
+ noteStep(`${prefix}: ${name}`, ms);
5854
+ roundStep(`${report.board ?? 'home'} ${name}`);
5855
+ };
5856
+ };
4753
5857
  export function tick({ progress = null, ...options }) {
4754
5858
  return roundProgress.run(progress, () => boardRound(options));
4755
5859
  }
4756
5860
  async function boardRound({ root, lanes, settings, only = null, slugs = null, dispatch = true, params = {}, deploymentReceipt = false }) {
5861
+ return measureWork("pass", "board.round", async () => {
4757
5862
  fleetRoot = root;
4758
5863
  discoveries.clear();
5864
+ const preparation = { ms: {} }, preparedPhase = phaseClock(preparation, 'dispatch home');
4759
5865
  // a verb's flags (dispatch --max, --failure-limit) are params of this tick, read by the workflow as its own
4760
5866
  const deployment = deploymentReceipt ? await workflowRevision(root) : null;
4761
5867
  const loaded = await loadWorkflow(root);
4762
5868
  const entry = { ...loaded, params: { ...loaded.params, ...params } };
5869
+ preparedPhase('workflow');
4763
5870
  const needFleet = Object.values(lanes).some((l) => l.surface === 'pane');
4764
- const fleetAt = Date.now();
4765
- const fleet = needFleet ? await untilParked(observeFleet(), () => null) : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
4766
- const fleetMs = Date.now() - fleetAt;
4767
- roundStep('the fleet read');
5871
+ // An unread fleet is one of the round's inputs, as the delivery pass reads it: what needs it waits, the rest runs
5872
+ // (ADR-0247), and its stretch is told once with its reason (surfaceStaleRecords' whole-outage path).
5873
+ const fleet = needFleet
5874
+ ? await untilParked(observeFleet(), () => null).catch((error) => ({ ok: false, alive: new Set(), online: new Set(), unknown: new Set(), sessions: [], error: error.message, targetsUnread: error.fleetTargetsUnread === true }))
5875
+ : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
5876
+ preparedPhase('fleet');
4768
5877
  // A park (stopDispatching) ends the round at its next step, not at its end: the successor's first round does the rest,
4769
5878
  // and every act re-reads its basis in its own transaction, so a round cut between steps leaves nothing half done. A
4770
5879
  // round that only read has nothing to keep; one that moved cards keeps the effects those moves asked for (queueEffects
@@ -4772,21 +5881,24 @@ async function boardRound({ root, lanes, settings, only = null, slugs = null, di
4772
5881
  // owner's 8 s wait for 'parked', and the handover rolled back (VO-3578, pin V, 21:53Z).
4773
5882
  if (stopping) return cutRound([]);
4774
5883
  await readMemoryPressure();
5884
+ preparedPhase('memory');
4775
5885
  const boards = slugs ?? listBoards(root);
4776
5886
  // what every board of the home holds, for caps whose scope is the whole home (kanban.max_in_progress counts so): every
4777
5887
  // board, including one whose dispatching is off, whose live workers still hold their places
4778
5888
  const held = dispatch ? await homeHoldings(root, listBoards(root), lanes, fleet, entry) : [];
5889
+ preparedPhase('holdings');
4779
5890
  if (stopping) return cutRound([]);
4780
5891
  const out = [];
4781
5892
  for (const slug of boards) {
4782
5893
  if (stopping) break;
4783
- const report = { board: slug, ms: { fleet: fleetMs }, mailed: [], started: [], queued: [], failed: [], lost: [], crashed: [], timed_out: [], reclaimed: [], auto_blocked: [], promoted: 0, unassigned: [], nonspawnable: [], notified: [], undelivered: [], fleet: fleet.ok ? 'read' : fleet.error };
5894
+ const report = { board: slug, ms: { ...preparation.ms }, mailed: [], started: [], queued: [], failed: [], lost: [], crashed: [], timed_out: [], reclaimed: [], auto_blocked: [], promoted: 0, unassigned: [], nonspawnable: [], notified: [], undelivered: [], fleet: fleet.ok ? 'read' : fleet.error };
4784
5895
  const path = boardPath(root, slug);
4785
5896
  const marker = `${path}.corrupt-seen`;
4786
5897
  if (existsSync(marker) && readFileSync(marker, 'utf8').trim() === fingerprint(path)) { report.error = 'skipped: the board file was corrupt and has not changed since (`supercode workflow repair`)'; out.push(report); continue; }
4787
5898
  // a copy of a home dispatches nothing: its runs, sessions and managers are the original's
4788
5899
  if (boardCopyOf({ root })) { report.error = `skipped: ${copyRefusal({ root }, 'starts, supervises, adopts and mails nothing').message}`; out.push(report); continue; }
4789
5900
  let board;
5901
+ const phase = phaseClock(report, `dispatch ${slug}`);
4790
5902
  try {
4791
5903
  board = openBoard(root, slug);
4792
5904
  board.label = boardLabel(root, slug);
@@ -4795,9 +5907,7 @@ async function boardRound({ root, lanes, settings, only = null, slugs = null, di
4795
5907
  try { board.publishPending?.(); } catch (error) { report.queued.push(['board document', `push pending: ${error.message}`]); }
4796
5908
  // what the dispatcher records on its own is the board's act
4797
5909
  setActor(boardAddress(board.label));
4798
- // where the tick's time goes, phase by phase (ms), printed by `serve --verbose` and in `--json`
4799
- let mark = Date.now();
4800
- const phase = (name) => { const t = Date.now(); report.ms[name] = t - mark; mark = t; };
5910
+ phase('open');
4801
5911
 
4802
5912
  // each step after a park is skipped (above); the effects of what moved are still recorded (queueEffects)
4803
5913
  const going = () => !stopping;
@@ -4806,28 +5916,42 @@ async function boardRound({ root, lanes, settings, only = null, slugs = null, di
4806
5916
  const effects = [];
4807
5917
  if (going()) await completeAdoptions(board, fleet, settings, report); if (going()) await reconnectContinuedSessions(board, fleet, entry, report); if (going()) await reconcileLaunches(board, fleet, report, entry, effects, settings, lanes); if (going()) await recordBounces(board, fleet, entry, report); if (going()) await supervise(board, fleet, settings, report, entry, effects); queueEffects(board, effects.splice(0)); if (going()) await closeRequested(board, report, { ttl: settings.ttl, fleet }); phase('supervise');
4808
5918
  report.unsettled = [];
5919
+ const startAt = Date.now();
4809
5920
  report.promoted = recomputeReady(board, effects, report.unsettled);
5921
+ report.next_pause_at = nextFlagExpiry(board.db, entry);
4810
5922
  tx(board,db=>auditEndedRuns(db,board,entry));
4811
- if (dispatch && going()) await activatePullRequestReviews(board, entry, report, effects, { only, fleet });
5923
+ phase('start_settle');
5924
+ if (dispatch && going()) await activatePullRequestReviews(board, entry, report, effects, { only, fleet, held, lanes });
5925
+ phase('start_reviews');
4812
5926
  if (dispatch && going()) await startCards(board, fleet, lanes, settings, report, entry, effects, { only, held });
4813
- phase('start');
5927
+ phase('start_cards');
5928
+ report.ms.start = Date.now() - startAt;
5929
+ const idleAt = Date.now();
4814
5930
  if (dispatch && !only && !report.paused && going()) await observeIdle(board, fleet, settings, report, entry, effects);
4815
- if (dispatch && !only && !report.paused && going()) retellBriefs(board, entry, report);
5931
+ phase('idle_sessions');
5932
+ if (dispatch && !only && !report.paused && going()) await retellBriefs(board, entry, report);
5933
+ phase('idle_briefs');
5934
+ if (dispatch && !only && going()) surfaceStaleRecords(board, entry, fleet, settings, report);
4816
5935
  // what the moves asked for is recorded with them; the delivery pass (`deliver`) carries it out, outside the round
4817
5936
  queueEffects(board, effects);
4818
- phase('idle');
4819
- ensureSubscriptions(board, entry);
5937
+ phase('idle_notices');
5938
+ report.ms.idle = Date.now() - idleAt;
5939
+ if (going()) await ensureSubscriptions(board, entry);
5940
+ phase('subscriptions');
4820
5941
  fireHooks(board, settings, report);
4821
5942
  if (String(board.db.prepare('PRAGMA journal_mode').get()?.journal_mode).toLowerCase() === 'wal') board.db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get();
4822
5943
  report.ready_waiting = Number(board.db.prepare("SELECT COUNT(*) AS n FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.status = 'ready' AND t.assignee IS NOT NULL AND c.subtask_of IS NULL").get().n);
4823
5944
  // what a Hermes gateway serves around its dispatcher and this one does not: said by the caller
4824
5945
  report.chat_subscriptions = Number(board.db.prepare("SELECT COUNT(*) AS n FROM kanban_notify_subs WHERE platform != 'supercode'").get().n);
4825
5946
  if (existsSync(marker)) unlinkSync(marker);
5947
+ phase('finalize');
4826
5948
  } catch (error) {
5949
+ phase('refused');
4827
5950
  report.error = error.message;
4828
5951
  if (CORRUPT.test(error.message)) writeFileSync(marker, fingerprint(path));
4829
5952
  } finally {
4830
5953
  board?.close();
5954
+ phase('close');
4831
5955
  }
4832
5956
  out.push(report);
4833
5957
  }
@@ -4837,4 +5961,7 @@ async function boardRound({ root, lanes, settings, only = null, slugs = null, di
4837
5961
  if (receipt && out[0]) out[0].deployment = receipt;
4838
5962
  }
4839
5963
  return out;
4840
- }
5964
+
5965
+
5966
+ });
5967
+ }