@volter/supercode-orchestrator 0.5.129 → 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 +5 -1
- package/board/dispatch.mjs +48 -10
- package/board/machine-fleet.mjs +35 -3
- package/board/record-view.mjs +2 -0
- package/package.json +2 -2
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
|
|
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;
|
package/board/dispatch.mjs
CHANGED
|
@@ -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
|
|
11
|
-
// resumes it in its own harness. Nothing
|
|
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
|
//
|
|
@@ -179,7 +180,11 @@ const addressOf = (s) => s?.address ?? (s?.session_id && s.machine ? `sc:${s.mac
|
|
|
179
180
|
// 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
|
|
180
181
|
// 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
|
|
181
182
|
// harness discovery. Null only when the machine cannot answer.
|
|
182
|
-
|
|
183
|
+
// A pane is read from the machine's record when the fleet reading holds its panes (current, or the machine is not in
|
|
184
|
+
// it): the pane list asked through its door serves READING_MS, so a pane that ended since that reading was read live
|
|
185
|
+
// for up to those 15 s more, and a session's loss was read that much late.
|
|
186
|
+
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;
|
|
187
|
+
const sessionAlive = (fleet, session) => fleet.native?.has(addressOf(session)) ? fleet.native.get(addressOf(session)).alive : paneInRecord(fleet, session) ?? unpublishedAlive(session);
|
|
183
188
|
/** A subscriber's address as the session it names, for sessionAlive (its machine, harness and native id). */
|
|
184
189
|
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; };
|
|
185
190
|
/** A subscriber's liveness, read first (readBefore): true, false, or null when it could not be read (unknown, never dead). */
|
|
@@ -563,17 +568,30 @@ async function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
563
568
|
const alive = sessionAlive(fleet,session);
|
|
564
569
|
if (alive == null) return;
|
|
565
570
|
const machineUp = fleet.online.has(session.machine);
|
|
566
|
-
|
|
567
|
-
|
|
571
|
+
// the grace after a launch is for a session that has not come up yet: one already seen live (bound at its
|
|
572
|
+
// launch, verified at its adoption) has, and is missed from the first reading it is gone from, so its loss is
|
|
573
|
+
// read one grace after it went, not two (an adopted session that ended inside the grace of its adoption waited
|
|
574
|
+
// out both)
|
|
575
|
+
const young = !session.seen_at && t - Number(session.launched_at ?? 0) < Number(limits.grace ?? settings.grace);
|
|
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));
|
|
568
584
|
const next = { ...session };
|
|
569
585
|
if (alive) { next.seen_at ??= t; next.missing_since = null; }
|
|
570
586
|
if (missing) next.missing_since ??= t;
|
|
571
|
-
if (missing && t - next.missing_since >= Number(limits.grace ?? settings.grace)) {
|
|
572
|
-
// lost: gone from an online machine for the whole grace. A resume that never
|
|
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.
|
|
573
590
|
if (next.mode === 'resume' && !next.seen_at) next.resumable = false;
|
|
574
591
|
setRunSession(db, runRow.id, next);
|
|
575
592
|
observe('session_lost', { session_id: next.session_id, machine: next.machine, pane: next.pane, mode: next.mode, seen: Boolean(next.seen_at),
|
|
576
|
-
never_came_up: next.mode === 'fresh' && !next.seen_at,
|
|
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');
|
|
577
595
|
return;
|
|
578
596
|
}
|
|
579
597
|
if (JSON.stringify(next) !== JSON.stringify(session)) setRunSession(db, runRow.id, next);
|
|
@@ -1054,6 +1072,8 @@ async function turnAtClose(session, pane = null, { launchWindow = null } = {}) {
|
|
|
1054
1072
|
return `its session ${who} is in a turn (${activity.turn ?? 'unknown'}); it closes once the turn ends`;
|
|
1055
1073
|
}
|
|
1056
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();
|
|
1057
1077
|
export async function closeRequested(board, report, { taskId = null, caller = null, ttl = null, fleet = null } = {}) {
|
|
1058
1078
|
const rows = view(board, (db) => db.prepare(`SELECT id, task_id, ended_at, metadata FROM task_runs
|
|
1059
1079
|
WHERE metadata IS NOT NULL ${taskId ? 'AND task_id = ?' : ''}`).all(...(taskId ? [taskId] : [])));
|
|
@@ -1132,6 +1152,24 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1132
1152
|
// Persist retries even for historical ends that did not have a close_session action. The close is claimed in the
|
|
1133
1153
|
// same transaction, before the pane or process is touched: of two rounds (or a round and a verb) that read one close,
|
|
1134
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 {
|
|
1135
1173
|
const claimed = tx(board, (db) => {
|
|
1136
1174
|
const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
|
|
1137
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;
|
|
@@ -1142,7 +1180,6 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1142
1180
|
return true;
|
|
1143
1181
|
});
|
|
1144
1182
|
if (!claimed) continue;
|
|
1145
|
-
let error = null;
|
|
1146
1183
|
try {
|
|
1147
1184
|
const machine = session.machine ?? localMachine();
|
|
1148
1185
|
if (session.surface === 'native' && !session.pane) {
|
|
@@ -1208,7 +1245,8 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1208
1245
|
}
|
|
1209
1246
|
});
|
|
1210
1247
|
if (error) report.undelivered.push([row.task_id, addressOf(session), error, 'close']);
|
|
1211
|
-
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 }); }
|
|
1212
1250
|
}
|
|
1213
1251
|
}
|
|
1214
1252
|
|
package/board/machine-fleet.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/board/record-view.mjs
CHANGED
|
@@ -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.
|
|
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": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"check-types": "node scripts/generate-types.mjs --check"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@volter/supercode-harness-sdk": "^0.3.
|
|
27
|
+
"@volter/supercode-harness-sdk": "^0.3.89",
|
|
28
28
|
"@volter/teams": "0.2.52",
|
|
29
29
|
"@volter/ztrack": "^2.4.23",
|
|
30
30
|
"yaml": "^2.9.0"
|