@volter/supercode-orchestrator 0.5.130 → 0.5.132

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);
@@ -632,7 +642,7 @@ async function stopSession(board, session) {
632
642
  return;
633
643
  }
634
644
  if (session.pane) {
635
- const r = await runtimeDoor(normalMachine(session.machine??localMachine())===localMachine()?null:session.machine,'harness.v1.panes.close',{pane:session.pane},{env:commandEnv(),timeout:60_000,as:boardAddress(board.label)});
645
+ 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)});
636
646
  // a door that timed out or could not be reached says nothing about the pane: the close waits for the next round
637
647
  const transient = doorTransient(r);
638
648
  if (r.code !== 0 || json(r.stdout.trim(), null)?.closed !== session.pane) throw new Error(`${transient ? 'close deferred: ' : ''}closing pane ${session.pane}: ${(r.stderr || r.stdout || `exit ${r.code}`).trim().slice(-500)}`);
@@ -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] : [])));
@@ -1129,17 +1141,43 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1129
1141
  }
1130
1142
  // A final refusal stops further close attempts. Only an authorized observation of this exact pane may establish
1131
1143
  // absence: panes.list can be filtered or carry missing context keys. Unknown/invisible keeps the original failure.
1132
- const finalFailure = closeRefused(session);
1144
+ // A refusal's reason can end (the machine's daemon was replaced by one that reads the launch's starter): the same
1145
+ // reading says whether this board's close of the pane would be admitted now (`yours`), and a close it would admit
1146
+ // is tried again, unless an unended run works in the pane. A machine that does not say stays refused.
1147
+ let finalFailure = closeRefused(session);
1133
1148
  if (finalFailure) {
1134
1149
  if (!session.pane) continue;
1135
- const r = await doorOn(session.machine ?? localMachine(), 'harness.v1.panes.presence', { pane: session.pane }, { timeout: 10_000, as: boardAddress(board.label) });
1150
+ const r = await doorOn(session.machine ?? localMachine(), 'harness.v1.panes.presence', { pane: session.pane, ...(session.launch_key ? { launch: session.launch_key } : {}) }, { timeout: 10_000, as: boardAddress(board.label) });
1136
1151
  const observed = r.code === 0 ? json(r.stdout.trim(), null) : null;
1137
- if (observed?.pane !== session.pane || observed.present !== false) continue;
1152
+ if (observed?.pane !== session.pane) continue;
1153
+ if (observed.present === true && observed.yours === true) {
1154
+ if (view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)`).all())
1155
+ .some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); })) continue;
1156
+ finalFailure = false;
1157
+ } else if (observed.present !== false) continue;
1138
1158
  }
1139
1159
  const active = view(board, (db) => db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)`).all());
1140
1160
  // Persist retries even for historical ends that did not have a close_session action. The close is claimed in the
1141
1161
  // same transaction, before the pane or process is touched: of two rounds (or a round and a verb) that read one close,
1142
1162
  // only the one whose claim committed closes it, and the other never records the pane it finds gone as a failure.
1163
+ // One close of a run at a time in this process too. The claim below keeps another process off it, never this one
1164
+ // (claimHolds: its own claim does not hold it), and the owner makes a close in more than one pass side by side: a
1165
+ // round's, and each `workflow close` it answers. A second pass that began while the first was stopping the session
1166
+ // found the pane still listed and no session under it, and answered "close deferred: pane … runs …, whose session
1167
+ // could not be read" for a session that was being closed (the system test's close loop, 1 take in 7). It waits
1168
+ // for the close under way and reports what that one did.
1169
+ const flight = `${board.root}\u0000${board.slug ?? ''}\u0000${row.id}`;
1170
+ const underway = closesUnderway.get(flight);
1171
+ if (underway) {
1172
+ const made = await underway;
1173
+ if (made.error) report.undelivered.push([row.task_id, addressOf(session), made.error, 'close']);
1174
+ else if (made.closed) (report.closed ??= []).push({ task_id: row.task_id, ...session });
1175
+ continue;
1176
+ }
1177
+ let settle;
1178
+ closesUnderway.set(flight, new Promise((resolve) => { settle = resolve; }));
1179
+ let error = null, closed = false;
1180
+ try {
1143
1181
  const claimed = tx(board, (db) => {
1144
1182
  const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
1145
1183
  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 +1188,6 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1150
1188
  return true;
1151
1189
  });
1152
1190
  if (!claimed) continue;
1153
- let error = null;
1154
1191
  try {
1155
1192
  const machine = session.machine ?? localMachine();
1156
1193
  if (session.surface === 'native' && !session.pane) {
@@ -1197,7 +1234,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1197
1234
  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 } : {}) });
1198
1235
  // Retain every attempt's latest fact, but avoid an unbounded thread of identical failures.
1199
1236
  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);
1200
- if (final) 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);
1237
+ 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);
1201
1238
  } else if (!current.close_confirmed_at) {
1202
1239
  // a close another closer already confirmed (a claim that expired under it) is said once, by that closer
1203
1240
  const { close_error: _error, ...rest } = current;
@@ -1216,7 +1253,8 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1216
1253
  }
1217
1254
  });
1218
1255
  if (error) report.undelivered.push([row.task_id, addressOf(session), error, 'close']);
1219
- else (report.closed ??= []).push({ task_id: row.task_id, ...session });
1256
+ else { closed = true; (report.closed ??= []).push({ task_id: row.task_id, ...session }); }
1257
+ } finally { closesUnderway.delete(flight); settle({ error, closed }); }
1220
1258
  }
1221
1259
  }
1222
1260
 
@@ -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.132",
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": {