@volter/supercode-orchestrator 0.5.130 → 0.5.131

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.
package/board/cli.mjs CHANGED
@@ -64,6 +64,7 @@ async function boardPublishing(root) {
64
64
  }
65
65
  import { dispatchingOf, knowLocalMachine, requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, stopDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
66
66
  import { watchBoard } from './wake.mjs';
67
+ import { onPanesChanged } from './machine-fleet.mjs';
67
68
  import { streamBoardPublications } from './event-stream.mjs';
68
69
  import { completionOwnerOf, followHome, isLatestReviewer, loadWorkflow, readHome, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
69
70
  import { evaluate } from './expr.mjs';
@@ -1702,6 +1703,9 @@ async function serve(root, args = {}) {
1702
1703
  if (watched !== undefined && board) heldDocumentsWatched(board, id ?? key, watched);
1703
1704
  else if (uncertain && !board) heldDocumentsChanged();
1704
1705
  } });
1706
+ // a machine's panes changing in its record (a session came up under its pane, a pane or its session ended) is what a
1707
+ // round binds a launch, verifies an adoption and reads a session's end from: the next round is due then
1708
+ onPanesChanged(() => events?.wake('machine-record'));
1705
1709
  // the owner holds its boards only now, its watch installed (watch before load): opened once, changed by events
1706
1710
  holdBoards(root);
1707
1711
  // a pause of the collector holds the loop like any synchronous step: each is a step, so a block line names it
@@ -1723,7 +1727,7 @@ async function serve(root, args = {}) {
1723
1727
  };
1724
1728
  await registerOwner();
1725
1729
  onLocalReopen(registerOwner);
1726
- log('dispatch wakes on board writes, document edits and board mail; the interval sweep reads each machine\'s own door');
1730
+ log('dispatch wakes on board writes, document edits, board mail and a change of a machine\'s panes in its record; the interval sweep reads each machine\'s own door');
1727
1731
  const stuck = {};
1728
1732
  const warned = {};
1729
1733
  let pausedSaid = false;
@@ -7,8 +7,9 @@
7
7
  // - `pane`: an interactive session of the lane's harness (Claude Code or Codex) on the card's machine (`supercode open
8
8
  // --new … --on <machine>`), its session id recorded on the run: one the dispatcher chose (Claude Code's
9
9
  // `--session-id`) or the one the harness named, read from the fleet by its pane (Codex). It closes when its run ends. It is live
10
- // while supercode's fleet lists it; one missing for the whole grace on an online machine is lost, and the next start
11
- // resumes it in its own harness. Nothing asks the session about itself.
10
+ // while supercode's fleet lists it; one whose pane its machine saw end is lost at once, one missing with no end seen
11
+ // for the whole grace on an online machine is lost then, and the next start resumes it in its own harness. Nothing
12
+ // asks the session about itself.
12
13
  // - `headless`: the profile's worker for one query on this machine (`bin/hermes-worker.mjs`, Hermes's worker
13
14
  // argv and task environment), watched by its pid as Hermes watches its own.
14
15
  //
@@ -572,16 +573,25 @@ async function supervise(board, fleet, settings, report, entry, effects) {
572
573
  // read one grace after it went, not two (an adopted session that ended inside the grace of its adoption waited
573
574
  // out both)
574
575
  const young = !session.seen_at && t - Number(session.launched_at ?? 0) < Number(limits.grace ?? settings.grace);
575
- const missing = !alive && machineUp && !young;
576
+ // its pane's end as its machine saw it (the record's `ended`: tmux's own exit log, or a pane kept dead): the
577
+ // session is known to be gone, and nothing is waited for. The grace is for a session that is only missing from
578
+ // a reading, with no end seen: it may be read again. A launch the board keyed is told how it ended by its
579
+ // machine's receipt (above: a clean end is a session someone closed, never a loss to recover from), so its
580
+ // receipt is what is waited for, as it was, on a round whose reading of it failed
581
+ const receipted = Boolean(session.launch_key) && Boolean(fleet.capabilities?.get(normalMachine(session.machine))?.launches);
582
+ const ended = !alive && machineUp && session.pane && !receipted ? fleet.ended?.get(normalMachine(session.machine))?.get(session.pane) ?? null : null;
583
+ const missing = !alive && machineUp && (!young || Boolean(ended));
576
584
  const next = { ...session };
577
585
  if (alive) { next.seen_at ??= t; next.missing_since = null; }
578
586
  if (missing) next.missing_since ??= t;
579
- if (missing && t - next.missing_since >= Number(limits.grace ?? settings.grace)) {
580
- // lost: gone from an online machine for the whole grace. A resume that never came up is not resumable.
587
+ if (missing && (ended || t - next.missing_since >= Number(limits.grace ?? settings.grace))) {
588
+ // lost: its pane's end was seen, or it is gone from an online machine for the whole grace. A resume that never
589
+ // came up is not resumable.
581
590
  if (next.mode === 'resume' && !next.seen_at) next.resumable = false;
582
591
  setRunSession(db, runRow.id, next);
583
592
  observe('session_lost', { session_id: next.session_id, machine: next.machine, pane: next.pane, mode: next.mode, seen: Boolean(next.seen_at),
584
- never_came_up: next.mode === 'fresh' && !next.seen_at, error: `session ${next.session_id} is gone from ${next.machine}` }, 'lost');
593
+ never_came_up: next.mode === 'fresh' && !next.seen_at, ...(ended ? { pane_ended: ended } : {}),
594
+ error: `session ${next.session_id} is gone from ${next.machine}${ended ? `: its pane ${next.pane} ended${ended.how ? ` (${ended.how})` : ''}` : ''}` }, 'lost');
585
595
  return;
586
596
  }
587
597
  if (JSON.stringify(next) !== JSON.stringify(session)) setRunSession(db, runRow.id, next);
@@ -1062,6 +1072,8 @@ async function turnAtClose(session, pane = null, { launchWindow = null } = {}) {
1062
1072
  return `its session ${who} is in a turn (${activity.turn ?? 'unknown'}); it closes once the turn ends`;
1063
1073
  }
1064
1074
 
1075
+ /** The closes this process is making now, by run: each one's outcome, for a pass that meets it under way. */
1076
+ const closesUnderway = new Map();
1065
1077
  export async function closeRequested(board, report, { taskId = null, caller = null, ttl = null, fleet = null } = {}) {
1066
1078
  const rows = view(board, (db) => db.prepare(`SELECT id, task_id, ended_at, metadata FROM task_runs
1067
1079
  WHERE metadata IS NOT NULL ${taskId ? 'AND task_id = ?' : ''}`).all(...(taskId ? [taskId] : [])));
@@ -1140,6 +1152,24 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1140
1152
  // Persist retries even for historical ends that did not have a close_session action. The close is claimed in the
1141
1153
  // same transaction, before the pane or process is touched: of two rounds (or a round and a verb) that read one close,
1142
1154
  // only the one whose claim committed closes it, and the other never records the pane it finds gone as a failure.
1155
+ // One close of a run at a time in this process too. The claim below keeps another process off it, never this one
1156
+ // (claimHolds: its own claim does not hold it), and the owner makes a close in more than one pass side by side: a
1157
+ // round's, and each `workflow close` it answers. A second pass that began while the first was stopping the session
1158
+ // found the pane still listed and no session under it, and answered "close deferred: pane … runs …, whose session
1159
+ // could not be read" for a session that was being closed (the system test's close loop, 1 take in 7). It waits
1160
+ // for the close under way and reports what that one did.
1161
+ const flight = `${board.root}\u0000${board.slug ?? ''}\u0000${row.id}`;
1162
+ const underway = closesUnderway.get(flight);
1163
+ if (underway) {
1164
+ const made = await underway;
1165
+ if (made.error) report.undelivered.push([row.task_id, addressOf(session), made.error, 'close']);
1166
+ else if (made.closed) (report.closed ??= []).push({ task_id: row.task_id, ...session });
1167
+ continue;
1168
+ }
1169
+ let settle;
1170
+ closesUnderway.set(flight, new Promise((resolve) => { settle = resolve; }));
1171
+ let error = null, closed = false;
1172
+ try {
1143
1173
  const claimed = tx(board, (db) => {
1144
1174
  const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
1145
1175
  if (!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;
@@ -1150,7 +1180,6 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1150
1180
  return true;
1151
1181
  });
1152
1182
  if (!claimed) continue;
1153
- let error = null;
1154
1183
  try {
1155
1184
  const machine = session.machine ?? localMachine();
1156
1185
  if (session.surface === 'native' && !session.pane) {
@@ -1216,7 +1245,8 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1216
1245
  }
1217
1246
  });
1218
1247
  if (error) report.undelivered.push([row.task_id, addressOf(session), error, 'close']);
1219
- else (report.closed ??= []).push({ task_id: row.task_id, ...session });
1248
+ else { closed = true; (report.closed ??= []).push({ task_id: row.task_id, ...session }); }
1249
+ } finally { closesUnderway.delete(flight); settle({ error, closed }); }
1220
1250
  }
1221
1251
  }
1222
1252
 
@@ -11,6 +11,33 @@ import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
11
11
  import { machineCall, machineRecordStream } from './doors.mjs';
12
12
  import { RecordView } from './record-view.mjs';
13
13
  const recordView = new RecordView(machineRecordStream);
14
+ /**
15
+ * What a round decides from in a machine's record: its live panes, the session under each and whether it runs, and
16
+ * the panes it saw end. Not a session's turn, which changes all day and starts, binds and ends nothing.
17
+ */
18
+ const paneFacts = (held) => {
19
+ const record = held?.record;
20
+ if (!record?.panes) return '';
21
+ const under = new Map((record.panes.activities ?? []).map(({ pid, activity }) => [pid, activity]));
22
+ return JSON.stringify([
23
+ (record.panes.panes ?? []).map((pane) => [pane.pane, Boolean(pane.dead), under.get(pane.pid)?.session_id ?? null, under.get(pane.pid)?.presence === 'running']).sort(),
24
+ (record.panes.ended ?? []).map((exit) => exit.pane).sort(),
25
+ ]);
26
+ };
27
+ const paneListeners = new Set();
28
+ let factsHeld = new Map();
29
+ recordView.onApplied = (records) => {
30
+ const next = new Map(Object.entries(records).map(([machine, held]) => [machine, paneFacts(held)]));
31
+ const changed = [...next].filter(([machine, facts]) => factsHeld.has(machine) && factsHeld.get(machine) !== facts).map(([machine]) => machine);
32
+ factsHeld = next;
33
+ if (changed.length) for (const listener of paneListeners) { try { listener(changed); } catch { /* a listener cannot stop the record's reading */ } }
34
+ };
35
+ /**
36
+ * Told when a machine's panes change in its record (a pane or the session under it came up, ended or was closed):
37
+ * the dispatcher's next round is due then, not at its interval's sweep. Returns its unsubscribe. The record's stream is
38
+ * opened by a round's first reading and held from then on.
39
+ */
40
+ export function onPanesChanged(listener) { paneListeners.add(listener); return () => paneListeners.delete(listener); }
14
41
  const DEADLINE_MS = 10_000;
15
42
  const within = (promise, ms) => Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(Object.assign(new Error(`no answer within ${ms} ms`), { code: 'timeout' })), ms).unref?.())]);
16
43
  // One machine's reading, from its held record: its panes, what a board launch needs of it, and the session under each
@@ -27,7 +54,7 @@ function readOne(name, held) {
27
54
  if (notCurrent) return { machine: name, error: `its panes are not current: ${notCurrent}`, code: 'stale' };
28
55
  const panes = record.panes?.panes ?? [];
29
56
  const capabilities = record.capabilities ?? { guard: [], launches: false };
30
- return { machine: name, panes, activities: record.panes?.activities ?? [], capabilities: { guard: capabilities.guard ?? [], launches: Boolean(capabilities.launches), launchesRefused: capabilities.launches ? null : 'its record names no keyed launch door' } };
57
+ return { machine: name, panes, activities: record.panes?.activities ?? [], ended: record.panes?.ended ?? [], capabilities: { guard: capabilities.guard ?? [], launches: Boolean(capabilities.launches), launchesRefused: capabilities.launches ? null : 'its record names no keyed launch door' } };
31
58
  }
32
59
  /** One reading of `machines` (normalized names) and this machine, `local`: every record, from this machine's daemon. */
33
60
  export async function readMachineFleet(machines, local) {
@@ -47,7 +74,8 @@ export async function readMachineFleet(machines, local) {
47
74
  const rows = names.map((name) => heldError ? { machine: name, error: `this machine's daemon did not give the records it holds: ${heldError}`, code: 'unavailable' } : readOne(name, held[name]));
48
75
  noteStep(`fleet reading (${names.length} machine${names.length === 1 ? '' : 's'}, held records)`, performance.now() - started);
49
76
  // `panes`: each answering machine's live pane ids, whether or not a session was read in them
50
- const online = new Set(), unknown = new Set(), alive = new Set(), native = new Map(), sessions = [], capabilities = new Map(), panes = new Map();
77
+ // `ended`: the panes each machine saw end (its tmux's exit log, or a pane its host keeps dead), with how each ended
78
+ const online = new Set(), unknown = new Set(), alive = new Set(), native = new Map(), sessions = [], capabilities = new Map(), panes = new Map(), ended = new Map();
51
79
  let localError = null;
52
80
  for (const row of rows) {
53
81
  if (row.error) {
@@ -59,6 +87,10 @@ export async function readMachineFleet(machines, local) {
59
87
  capabilities.set(row.machine, row.capabilities ?? { guard: [], launches: false });
60
88
  const live = (row.panes ?? []).filter((pane) => !pane.dead && pane.pid);
61
89
  panes.set(row.machine, new Set((row.panes ?? []).filter((pane) => !pane.dead).map((pane) => pane.pane)));
90
+ ended.set(row.machine, new Map([
91
+ ...(row.ended ?? []).filter((exit) => exit?.pane && !panes.get(row.machine).has(exit.pane)).map((exit) => [exit.pane, { failed: exit.failed ?? null, how: exit.how ?? null }]),
92
+ ...(row.panes ?? []).filter((pane) => pane.dead).map((pane) => [pane.pane, { failed: null, how: 'its program ended (the pane is kept for its last screen)' }]),
93
+ ]));
62
94
  for (const { pid, activity } of row.activities ?? []) {
63
95
  const pane = live.find((item) => item.pid === pid);
64
96
  if (!pane || !activity?.session_id || !activity.harness) continue;
@@ -73,5 +105,5 @@ export async function readMachineFleet(machines, local) {
73
105
  }
74
106
  }
75
107
  const ok = online.has(local);
76
- return { ok, presenceKnown: ok, online, unknown, alive, native, sessions, capabilities, panes, error: ok ? null : `machine ${local}'s door did not answer: ${localError ?? 'no row'}` };
108
+ return { ok, presenceKnown: ok, online, unknown, alive, native, sessions, capabilities, panes, ended, error: ok ? null : `machine ${local}'s door did not answer: ${localError ?? 'no row'}` };
77
109
  }
@@ -88,6 +88,8 @@ export class RecordView {
88
88
  if (this.ready) {
89
89
  for (const waiter of this.waiters) { clearTimeout(waiter.timer); waiter.resolve(this.records); }
90
90
  this.waiters.clear();
91
+ // whoever follows the records' changes (the dispatcher's wake) is told once a snapshot or a page of changes is whole
92
+ try { this.onApplied?.(this.records); } catch { /* a follower cannot fail the stream */ }
91
93
  }
92
94
  }
93
95
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.130",
3
+ "version": "0.5.131",
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": {