@volter/supercode-orchestrator 0.5.146 → 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.
- package/board/dispatch.mjs +81 -25
- package/package.json +1 -1
package/board/dispatch.mjs
CHANGED
|
@@ -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
|
|
1087
|
-
* kill and outside any write: a pane's by the harness session under its process (whatever session it is: an
|
|
1088
|
-
* never learned, a harness its record does not name), a session in no pane by its own id.
|
|
1089
|
-
* stopped, or no harness session in the pane at all, is closed
|
|
1090
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
1120
|
-
catch (error) { return `its session could not be read just now (${String(error.message).slice(0, 160)})
|
|
1121
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
@@ -2626,6 +2672,12 @@ function sessionRecord(db) {
|
|
|
2626
2672
|
return { held, latest };
|
|
2627
2673
|
}
|
|
2628
2674
|
|
|
2675
|
+
/**
|
|
2676
|
+
* Whether the board's own record says the session at `address` has ended: the latest run that names it closed or
|
|
2677
|
+
* released it, and no unended or current run works in it. An agent's address, or a session no run names, is never one.
|
|
2678
|
+
*/
|
|
2679
|
+
const recordedEnded = ({ held, latest }, address) => Boolean(sessionAt(address)) && !held.has(address) && Boolean(latest.get(address)?.session.close_confirmed_at);
|
|
2680
|
+
|
|
2629
2681
|
/**
|
|
2630
2682
|
* The worker subscriptions of sessions that have ended, removed in one transaction, with one notice_unsubscribed event
|
|
2631
2683
|
* per session naming its cards. A session has ended when no unended or current run works in it, the fleet does not list
|
|
@@ -2669,10 +2721,13 @@ async function endWorkerSubscriptions(board, fleet, report) {
|
|
|
2669
2721
|
* Each subscriber's notices, filed in its mailbox (D138 step 6, D140): the dispatcher's job ends at the filing, a local
|
|
2670
2722
|
* write under the notice's fixed key; the mailbox delivers it to the session, on this machine or through Teams on
|
|
2671
2723
|
* another. Each subscription's notices go in order, and its cursor moves past a notice once it is filed (or passed
|
|
2672
|
-
* over); a filing that failed here leaves the cursor before it, and the next pass files it.
|
|
2724
|
+
* over); a filing that failed here leaves the cursor before it, and the next pass files it. A session the board's own
|
|
2725
|
+
* record says has ended (recordedEnded) is filed nothing, in any role, until it is read running again: its cursor
|
|
2726
|
+
* stays, so a session resumed later is told what it missed. A pass with no fleet reading files it nothing either.
|
|
2673
2727
|
*/
|
|
2674
2728
|
async function notify(board, fleet, report, entry) {
|
|
2675
2729
|
await endWorkerSubscriptions(board, fleet, report);
|
|
2730
|
+
const record = view(board, (db) => sessionRecord(db));
|
|
2676
2731
|
for (const sub of subscriptions(board.db).filter((s) => s.platform === 'supercode')) {
|
|
2677
2732
|
if (stopping) break;
|
|
2678
2733
|
if (sub.delivery_metadata?.dependent_card && sub.delivery_metadata.waiting_recipient) continue;
|
|
@@ -2680,12 +2735,13 @@ async function notify(board, fleet, report, entry) {
|
|
|
2680
2735
|
// a session the fleet does not list this pass is told next pass: one reading is not a loss (a loaded box drops
|
|
2681
2736
|
// sessions from a read, and a dropped subscription never came back), and a session that is replaced on its card
|
|
2682
2737
|
// has its subscription moved when the new one starts
|
|
2683
|
-
|
|
2738
|
+
const ended = recordedEnded(record, sub.chat_id);
|
|
2739
|
+
if ((role !== 'creator' || ended) && fleet.ok) {
|
|
2684
2740
|
const alive = await subscriberAlive(fleet, sub.chat_id);
|
|
2685
2741
|
if (alive === false) continue;
|
|
2686
2742
|
// unknown is not absent: its notices wait, and the wait is said, until its liveness can be read
|
|
2687
2743
|
if (alive === null) { report.undelivered.push([sub.task_id, sub.chat_id, 'its liveness could not be read; its notices wait until it can be']); continue; }
|
|
2688
|
-
}
|
|
2744
|
+
} else if (ended) continue; // no fleet reading: nothing is filed unread to a session recorded as ended
|
|
2689
2745
|
const kinds = heardBy(entry, role);
|
|
2690
2746
|
if (!kinds.length) continue;
|
|
2691
2747
|
const events = board.db.prepare(`SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
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": {
|