@volter/supercode-orchestrator 0.5.147 → 0.5.148

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 (2) hide show
  1. package/board/dispatch.mjs +68 -22
  2. package/package.json +1 -1
@@ -1083,47 +1083,91 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
1083
1083
 
1084
1084
  /** Reconcile durable close intent and ended runs, including old unverified closed_at records.
1085
1085
  * Reads and process I/O stay outside the writer. An active successor owns its pane until it ends. */
1086
- /** Why the close of an ended run's live session waits, or null when nothing would be cut. Read last, just before the
1087
- * kill and outside any write: a pane's by the harness session under its process (whatever session it is: an id the run
1088
- * never learned, a harness its record does not name), a session in no pane by its own id. Only a session read idle, or
1089
- * stopped, or no harness session in the pane at all, is closed; working, waiting on input, a turn the harness does not
1090
- * publish (unknown) and a reading that fails all wait a round. The card's move never does. */
1086
+ /** Why the close of an ended run's live session waits (`why`), or null when nothing would be cut. Read last, just
1087
+ * before the kill and outside any write: a pane's by the harness session under its process (whatever session it is: an
1088
+ * id the run never learned, a harness its record does not name), a session in no pane by its own id. A session read
1089
+ * idle, or stopped, or no harness session in the pane at all, is closed. Only a turn read in progress (working, waiting
1090
+ * on input) holds the close for as long as it is read. A harness pane whose session is not found under its process is
1091
+ * read by the run's own session id, as a session in no pane is; with no row there it waits, as a reading that fails
1092
+ * does. A turn its harness does not publish (unknown) is no turn read: its close waits on the session's own record
1093
+ * instead (`unread`: since when, the record's last write then, and when it was last read), and goes ahead once the
1094
+ * board has watched `quiet` seconds with nothing written to it. Rounds further apart than `gap` are no watch: the
1095
+ * count starts again. The card's move never waits. */
1091
1096
  // The programs a harness pane runs in its foreground: Claude Code's, Codex's, and node (the daemon's Codex pane host,
1092
1097
  // a harness installed through npm). A pane running one of these runs a harness session, read or not.
1093
1098
  const HARNESS_COMMANDS = new Set(['claude', 'claude.exe', 'codex', 'codex.exe', 'node']);
1094
1099
  /** A board launch never bound to a session (no session id read for it) whose launch window has passed. */
1095
1100
  export const unboundLaunch = (session, launchWindow, at = now()) => Boolean(session?.launch_key && !session.session_id && launchWindow
1096
1101
  && Number(session.launched_at) > 0 && at - Number(session.launched_at) >= launchWindow);
1097
- async function turnAtClose(session, pane = null, { launchWindow = null } = {}) {
1102
+ /** The fleet turns of a session read in a turn (`needs_input`: a turn stopped on a permission or a question). */
1103
+ const TURN_IN_PROGRESS = new Set(['working', 'needs_input']);
1104
+ /** The watch on a session whose turn is not published: it began (`since`) at the first such reading, at the reading
1105
+ * that found its record written again (`mark`: its last write and when its harness says its state began, in ms), or
1106
+ * at the first reading after more than `gap` seconds unread (`seen`: the last reading). */
1107
+ function unreadWatch(held, mark, gap, at = now()) {
1108
+ const kept = held && Number(held.mark) === mark && Number(held.since) <= at && at - Number(held.seen) <= gap;
1109
+ return { since: kept ? Number(held.since) : at, mark, seen: at };
1110
+ }
1111
+ async function turnAtClose(session, pane = null, { launchWindow = null, quiet = null, gap = 0 } = {}) {
1098
1112
  const machine = session.machine ?? localMachine();
1099
- let activity;
1113
+ const recordOf = (rows, id, harness) => rows.find((row) => row.locator?.session_id === id && row.locator?.harness === harness);
1114
+ let activity, rows = null;
1100
1115
  if (pane?.pid) {
1101
1116
  const r = await doorOn(machine, 'harness.v1.sessions.activity_under', { pids: [pane.pid] }, { timeout: NATIVE_DISCOVERY_MS });
1102
1117
  const page = r.code === 0 ? lastJson(r.stdout) : null;
1103
- if (!Array.isArray(page?.activities)) return `the session in pane ${session.pane} could not be read just now (${(r.stderr || r.stdout).trim().slice(-160)})`;
1118
+ 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)})` };
1104
1119
  activity = page.activities.find((item) => item.pid === pane.pid)?.activity ?? null;
1105
1120
  // No session read under the pane is no absence (a registry it could not read, another config home, a failed process
1106
- // read): the pane's own foreground program says whether a harness runs there. Only a pane running none is closed.
1121
+ // read, a process walk cut short): the pane's own foreground program says whether a harness runs there. A pane
1122
+ // running none is closed; one running a harness is read by the session id the run holds.
1107
1123
  if (!activity) {
1108
1124
  // A pane the board launched whose session no reading ever found (the fleet's never bound it, and none runs under
1109
1125
  // its process now) past the launch window holds a harness that never started a session: nothing is cut by its
1110
1126
  // close. Waiting on it kept t_7ec85b6b's Codex pane on aarons-mac-mini, stuck before its first turn, open for 15 h.
1111
1127
  if (unboundLaunch(session, launchWindow)) return null;
1112
1128
  const command = String(pane.command ?? '').split(/[\\/]/).pop().toLowerCase();
1113
- return command && !HARNESS_COMMANDS.has(command) ? null : `pane ${session.pane} runs ${command || 'a program it does not name'}, whose session could not be read`;
1129
+ if (command && !HARNESS_COMMANDS.has(command)) return null;
1130
+ const unfound = `pane ${session.pane} runs ${command || 'a program it does not name'}, whose session could not be read`;
1131
+ if (!session.session_id) return { why: unfound };
1132
+ try { rows = (await discoverNative(machine, session.harness ?? 'claude-code', session.session_id)).sessions; }
1133
+ catch (error) { return { why: `${unfound} (${String(error.message).slice(0, 160)})` }; }
1134
+ activity = recordOf(rows, session.session_id, session.harness ?? 'claude-code')?.activity ?? null;
1135
+ // a row that shows no live owner beside a pane that runs a harness places nothing: another session may run there
1136
+ if (activity?.presence !== 'running') return { why: unfound };
1114
1137
  }
1115
1138
  } else if (pane) {
1116
- return `pane ${session.pane} names no process to read`;
1139
+ return { why: `pane ${session.pane} names no process to read` };
1117
1140
  } else {
1118
- if (!session.session_id) return 'its session is not known yet';
1119
- try { activity = nativeReading((await discoverNative(machine, session.harness ?? 'claude-code', session.session_id)).sessions, session).row?.activity ?? null; }
1120
- catch (error) { return `its session could not be read just now (${String(error.message).slice(0, 160)})`; }
1121
- if (!activity) return `its session ${addressOf(session)} is not in its machine's discovery`;
1141
+ if (!session.session_id) return { why: 'its session is not known yet' };
1142
+ try { rows = (await discoverNative(machine, session.harness ?? 'claude-code', session.session_id)).sessions; }
1143
+ catch (error) { return { why: `its session could not be read just now (${String(error.message).slice(0, 160)})` }; }
1144
+ activity = nativeReading(rows, session).row?.activity ?? null;
1145
+ if (!activity) return { why: `its session ${addressOf(session)} is not in its machine's discovery` };
1122
1146
  }
1123
1147
  if (activity.presence && activity.presence !== 'running') return null;
1124
1148
  if (activity.turn === 'idle') return null;
1125
1149
  const who = activity.session_id ? `sc:${normalMachine(machine)}:${activity.harness ?? session.harness}:${activity.session_id}` : addressOf(session);
1126
- return `its session ${who} is in a turn (${activity.turn ?? 'unknown'}); it closes once the turn ends`;
1150
+ const inTurn = (turn) => ({ why: `its session ${who} is in a turn (${turn}); it closes once the turn ends`, unread: null });
1151
+ if (TURN_IN_PROGRESS.has(activity.turn)) return inTurn(activity.turn);
1152
+ // No turn published: the session's own record says whether anything goes on in it. One whose record does not answer,
1153
+ // or shows neither a write nor when its state began, has nothing to count from and waits.
1154
+ const unknown = `its session ${who} publishes no turn (${activity.turn ?? 'unknown'})`;
1155
+ const sessionId = activity.session_id ?? session.session_id, harness = activity.harness ?? session.harness ?? 'claude-code';
1156
+ if (!sessionId || !quiet) return { why: unknown };
1157
+ if (!rows) try { rows = (await discoverNative(machine, harness, sessionId)).sessions; }
1158
+ catch (error) { return { why: `${unknown}, and its record could not be read just now (${String(error.message).slice(0, 160)})` }; }
1159
+ const record = recordOf(rows, sessionId, harness);
1160
+ if (TURN_IN_PROGRESS.has(record?.activity?.turn)) return inTurn(record.activity.turn);
1161
+ const mark = Math.max(Number(record?.updated_at_ms) || 0, Number(activity.evidence?.native_state_since_ms) || 0);
1162
+ if (!mark) return { why: `${unknown}, and its record shows no write to count from` };
1163
+ const unread = unreadWatch(session.close_unread, mark, gap);
1164
+ if (unread.seen - unread.since >= quiet) return null;
1165
+ return { why: `${unknown}; it closes once it is read idle, or after ${quiet}s watched with nothing written to its record`, unread };
1166
+ }
1167
+ /** Hold the close of an ended run's session for what `turnAtClose` read, with the watch that reading leaves on the run. */
1168
+ async function holdForTurn(session, pane, options) {
1169
+ const held = await turnAtClose(session, pane, options);
1170
+ if (held) throw Object.assign(new Error(`close deferred: ${held.why}`), 'unread' in held ? { closeUnread: held.unread } : {});
1127
1171
  }
1128
1172
 
1129
1173
  /** The closes this process is making now, by run: each one's outcome, for a pass that meets it under way. */
@@ -1230,7 +1274,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1230
1274
  }
1231
1275
  let settle;
1232
1276
  closesUnderway.set(flight, new Promise((resolve) => { settle = resolve; }));
1233
- let error = null, closed = false;
1277
+ let error = null, closed = false, unread;
1234
1278
  try {
1235
1279
  const claimed = tx(board, (db) => {
1236
1280
  const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
@@ -1250,7 +1294,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1250
1294
  if (reading.alive) {
1251
1295
  const protectedNow = active.some(r => addressOf(json(r.metadata, {})?.supercode) === addressOf(session));
1252
1296
  if (protectedNow) throw new Error('close deferred: native session is owned by an unended run');
1253
- if (row.ended_at) { const inTurn = await turnAtClose(session); if (inTurn) throw new Error(`close deferred: ${inTurn}`); }
1297
+ 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) }); }
1254
1298
  await stopSession(board, session);
1255
1299
  }
1256
1300
  } else if (!finalFailure) {
@@ -1267,7 +1311,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1267
1311
  if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
1268
1312
  // An ended run's session is closed only between its turns, read last, after the claim and the pane list (a pane
1269
1313
  // already gone is settled above with no reading; t_f34710bc's coder was cut mid-proof at 15:01:13Z, 2026-10-04).
1270
- if (row.ended_at) { const inTurn = await turnAtClose(session, observed.live.get(session.pane), { launchWindow: boardSettings(workflowFor(board.root).params).launchWindow }); if (inTurn) throw new Error(`close deferred: ${inTurn}`); }
1314
+ if (row.ended_at) { const { launchWindow, idleQuiet: quiet, interval } = boardSettings(workflowFor(board.root).params); await holdForTurn(session, observed.live.get(session.pane), { launchWindow, quiet, gap: Math.max(3 * interval, quiet / 2) }); }
1271
1315
  await stopSession(board, session); observed.live.delete(session.pane);
1272
1316
  }
1273
1317
  }
@@ -1276,6 +1320,8 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1276
1320
  // the prefix is decided on the whole message, before it is cut to size
1277
1321
  const message = String(e.message), deferred = Boolean(e.launchDeferred) || /^close deferred: /.test(message);
1278
1322
  error = `${deferred ? 'close deferred: ' : ''}${message.replace(/^close deferred: /, '').slice(-480)}`;
1323
+ // the watch a turn reading left (or ended: a turn read in progress); an attempt that took none keeps the last
1324
+ if ('closeUnread' in e) unread = e.closeUnread;
1279
1325
  }
1280
1326
  tx(board, (db) => {
1281
1327
  const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
@@ -1285,14 +1331,14 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1285
1331
  if (error && current.close_confirmed_at) return;
1286
1332
  if (error) {
1287
1333
  const final = !/^close deferred/.test(error);
1288
- setRunSession(db, row.id, { ...current, close_attempted_at: at, close_attempts: Number(current.close_attempts ?? 0) + 1, close_error: error, ...(final ? { close_failed_at: at } : {}) });
1334
+ setRunSession(db, row.id, { ...current, close_attempted_at: at, close_attempts: Number(current.close_attempts ?? 0) + 1, close_error: error, ...(final ? { close_failed_at: at } : {}), ...(unread !== undefined ? { close_unread: unread } : {}) });
1289
1335
  // Retain every attempt's latest fact, but avoid an unbounded thread of identical failures.
1290
1336
  if (current.close_error !== error) event(db, row.task_id, 'session_close_failed', { session_id: session.session_id, pane: session.pane, error, retry: !final }, row.id);
1291
1337
  if (final && current.close_error !== error) event(db, row.task_id, 'run_incident', { observation: 'close_failed', run: row.id, reason: `the board could not close ${addressOf(session)} and will not try again: ${error}`, session: session.session_id, machine: session.machine, pane: session.pane ?? null, evidence: { error } }, row.id);
1292
1338
  } else if (!current.close_confirmed_at) {
1293
1339
  // a close another closer already confirmed (a claim that expired under it) is said once, by that closer
1294
1340
  const { close_error: _error, ...rest } = current;
1295
- setRunSession(db, row.id, { ...rest, close_error: null, closed_at: at, close_confirmed_at: at });
1341
+ setRunSession(db, row.id, { ...rest, close_error: null, close_unread: null, closed_at: at, close_confirmed_at: at });
1296
1342
  const address = addressOf(session);
1297
1343
  // The closed session stops hearing every card it heard as their worker: its own and each subtask (a launch
1298
1344
  // subscribes it to all of them), unless another unended run still works in it, read in this transaction.
@@ -2032,7 +2078,7 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2032
2078
  if (next === 'refuse') throw new Error(`session ${prev.session_id} still has live pane ${prev.pane}; cannot resume or replace it`);
2033
2079
  if (next === 'keep') {
2034
2080
  // the session is kept; the heartbeat's count belongs to the run that ended, so the new run starts it again
2035
- const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _is, unknown_since: _us, unknown_notice: _un, work_progress: _wp, idle_observation: _io, idle_below_threshold: _ib, idle_eligible: _ie, close_requested: _q, closed_at: _c, close_confirmed_at: _cc, close_error: _ce, close_attempts: _ca, close_attempted_at: _ct, close_failed_at: _cf, close_claim: _cl, monitor_held: _mh, released: _rl, ...kept } = prev;
2081
+ const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _is, unknown_since: _us, unknown_notice: _un, work_progress: _wp, idle_observation: _io, idle_below_threshold: _ib, idle_eligible: _ie, close_requested: _q, closed_at: _c, close_confirmed_at: _cc, close_error: _ce, close_attempts: _ca, close_attempted_at: _ct, close_failed_at: _cf, close_unread: _cu, close_claim: _cl, monitor_held: _mh, released: _rl, ...kept } = prev;
2036
2082
  session = { ...kept, mode: 'adopt', launched_at: now(), seen_at: now() };
2037
2083
  // a kept session is told what this run is for (its card came back to it, with what happened since), only while
2038
2084
  // this run is still the card's current one (its pane and native reads were awaited)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.147",
3
+ "version": "0.5.148",
4
4
  "type": "module",
5
5
  "description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
6
6
  "exports": {