@volter/supercode-orchestrator 0.5.40 → 0.5.42
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/bin/machine-fleet.mjs +13 -4
- package/board/cli.mjs +24 -2
- package/board/dispatch.mjs +90 -131
- package/board/engine.mjs +13 -1
- package/board/event-stream.mjs +5 -3
- package/board/machine-fleet.mjs +9 -5
- package/board/published.mjs +0 -35
- package/board/wake.mjs +1 -7
- package/board/workflow.mjs +0 -3
- package/board.mjs +4 -0
- package/package.json +2 -2
- package/board/event-relay.mjs +0 -51
package/bin/machine-fleet.mjs
CHANGED
|
@@ -12,15 +12,24 @@ async function read({ name, at }) {
|
|
|
12
12
|
let channel;
|
|
13
13
|
try {
|
|
14
14
|
channel = await openMachine({ machine: at });
|
|
15
|
-
const
|
|
15
|
+
const node = new StreamRpc(await channel.open('node'));
|
|
16
|
+
const panes = await node.call('harness.v1.panes.list', {});
|
|
17
|
+
// What a board launch needs of this machine: its board process guard (describe), and its keyed launch door, asked
|
|
18
|
+
// for a key no launch has (an answer of null is the door; an unknown method is its absence)
|
|
19
|
+
const description = await node.call('harness.v1.machine.describe', {}).catch(() => null);
|
|
20
|
+
const keyed = await node.call('harness.v1.launches.get', { key: 'board-capability-reading' }).then(() => null, (error) => String(error?.message ?? error).slice(0, 200));
|
|
21
|
+
const capabilities = { guard: description?.boardProcessPolicy?.version === 1 ? description.boardProcessPolicy.harnesses ?? [] : [], launches: keyed === null, launchesRefused: keyed };
|
|
16
22
|
const live = (Array.isArray(panes) ? panes : []).filter((pane) => !pane.dead && pane.pid);
|
|
17
|
-
|
|
23
|
+
// the session under each live pane: the harness stream, an operator's; a caller the machine admits only to its panes
|
|
24
|
+
// (a box another custodian enrolled, granting view and control) reads its panes and no sessions
|
|
25
|
+
let activities = [], sessionsUnread = null;
|
|
18
26
|
if (live.length) {
|
|
19
27
|
const client = new SupercodeHarnessClient({ transport: harnessTransport(channel) });
|
|
20
28
|
try { await client.start?.(); activities = (await client.request('harness.v1.sessions.activity_under', { pids: live.map((pane) => pane.pid) }))?.activities ?? []; }
|
|
21
|
-
|
|
29
|
+
catch (error) { sessionsUnread = String(error?.message ?? error).slice(0, 200); }
|
|
30
|
+
finally { await client.close?.().catch(() => {}); }
|
|
22
31
|
}
|
|
23
|
-
return { machine: name, panes, activities };
|
|
32
|
+
return { machine: name, panes, activities, capabilities, sessionsUnread };
|
|
24
33
|
} finally { channel?.close(); }
|
|
25
34
|
}
|
|
26
35
|
const rows = await Promise.all(machines.map((machine) => within(read(machine), deadlineMs)
|
package/board/cli.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// which lands here. The home is `--root`, else `SUPERCODE_ORCHESTRATOR_HOME`, else `HERMES_HOME`; the board is
|
|
6
6
|
// `--board <slug>`, else `HERMES_KANBAN_BOARD`, else the home's current board (`kanban/current`), else `default`.
|
|
7
7
|
import { taskStatusOf } from './published.mjs';
|
|
8
|
-
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { spawnSync } from 'node:child_process';
|
|
10
10
|
import { recordSlow, takeSteps } from '@volter/supercode-harness-sdk/slow-log';
|
|
11
11
|
import { basename, join, resolve } from 'node:path';
|
|
@@ -15,6 +15,19 @@ import { actorOf, view, backingPaths, layOut, backingOf, boardExists, boardPath,
|
|
|
15
15
|
import { isLocalWorkspace } from './workspace.mjs';
|
|
16
16
|
import { openZtrackBoard } from './ztrack.mjs';
|
|
17
17
|
import * as engine from './engine.mjs';
|
|
18
|
+
import { runtimeDoor } from './runtime-door.mjs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What this machine's connector reports of publishing this board home (`harness.v1.boards.list`): whether the team
|
|
22
|
+
* refused its credential, the publication it is retrying and the ones it abandoned. Null when no daemon answers or the
|
|
23
|
+
* home is not registered: diagnostics then say nothing of publishing.
|
|
24
|
+
*/
|
|
25
|
+
function boardPublishing(root) {
|
|
26
|
+
const read = runtimeDoor(null, 'harness.v1.boards.list', {}, { timeout: 15_000 });
|
|
27
|
+
if (read.code !== 0) return null;
|
|
28
|
+
let here; try { here = realpathSync(root); } catch { here = resolve(root); }
|
|
29
|
+
try { const listed = JSON.parse(read.stdout); const board = listed.boards?.find((b) => b.root === here); return board ? { ...board, refused: listed.refused ?? null } : null; } catch { return null; }
|
|
30
|
+
}
|
|
18
31
|
import { requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, stopDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
|
|
19
32
|
import { watchBoard } from './wake.mjs';
|
|
20
33
|
import { streamBoardPublications } from './event-stream.mjs';
|
|
@@ -819,10 +832,12 @@ export async function runWorkflow(argv) {
|
|
|
819
832
|
case 'diagnostics': case 'diag': {
|
|
820
833
|
const { settings } = await laneState(root).catch(() => ({ settings: boardSettings({}) }));
|
|
821
834
|
const fleet = observeFleet(root);
|
|
835
|
+
const publishing = boardPublishing(root);
|
|
822
836
|
const order = ['warning', 'error', 'critical'];
|
|
823
837
|
const found = withBoard((b) => {
|
|
824
838
|
const cards = args.task ? [engine.getTask(b.db, args.task)].filter(Boolean) : engine.listCards(b.db, {});
|
|
825
|
-
return
|
|
839
|
+
return [...(args.task ? [] : engine.diagnosePublishing(publishing).map((d) => ({ task: '(board)', title_of_task: null, ...d }))),
|
|
840
|
+
...cards.flatMap((t) => engine.diagnose(b.db, t, { failureLimit: settings.failureLimit, fleet, launchWindowSeconds: settings.launchWindow, publishing }).map((d) => ({ task: t.id, title_of_task: t.title, ...d })))]
|
|
826
841
|
.filter((d) => !args.severity || order.indexOf(d.severity) >= order.indexOf(args.severity));
|
|
827
842
|
});
|
|
828
843
|
if (args.json) { say(args, '', found); return 0; }
|
|
@@ -1448,6 +1463,13 @@ async function serve(root, args = {}) {
|
|
|
1448
1463
|
const running = installed();
|
|
1449
1464
|
log(`dispatching the boards of ${root} from ${localMachine()} (orchestrator ${running ?? 'unknown'})`);
|
|
1450
1465
|
events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log });
|
|
1466
|
+
// The board's events are published by this machine's connector, not by the dispatcher (D137, D138 step 4): the home is
|
|
1467
|
+
// named to the daemon once, and the connector follows its `workflow events` from then on, with or without this serve.
|
|
1468
|
+
{ const named = runtimeDoor(null, 'harness.v1.boards.register', { root }, { timeout: 30_000 });
|
|
1469
|
+
const refused = named.code === 0 ? JSON.parse(named.stdout || '{}').refused ?? null : null;
|
|
1470
|
+
log(named.code !== 0 ? `board events: not published; the machine daemon did not take this home: ${(named.stderr || named.stdout).trim().split('\n').at(-1)}`
|
|
1471
|
+
: refused ? `board events: registered, but the team refused this machine's credential (${refused.status}); nothing is published until it is re-enrolled`
|
|
1472
|
+
: `board events: this home is published by the machine's connector`); }
|
|
1451
1473
|
log('dispatch wakes on board writes, document edits and board mail; the interval sweep reads each machine\'s own door');
|
|
1452
1474
|
const stuck = {};
|
|
1453
1475
|
const warned = {};
|
package/board/dispatch.mjs
CHANGED
|
@@ -312,6 +312,8 @@ function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
312
312
|
for (const { id } of rows) {
|
|
313
313
|
const session = currentRun(board.db, id)?.metadata?.supercode;
|
|
314
314
|
if (!session?.launch_key || !fleet.ok || sessionAlive(fleet, session) !== false) continue;
|
|
315
|
+
// how a keyed launch ended, where its machine keeps that record: read when it can be, never waited on
|
|
316
|
+
if (!fleet.capabilities?.get(normalMachine(session.machine))?.launches) continue;
|
|
315
317
|
try { const receipt = launchReceipt(board, session); if (receipt?.live === false && receipt.exit) exits.set(id, receipt.exit); }
|
|
316
318
|
catch (error) { report.failed.push([id, `exit receipt unavailable; retaining liveness observation: ${error.message}`]); }
|
|
317
319
|
}
|
|
@@ -351,11 +353,16 @@ function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
351
353
|
const adopted = session?.mode === 'adopt' && Boolean(session.address);
|
|
352
354
|
if (runRow && ((session?.launch_pending && !adopted) || session?.launch_deferred || (!session?.session_id && !session?.pid))) {
|
|
353
355
|
const age = t - Number(session?.launched_at ?? runRow.started_at);
|
|
356
|
+
// a launch whose answer was lost may still have opened its pane: the window ends it only when this round's
|
|
357
|
+
// fleet reading shows neither its pane nor its session on its machine
|
|
358
|
+
const seen = session?.launch_key ? launchReading(fleet, session) : 'absent';
|
|
359
|
+
if (age >= settings.launchWindow && seen !== 'absent') { extendClaim(db, id, { ttl: settings.ttl, reason: seen === 'present' ? 'launch_binding' : 'launch_unread' }); return; }
|
|
354
360
|
if (age >= settings.launchWindow) {
|
|
355
361
|
const reason = session?.launch_deferred_error ?? session?.launch_error ?? 'no session or process was bound to the dispatched run';
|
|
356
362
|
const error = `launch window ${settings.launchWindow}s expired after ${age}s: ${reason}`;
|
|
357
363
|
if (!session?.launch_expired_at) {
|
|
358
|
-
|
|
364
|
+
// a fresh launch the reading never showed has no session to resume: its next run starts fresh
|
|
365
|
+
setRunSession(db, runRow.id, { launch_expired_at: t, launch_error: error, ...(session?.launch_key && session.mode === 'fresh' ? { forgotten: true } : {}) });
|
|
359
366
|
event(db, id, 'session_launch_expired', { error, age_seconds: age, window_seconds: settings.launchWindow, key: session?.launch_key ?? null, pane: session?.pane ?? null }, runRow.id);
|
|
360
367
|
}
|
|
361
368
|
observe('spawn_failed', { error, launch_window_seconds: settings.launchWindow }, null);
|
|
@@ -799,7 +806,7 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
|
|
|
799
806
|
|
|
800
807
|
/** Reconcile durable close intent and ended runs, including old unverified closed_at records.
|
|
801
808
|
* Reads and process I/O stay outside the writer. An active successor owns its pane until it ends. */
|
|
802
|
-
export function closeRequested(board, report, { taskId = null, caller = null, ttl = null } = {}) {
|
|
809
|
+
export function closeRequested(board, report, { taskId = null, caller = null, ttl = null, fleet = null } = {}) {
|
|
803
810
|
const rows = view(board, (db) => db.prepare(`SELECT id, task_id, ended_at, metadata FROM task_runs
|
|
804
811
|
WHERE metadata IS NOT NULL ${taskId ? 'AND task_id = ?' : ''}`).all(...(taskId ? [taskId] : [])));
|
|
805
812
|
// Repair pre-transfer history once: only identities with an older unended reference need a write.
|
|
@@ -821,29 +828,29 @@ export function closeRequested(board, report, { taskId = null, caller = null, tt
|
|
|
821
828
|
for (const row of rows) {
|
|
822
829
|
let session = json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
|
|
823
830
|
if (!session || session.close_transferred_to || session.close_confirmed_at) continue;
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
(
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
831
|
+
// A keyed launch is reconciled by its machine's own daemon (D137), never by a door the close waits on. A session
|
|
832
|
+
// adopted onto a run whose launch was still pending did not come from that launch: the key is let go and the
|
|
833
|
+
// session is the run's, released or closed as its own below. A launch that never bound a pane left nothing the
|
|
834
|
+
// board can name on its machine: its close is settled here. One with a pane is closed as any pane: panes.close,
|
|
835
|
+
// then the pane read absent.
|
|
836
|
+
if (session.launch_key && session.launch_pending && (row.ended_at || session.close_requested)) {
|
|
837
|
+
if (session.mode === 'adopt' && session.address) {
|
|
838
|
+
tx(board, (db) => setRunSession(db, row.id, { launch_pending: false, launch_key: null, launch_error: null }));
|
|
839
|
+
session = json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
|
|
840
|
+
} else if (!session.pane && !session.address && !session.pid) {
|
|
841
|
+
// settled only on a fleet reading that shows no session with its id on its machine; a lost open answer may
|
|
842
|
+
// have opened one, and that is closed as its own once it is bound
|
|
843
|
+
if (launchReading(fleet, session) !== 'absent') { report.undelivered.push([row.task_id, session.launch_key, 'close waits for a fleet reading that shows its launch absent', 'close']); continue; }
|
|
844
|
+
const settled = tx(board, (db) => {
|
|
845
|
+
const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
|
|
846
|
+
if (!current || current.launch_key !== session.launch_key || current.pane || current.close_confirmed_at) return false;
|
|
847
|
+
const at = now();
|
|
848
|
+
setRunSession(db, row.id, { launch_pending: false, close_requested: current.close_requested ?? at, closed_at: at, close_confirmed_at: at, close_error: null,
|
|
849
|
+
launch_error: current.launch_error ?? 'no pane was bound to its launch', forgotten: current.mode === 'fresh' ? true : Boolean(current.forgotten) });
|
|
850
|
+
event(db, row.task_id, 'session_closed', { key: session.launch_key, machine: session.machine, reason: 'no pane was bound to its launch, and the fleet reading shows neither a pane nor its session' }, row.id);
|
|
851
|
+
return true;
|
|
845
852
|
});
|
|
846
|
-
report.
|
|
853
|
+
if (settled) (report.closed ??= []).push({ task_id: row.task_id, ...session });
|
|
847
854
|
continue;
|
|
848
855
|
}
|
|
849
856
|
}
|
|
@@ -1049,10 +1056,7 @@ export async function sessionNotice(board, e) {
|
|
|
1049
1056
|
if (prior?.close_confirmed_at) {
|
|
1050
1057
|
tx(board, (db) => {
|
|
1051
1058
|
// A closed session has nothing to be told; its next run starts with its own prompt.
|
|
1052
|
-
|
|
1053
|
-
event(db, e.id, 'session_notice_not_required', { address: e.address, verb: e.verb, notice: e.durableId,
|
|
1054
|
-
reason: unissued ? 'machine confirmed launch was not issued; no session exists to notify' : 'session closed',
|
|
1055
|
-
...(unissued ? { receipt: prior.launch_cancellation } : {}) }, e.runId);
|
|
1059
|
+
event(db, e.id, 'session_notice_not_required', { address: e.address, verb: e.verb, notice: e.durableId, reason: 'session closed' }, e.runId);
|
|
1056
1060
|
if (prior.lifecycle_notice_pending === e.durableId) setRunSession(db, e.runId, { lifecycle_notice_pending: null });
|
|
1057
1061
|
});
|
|
1058
1062
|
return true;
|
|
@@ -1172,6 +1176,11 @@ function launcherCheck(db, task, lane, fleet) {
|
|
|
1172
1176
|
const machine = normalMachine(task.machine);
|
|
1173
1177
|
if (!fleet.ok) return `supercode could not read the fleet: ${fleet.error}`;
|
|
1174
1178
|
if (fleet.unknown?.has(machine)) return `machine ${machine}'s door did not answer; the card starts when it does`;
|
|
1179
|
+
// a machine is scheduled only when it can take a board launch: its board process guard runs this harness, and its
|
|
1180
|
+
// daemon keeps keyed launches (it reconciles them itself). One without them is not scheduled, and says why.
|
|
1181
|
+
const can = fleet.capabilities?.get(machine);
|
|
1182
|
+
if (fleet.online.has(machine) && can && !can.launches) return `machine ${machine} cannot take a board launch: its keyed launch door (harness.v1.launches.get) answered ${can.launchesRefused ?? 'nothing'} (an older daemon has none; a box another custodian runs keeps it for its operators)`;
|
|
1183
|
+
if (fleet.online.has(machine) && can && !can.guard.includes(HARNESSES[lane.harness] ? lane.harness : 'claude-code')) return `machine ${machine} has no native board process guard for ${lane.harness ?? 'claude-code'}; no unguarded session is launched`;
|
|
1175
1184
|
if (!fleet.online.has(machine)) return `machine ${machine} is offline; the card starts when it is back`;
|
|
1176
1185
|
// a card on this machine gets its workspace made here (scratch, dir or worktree, as Hermes makes it); another
|
|
1177
1186
|
// machine's card names its directory there
|
|
@@ -1233,68 +1242,28 @@ const getTaskNow = (board, id) => view(board, (db) => getTask(db, id));
|
|
|
1233
1242
|
|
|
1234
1243
|
|
|
1235
1244
|
/** A transport error is not a negative launch receipt. The key remains owned by its run. */
|
|
1245
|
+
/**
|
|
1246
|
+
* Where the round's fleet reading puts a launch's session: 'present' when its machine lists the launch's pane or a
|
|
1247
|
+
* session with its id, 'absent' when its machine answered and lists neither, null when its machine was not read.
|
|
1248
|
+
*/
|
|
1249
|
+
function launchReading(fleet, session) {
|
|
1250
|
+
const machine = normalMachine(session?.machine ?? localMachine());
|
|
1251
|
+
if (!fleet?.ok || !fleet.online?.has(machine) || !fleet.panes?.has(machine)) return null;
|
|
1252
|
+
const sessionSeen = session.session_id && fleet.sessions?.some((f) => f.machine === machine && f.session_id === session.session_id);
|
|
1253
|
+
return (session.pane && fleet.panes.get(machine).has(session.pane)) || sessionSeen ? 'present' : 'absent';
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/** How a keyed launch ended, as its machine recorded it; read only where the machine has the door (supervise). */
|
|
1236
1257
|
function launchReceipt(board, session) {
|
|
1237
1258
|
const r = doorOn(session.machine, 'harness.v1.launches.get',{key:session.launch_key},
|
|
1238
1259
|
{ timeout: 30_000, as: boardAddress(board.label) });
|
|
1239
|
-
if (r.code !== 0) throw unavailable(`launch receipt unavailable on ${session.machine}:
|
|
1260
|
+
if (r.code !== 0) throw unavailable(`launch receipt unavailable on ${session.machine}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
|
|
1240
1261
|
if (r.stdout.trim() === 'null') return null;
|
|
1241
1262
|
const receipt = lastJson(r.stdout);
|
|
1242
1263
|
if (!receipt || receipt.key !== session.launch_key) throw unavailable(`invalid launch receipt for ${session.launch_key} on ${session.machine}`);
|
|
1243
1264
|
return receipt;
|
|
1244
1265
|
}
|
|
1245
1266
|
|
|
1246
|
-
function cancelUnissuedLaunch(board, session) {
|
|
1247
|
-
const result = doorOn(session.machine, 'harness.v1.launches.cancel', {key:session.launch_key},
|
|
1248
|
-
{ timeout: 30_000, as: boardAddress(board.label) });
|
|
1249
|
-
if (result.code !== 0) throw unavailable(`launch cancellation unavailable on ${session.machine}; retain ${session.launch_key}: ${(result.stderr || result.stdout).trim().slice(-300)}`);
|
|
1250
|
-
const receipt = lastJson(result.stdout);
|
|
1251
|
-
if (!receipt || receipt.key !== session.launch_key) throw unavailable(`invalid cancellation receipt for ${session.launch_key}`);
|
|
1252
|
-
return receipt;
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
function unissuedLaunch(receipt, session) {
|
|
1256
|
-
return receipt?.key === session?.launch_key && receipt?.status === 'cancelled' && receipt?.cancelled === true &&
|
|
1257
|
-
receipt?.outcome === 'not_issued' && receipt?.live === false && receipt?.pane === null;
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
/**
|
|
1261
|
-
* Retire a launch key its machine confirmed it never issued. Returns true when the run's launch is settled and closed,
|
|
1262
|
-
* 'released' when the key is retired but the run keeps a session adopted onto it, false when the run is not this key's.
|
|
1263
|
-
*/
|
|
1264
|
-
function retireUnissuedLaunch(board, row, session, receipt, { entry = null, effects = [] } = {}) {
|
|
1265
|
-
if (!unissuedLaunch(receipt, session)) return false;
|
|
1266
|
-
const result = tx(board, db => {
|
|
1267
|
-
const current = db.prepare('SELECT ended_at, metadata FROM task_runs WHERE id=?').get(row.id);
|
|
1268
|
-
const saved = json(current?.metadata, {})?.supercode;
|
|
1269
|
-
if (!saved || saved.launch_key !== session.launch_key) return { retired: false, effects: [] };
|
|
1270
|
-
// A session adopted onto a run whose launch was still pending did not come from that launch (the machine never
|
|
1271
|
-
// issued the key): the key is retired and the adopted session stays the run's, supervised and closed as its own.
|
|
1272
|
-
if (saved.mode === 'adopt' && saved.address && saved.launch_pending) {
|
|
1273
|
-
setRunSession(db, row.id, { launch_pending: false, launch_cancellation: receipt, launch_error: null, launch_key: null });
|
|
1274
|
-
event(db, row.task_id, 'session_launch_cancelled', { key: session.launch_key, machine: session.machine, receipt, kept: saved.address }, row.id);
|
|
1275
|
-
return { retired: 'released', effects: [] };
|
|
1276
|
-
}
|
|
1277
|
-
if (saved.pane || saved.address || saved.pid) return { retired: false, effects: [] };
|
|
1278
|
-
if (!current.ended_at && (!entry || getTask(db, row.task_id)?.current_run_id !== row.id)) return { retired: false, effects: [] };
|
|
1279
|
-
if (saved.close_confirmed_at) return { retired: true, effects: [] };
|
|
1280
|
-
const collected = [];
|
|
1281
|
-
const at = now();
|
|
1282
|
-
setRunSession(db, row.id, { launch_pending: false, launch_cancellation: receipt, launch_error: 'machine confirmed launch was not issued',
|
|
1283
|
-
close_requested: saved.close_requested ?? at, closed_at: at, close_confirmed_at: at, close_error: null,
|
|
1284
|
-
forgotten: saved.mode === 'fresh' ? true : Boolean(saved.forgotten) });
|
|
1285
|
-
event(db, row.task_id, 'session_launch_cancelled', { key: session.launch_key, machine: session.machine, receipt }, row.id);
|
|
1286
|
-
if (!current.ended_at) {
|
|
1287
|
-
const payload = { error: `launch ${session.launch_key} was not issued; its machine retired the key` };
|
|
1288
|
-
const moved = monitorsRuns(entry) ? (surfaceIncident(db,board,entry,row.task_id,'spawn_failed',payload), {effects:[]}) : fire(db,board,entry,row.task_id,'spawn_failed',{roles:['platform'],payload});
|
|
1289
|
-
collected.push(...moved.effects);
|
|
1290
|
-
}
|
|
1291
|
-
event(db, row.task_id, 'session_closed', { key: session.launch_key, machine: session.machine, reason: 'launch not issued', receipt }, row.id);
|
|
1292
|
-
return { retired: true, effects: collected };
|
|
1293
|
-
});
|
|
1294
|
-
effects.push(...result.effects);
|
|
1295
|
-
return result.retired;
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
1267
|
function bindLaunch(board, row, session, pane, sid) {
|
|
1299
1268
|
return tx(board, (db) => {
|
|
1300
1269
|
const current = db.prepare('SELECT ended_at, metadata FROM task_runs WHERE id=?').get(row.id);
|
|
@@ -1331,12 +1300,10 @@ export function reconcileCallerLaunch(board, sid) {
|
|
|
1331
1300
|
if (!candidates.length) return;
|
|
1332
1301
|
const pane = process.env.SUPERCODE_TEAMS_PANE || observeFleet().sessions?.find((s) => s.address === `sc:${machine}:${harness}:${sid}`)?.pane;
|
|
1333
1302
|
if (!pane) return;
|
|
1303
|
+
// the caller's own pane (its environment, or this machine's reading) binds the run whose launch recorded that pane
|
|
1334
1304
|
for (const row of candidates) {
|
|
1335
1305
|
const s = json(row.metadata, {})?.supercode;
|
|
1336
|
-
|
|
1337
|
-
if (receipt?.live && receipt.pane === pane) {
|
|
1338
|
-
if (bindLaunch(board, row, s, pane, sid)) return;
|
|
1339
|
-
}
|
|
1306
|
+
if ((s.pane === pane || s.session_id === sid) && bindLaunch(board, row, s, pane, sid)) return;
|
|
1340
1307
|
}
|
|
1341
1308
|
}
|
|
1342
1309
|
|
|
@@ -1433,35 +1400,29 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes
|
|
|
1433
1400
|
const rows = view(board, (db) => db.prepare(`SELECT id, task_id, started_at, ended_at, metadata FROM task_runs
|
|
1434
1401
|
WHERE json_extract(metadata, '$.supercode.launch_pending') = 1
|
|
1435
1402
|
AND json_extract(metadata, '$.supercode.close_confirmed_at') IS NULL`).all());
|
|
1403
|
+
// A keyed launch whose session is not bound yet is bound from this round's fleet reading: its pane, and the session
|
|
1404
|
+
// the reading finds in it. Its machine's daemon reconciles the key itself (D137); no door is asked here. A launch with
|
|
1405
|
+
// no pane waits for its launch window (supervise surfaces it then); a pane read absent on its online machine after
|
|
1406
|
+
// the grace ended before its session was bound.
|
|
1436
1407
|
for (const row of rows) {
|
|
1437
1408
|
const s = json(row.metadata, {})?.supercode;
|
|
1438
1409
|
try {
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
const moved = tx(board, (db) => {
|
|
1456
|
-
if (getTask(db, row.task_id)?.current_run_id !== row.id) return null;
|
|
1457
|
-
setRunSession(db, row.id, { launch_pending: false });
|
|
1458
|
-
const payload = { error: `launch ${s.launch_key} ended before session binding` };
|
|
1459
|
-
if (monitorsRuns(entry)) { surfaceIncident(db, board, entry, row.task_id, 'spawn_failed', payload); return { effects: [] }; }
|
|
1460
|
-
return fire(db, board, entry, row.task_id, 'spawn_failed', { roles: ['platform'], payload });
|
|
1461
|
-
});
|
|
1462
|
-
if (moved) effects.push(...moved.effects);
|
|
1463
|
-
}
|
|
1464
|
-
} else if (!sid) report.queued.push([row.task_id, `pane ${receipt.pane} is live; awaiting ${s.harness} session identity`]);
|
|
1410
|
+
// found by its pane (chosen before open) or by its session id (named before open, for Claude Code)
|
|
1411
|
+
const found = fleet.sessions?.find((f) => f.machine === normalMachine(s.machine) && String(f.address).startsWith(`sc:${s.machine}:${s.harness}:`)
|
|
1412
|
+
&& ((s.pane && f.pane === s.pane) || (s.session_id && f.session_id === s.session_id)));
|
|
1413
|
+
const sid = s.session_id ?? found?.address.split(':')[3];
|
|
1414
|
+
if (found && sid) { bindLaunch(board, row, s, found.pane ?? s.pane, sid); continue; }
|
|
1415
|
+
if (!row.ended_at && now() - Number(s.launched_at) > settings.grace && launchReading(fleet, s) === 'absent') {
|
|
1416
|
+
const moved = tx(board, (db) => {
|
|
1417
|
+
if (getTask(db, row.task_id)?.current_run_id !== row.id) return null;
|
|
1418
|
+
// a fresh launch that never showed in a reading has no session to resume: its next run starts fresh
|
|
1419
|
+
setRunSession(db, row.id, { launch_pending: false, ...(s.mode === 'fresh' ? { forgotten: true } : {}) });
|
|
1420
|
+
const payload = { error: `launch ${s.launch_key} ended before session binding` };
|
|
1421
|
+
if (monitorsRuns(entry)) { surfaceIncident(db, board, entry, row.task_id, 'spawn_failed', payload); return { effects: [] }; }
|
|
1422
|
+
return fire(db, board, entry, row.task_id, 'spawn_failed', { roles: ['platform'], payload });
|
|
1423
|
+
});
|
|
1424
|
+
if (moved) effects.push(...moved.effects);
|
|
1425
|
+
} else report.queued.push([row.task_id, `launch ${s.launch_key}${s.pane ? ` (pane ${s.pane})` : ''} awaits its ${s.harness} session in the fleet reading`]);
|
|
1465
1426
|
} catch (error) { report.failed.push([row.task_id, `launch reconciliation retained: ${error.message}`]); }
|
|
1466
1427
|
}
|
|
1467
1428
|
}
|
|
@@ -1499,7 +1460,8 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1499
1460
|
if (pending) throw unavailable(`slot ${slot} still owns pane ${pending.session.pane} from run ${pending.id}; closure must be confirmed before another session starts: ${pending.session.close_error ?? 'close pending'}`);
|
|
1500
1461
|
let prev = view(board, (db) => lastSession(db, task.id, slot));
|
|
1501
1462
|
if (prev?.session_id && !prev.forgotten && (prev.harness ?? 'claude-code') === harness && prev.machine === machine) {
|
|
1502
|
-
|
|
1463
|
+
// the round's own fleet reading: a launch never reads the fleet again
|
|
1464
|
+
const fresh = fleet;
|
|
1503
1465
|
if (!fresh.ok) throw unavailable(`cannot check for a manual continuation of ${prev.session_id}: ${fresh.error}`);
|
|
1504
1466
|
const r = doorOn(machine, 'harness.v1.panes.list',{}, { timeout: 60_000 });
|
|
1505
1467
|
const listed = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
@@ -1515,30 +1477,27 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1515
1477
|
// a pane that could not be closed still holds the session: no second process is started on it
|
|
1516
1478
|
if (prev?.close_requested && !prev.closed_at) throw unavailable(`the slot's session ${prev.session_id} asked to be closed and its pane ${prev.pane} could not be: ${report.undelivered.map((u) => u[2]).join('; ') || 'no reason given'}`);
|
|
1517
1479
|
}
|
|
1480
|
+
// The machine was checked before the card was claimed (launcherCheck: its board process guard and keyed launch door,
|
|
1481
|
+
// from the round's fleet reading); the launch asks no door of it. Open's reply names the pane; a session named at
|
|
1482
|
+
// launch (Claude Code's) is bound with it, one that names itself (Codex) is bound from a later round's fleet reading.
|
|
1483
|
+
// The pane id is chosen from the launch key and recorded before open runs, so a launch whose answer is lost (a
|
|
1484
|
+
// timeout, a closed stream) is still found by its pane in a later round's fleet reading, and by its session id.
|
|
1518
1485
|
const openOwned = (key, sid, mode, args) => {
|
|
1519
|
-
const
|
|
1520
|
-
const policy = description.code === 0 ? json(description.stdout,null)?.boardProcessPolicy : null;
|
|
1521
|
-
if (policy?.version !== 1 || !policy.harnesses?.includes(harness)) throw unavailable(`machine ${machine} has no native board process guard for ${harness}; no unguarded session was launched`);
|
|
1522
|
-
launchReceipt(board, { machine, launch_key: key }); // Prove the exact receipt door before starting a process.
|
|
1486
|
+
const chosen = `p_${createHash('sha256').update(key).digest('hex').slice(0, 16)}`;
|
|
1523
1487
|
tx(board, (db) => setRunSession(db, runId, { ...meta, surface: 'pane', machine, harness, slot,
|
|
1524
|
-
session_id: sid, mode, launch_key: key, launch_pending: true, launched_at: now() }));
|
|
1525
|
-
const result = sc(args, { timeout: 180_000, as: boardAddress(board.label) });
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
const pane = facts?.pane ?? lastJson(result.stdout)?.pane ?? null;
|
|
1529
|
-
// The daemon's keyed receipt is authoritative even when the stream carrying open's reply closed.
|
|
1530
|
-
if (pane) tx(board, (db) => {
|
|
1488
|
+
session_id: sid, mode, launch_key: key, pane: chosen, launch_pending: true, launched_at: now() }));
|
|
1489
|
+
const result = sc([...args.slice(0, args.indexOf('--key')), '--pane', chosen, ...args.slice(args.indexOf('--key'))], { timeout: 180_000, as: boardAddress(board.label) });
|
|
1490
|
+
const pane = lastJson(result.stdout)?.pane ?? chosen;
|
|
1491
|
+
if (pane !== chosen) tx(board, (db) => {
|
|
1531
1492
|
const before = json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(runId)?.metadata, {})?.supercode ?? {};
|
|
1532
1493
|
setRunSession(db, runId, { ...before, pane });
|
|
1533
1494
|
});
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
if (pane && discovered && facts?.live === true) {
|
|
1537
|
-
return { surface: 'pane', session_id: discovered, machine, harness, pane, mode, launch_key: key,
|
|
1495
|
+
if (pane && sid && result.code === 0) {
|
|
1496
|
+
return { surface: 'pane', session_id: sid, machine, harness, pane, mode, launch_key: key,
|
|
1538
1497
|
launch_pending: false, launch_error: null, launched_at: now() };
|
|
1539
1498
|
}
|
|
1540
|
-
const reason =
|
|
1541
|
-
`launch ${key} is
|
|
1499
|
+
const reason = result.code === 0 ? `launch ${key} opened pane ${pane}; its ${harness} session is bound from the fleet reading` :
|
|
1500
|
+
`launch ${key} (pane ${pane}) gave no answer; it is bound from the fleet reading if it opened: ${(result.stderr || result.stdout).trim().slice(-300)}`;
|
|
1542
1501
|
tx(board, (db) => {
|
|
1543
1502
|
setRunSession(db, runId, { launch_pending: true, launch_error: reason });
|
|
1544
1503
|
event(db, task.id, 'session_launch_pending', { key, pane, reason }, runId);
|
|
@@ -2726,7 +2685,7 @@ export async function deliver({ root, slugs = null }) {
|
|
|
2726
2685
|
* `workflow dispatch`). An adoption request and a session close are claimed in a transaction (`newClaim`) before
|
|
2727
2686
|
* anything is done for them outside the board; card starts, supervision, review requests, mailed verbs and manual
|
|
2728
2687
|
* continuations rely on their per-card transactions. Two rounds on one board at once can still both act on:
|
|
2729
|
-
* deferred-launch retries (two panes), launch
|
|
2688
|
+
* deferred-launch retries (two panes), launch binding (bindLaunch), and board-only writes
|
|
2730
2689
|
* that do not re-check. A round runs no effect and delivers nothing: it records effects (`queueEffects`) and the
|
|
2731
2690
|
* delivery pass (`deliver`) carries them out. Answers one report per board.
|
|
2732
2691
|
*/
|
|
@@ -2770,7 +2729,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
2770
2729
|
readMailbox(board, boardAddress(board.label), report); phase('mailbox');
|
|
2771
2730
|
await refreshRelationAnswers(board); phase('answers');
|
|
2772
2731
|
const effects = [];
|
|
2773
|
-
completeAdoptions(board, fleet, settings, report, lanes); reconnectContinuedSessions(board, fleet, entry, report); reconcileLaunches(board, fleet, report, entry, effects, settings, lanes); supervise(board, fleet, settings, report, entry, effects); queueEffects(board, effects.splice(0)); closeRequested(board, report, { ttl: settings.ttl }); phase('supervise');
|
|
2732
|
+
completeAdoptions(board, fleet, settings, report, lanes); reconnectContinuedSessions(board, fleet, entry, report); reconcileLaunches(board, fleet, report, entry, effects, settings, lanes); supervise(board, fleet, settings, report, entry, effects); queueEffects(board, effects.splice(0)); closeRequested(board, report, { ttl: settings.ttl, fleet }); phase('supervise');
|
|
2774
2733
|
report.unsettled = [];
|
|
2775
2734
|
report.promoted = recomputeReady(board, effects, report.unsettled);
|
|
2776
2735
|
tx(board,db=>auditEndedRuns(db,board,entry));
|
package/board/engine.mjs
CHANGED
|
@@ -912,12 +912,20 @@ export function gcEvents(board, seconds) {
|
|
|
912
912
|
|
|
913
913
|
const hours = (s) => s / 3600;
|
|
914
914
|
|
|
915
|
+
/** The board home's publishing, as its machine's connector reports it (`harness.v1.boards.list`): what holds it all. */
|
|
916
|
+
export function diagnosePublishing(publishing) {
|
|
917
|
+
if (!publishing) return [];
|
|
918
|
+
if (publishing.refused) return [{ kind: 'publishing_refused', severity: 'error', title: `the team refused this machine's credential (${publishing.refused.status}); no card of this home is published until the machine is re-enrolled`, data: publishing.refused, actions: ['supercode teams enroll'] }];
|
|
919
|
+
if (publishing.error) return [{ kind: 'publishing_waits', severity: 'warning', title: `publishing to the team waits and is retried: ${publishing.error}`, data: { failing: publishing.failing ?? null }, actions: [] }];
|
|
920
|
+
return [];
|
|
921
|
+
}
|
|
922
|
+
|
|
915
923
|
/**
|
|
916
924
|
* Distress signals on a card, computed on demand and never stored (Hermes's diagnostics, the same kinds where the
|
|
917
925
|
* same thing can go wrong, and supercode's own where only its sessions can): each `{ kind, severity, title, data,
|
|
918
926
|
* actions }`, worst first.
|
|
919
927
|
*/
|
|
920
|
-
export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, strandedSeconds = 30 * 60, blockedHours = 24, fleet = null, launchWindowSeconds = 300 } = {}) {
|
|
928
|
+
export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, strandedSeconds = 30 * 60, blockedHours = 24, fleet = null, launchWindowSeconds = 300, publishing = null } = {}) {
|
|
921
929
|
const out = [];
|
|
922
930
|
const t = now();
|
|
923
931
|
const events = all(db, 'SELECT kind, payload, created_at FROM task_events WHERE task_id = ? ORDER BY id', task.id);
|
|
@@ -979,6 +987,10 @@ export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, stran
|
|
|
979
987
|
const p = json(row.payload, {}) ?? {};
|
|
980
988
|
out.push({ kind: 'notice_abandoned', severity: 'warning', title: `the notice of event ${p.source_event} to ${p.address} was abandoned after ${p.attempts} attempts: ${p.error ?? 'no reason recorded'}`, data: { event: p.source_event, address: p.address, attempts: p.attempts }, actions: [] });
|
|
981
989
|
}
|
|
990
|
+
// publications of the card the machine's connector abandoned: the team refused each one past its attempts
|
|
991
|
+
for (const a of (publishing?.abandoned ?? []).filter((a) => a.card === task.id).slice(0, 20)) {
|
|
992
|
+
out.push({ kind: 'publication_abandoned', severity: 'warning', title: `its ${a.kind} (sequence ${a.sequence}) was not published to the team: refused ${a.attempts} times (${a.status} ${a.code ?? ''}): ${a.error}`, data: { publication: a.publication, sequence: a.sequence, status: a.status, code: a.code }, actions: [] });
|
|
993
|
+
}
|
|
982
994
|
const order = ['critical', 'error', 'warning'];
|
|
983
995
|
return out.sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity));
|
|
984
996
|
}
|
package/board/event-stream.mjs
CHANGED
|
@@ -81,9 +81,11 @@ export async function* followBoardPublications({root,after,signal}) {
|
|
|
81
81
|
// The transaction's watermark fences concurrent mutations. Replay
|
|
82
82
|
// after it repairs any head updated while this inventory is read.
|
|
83
83
|
for(const sequence of boundary.heads){
|
|
84
|
-
const row=board.db.prepare('SELECT snapshot FROM supercode_publications WHERE sequence=?').get(sequence);
|
|
84
|
+
const row=board.db.prepare('SELECT id,kind,sequence,snapshot FROM supercode_publications WHERE sequence=?').get(sequence);
|
|
85
85
|
if(!row)throw resync('Workflow snapshot unavailable');
|
|
86
|
-
|
|
86
|
+
// each item names the publication it is (its id, kind and sequence), as an event does, so a follower that
|
|
87
|
+
// republishes it keys and orders it the same way however it first read it
|
|
88
|
+
yield {type:'snapshot_item',id:row.id,kind:row.kind,sequence:row.sequence,data:{board:slug,card:JSON.parse(row.snapshot)}};
|
|
87
89
|
}
|
|
88
90
|
positions[slug]={log:boundary.log,sequence:boundary.high};
|
|
89
91
|
}else{
|
|
@@ -91,7 +93,7 @@ export async function* followBoardPublications({root,after,signal}) {
|
|
|
91
93
|
if(!position||position.log!==boundary.log||!Number.isSafeInteger(position.sequence)||position.sequence<boundary.floor||position.sequence>boundary.high)throw resync(`Workflow publication log changed: ${slug}`);
|
|
92
94
|
for(;;){
|
|
93
95
|
const rows=board.db.prepare('SELECT * FROM supercode_publications WHERE sequence>? ORDER BY sequence LIMIT 32').all(positions[slug].sequence);if(!rows.length)break;
|
|
94
|
-
for(const row of rows){positions[slug].sequence=row.sequence;yield {type:'event',id:row.id,kind:row.kind,cursor:cursorOf(home,positions),data:{board:slug,card:JSON.parse(row.snapshot)}};}
|
|
96
|
+
for(const row of rows){positions[slug].sequence=row.sequence;yield {type:'event',id:row.id,kind:row.kind,sequence:row.sequence,cursor:cursorOf(home,positions),data:{board:slug,card:JSON.parse(row.snapshot)}};}
|
|
95
97
|
}
|
|
96
98
|
}
|
|
97
99
|
}finally{board.close();const paths=backingPaths(root,slug);for(const path of [paths.sqlite,`${paths.sqlite}-wal`])settled.set(path,statOf(path));}
|
package/board/machine-fleet.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// The fleet as each machine the board works on answers for itself: its panes (`harness.v1.panes.list`) and the harness
|
|
2
2
|
// session beneath each live pane's process (`harness.v1.sessions.activity_under`). This is the reading the machine's
|
|
3
|
-
// connector publishes to Teams, taken at its source.
|
|
4
|
-
// (
|
|
3
|
+
// connector publishes to Teams, taken at its source. Each machine's reading also says what a board launch needs of it
|
|
4
|
+
// (`capabilities`: its board process guard's harnesses, and whether it has the keyed launch door). Teams' session feed
|
|
5
|
+
// is for viewers; dispatch never waits on it (D137, D138).
|
|
5
6
|
//
|
|
6
7
|
// Every machine is read at once, each within DEADLINE_MS (bin/machine-fleet.mjs), so one hung machine or relay costs a
|
|
7
8
|
// reading that long, not the sum of them. A machine that answered is `online`. A remote machine that did not is
|
|
@@ -20,10 +21,11 @@ export function readMachineFleet(machines, local) {
|
|
|
20
21
|
encoding: 'utf8', timeout: DEADLINE_MS + 5_000, maxBuffer: 64 * 1024 * 1024 });
|
|
21
22
|
let rows = null;
|
|
22
23
|
try { rows = JSON.parse(String(reply.stdout).trim().split('\n').pop()); } catch { /* no reading */ }
|
|
23
|
-
|
|
24
|
+
// `panes`: each answering machine's live pane ids, whether or not a session was read in them
|
|
25
|
+
const online = new Set(), unknown = new Set(), alive = new Set(), native = new Map(), sessions = [], capabilities = new Map(), panes = new Map();
|
|
24
26
|
if (!Array.isArray(rows)) {
|
|
25
27
|
const error = String(reply.stderr || reply.error?.message || 'no answer').trim().slice(-300);
|
|
26
|
-
return { ok: false, presenceKnown: false, online, unknown: new Set(names), alive, native, sessions, error: `the fleet reading did not answer: ${error}` };
|
|
28
|
+
return { ok: false, presenceKnown: false, online, unknown: new Set(names), alive, native, sessions, capabilities, panes, error: `the fleet reading did not answer: ${error}` };
|
|
27
29
|
}
|
|
28
30
|
let localError = null;
|
|
29
31
|
for (const row of rows) {
|
|
@@ -33,7 +35,9 @@ export function readMachineFleet(machines, local) {
|
|
|
33
35
|
continue;
|
|
34
36
|
}
|
|
35
37
|
online.add(row.machine);
|
|
38
|
+
capabilities.set(row.machine, row.capabilities ?? { guard: [], launches: false });
|
|
36
39
|
const live = (row.panes ?? []).filter((pane) => !pane.dead && pane.pid);
|
|
40
|
+
panes.set(row.machine, new Set((row.panes ?? []).filter((pane) => !pane.dead).map((pane) => pane.pane)));
|
|
37
41
|
for (const { pid, activity } of row.activities ?? []) {
|
|
38
42
|
const pane = live.find((item) => item.pid === pid);
|
|
39
43
|
if (!pane || !activity?.session_id || !activity.harness) continue;
|
|
@@ -45,5 +49,5 @@ export function readMachineFleet(machines, local) {
|
|
|
45
49
|
}
|
|
46
50
|
}
|
|
47
51
|
const ok = online.has(local);
|
|
48
|
-
return { ok, presenceKnown: ok, online, unknown, alive, native, sessions, error: ok ? null : `machine ${local}'s door did not answer: ${localError ?? 'no row'}` };
|
|
52
|
+
return { ok, presenceKnown: ok, online, unknown, alive, native, sessions, capabilities, panes, error: ok ? null : `machine ${local}'s door did not answer: ${localError ?? 'no row'}` };
|
|
49
53
|
}
|
package/board/published.mjs
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
// The card writer commits the full snapshot and a retained delivery obligation together.
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
-
import { realpathSync } from 'node:fs';
|
|
4
3
|
export const PUBLICATION_SCHEMA = `
|
|
5
|
-
CREATE TABLE IF NOT EXISTS supercode_publication_receipts (
|
|
6
|
-
event_id TEXT NOT NULL, destination TEXT NOT NULL, PRIMARY KEY(event_id,destination)
|
|
7
|
-
);
|
|
8
4
|
CREATE TABLE IF NOT EXISTS supercode_publications (
|
|
9
5
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, resource_id TEXT NOT NULL,
|
|
10
6
|
kind TEXT NOT NULL, snapshot TEXT NOT NULL, actor TEXT, created_at INTEGER NOT NULL
|
|
@@ -103,39 +99,8 @@ export function recordCardPublications(db,actor) {
|
|
|
103
99
|
if(history>2*RETAINED_CHANGES){
|
|
104
100
|
const cut=Number(db.prepare('SELECT sequence FROM supercode_publications WHERE sequence NOT IN (SELECT sequence FROM supercode_publication_heads) ORDER BY sequence DESC LIMIT 1 OFFSET ?').get(RETAINED_CHANGES).sequence);
|
|
105
101
|
const old='SELECT id FROM supercode_publications WHERE sequence<=? AND sequence NOT IN (SELECT sequence FROM supercode_publication_heads)';
|
|
106
|
-
db.prepare(`DELETE FROM supercode_publication_receipts WHERE event_id IN (${old})`).run(cut);
|
|
107
102
|
db.prepare(`DELETE FROM supercode_publications WHERE id IN (${old})`).run(cut);
|
|
108
103
|
db.prepare('UPDATE supercode_publication_log SET pruned_through=MAX(pruned_through,?)').run(cut);
|
|
109
104
|
}
|
|
110
105
|
return changed;
|
|
111
106
|
}
|
|
112
|
-
export const boardSourceName = (root,slug) => `board:${hash(realpathSync(root)).slice(0,24)}:${slug}`;
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* The card as one published event may carry it (`limit`: the bytes its `data`, `{ board, card }`, may serialize to).
|
|
116
|
-
* The board keeps every card whole; a card too large to send keeps its newest history: the oldest comments, attempts
|
|
117
|
-
* and reviews go first, then the body's tail, and `omitted` counts what the event leaves out.
|
|
118
|
-
*/
|
|
119
|
-
export function publishableCard(card,board,limit) {
|
|
120
|
-
const bytes=value=>Buffer.byteLength(JSON.stringify(value));
|
|
121
|
-
const size=value=>bytes({board,card:value});
|
|
122
|
-
if(size(card)<=limit)return card;
|
|
123
|
-
const omitted={comments:0,attempts:0,reviews:0,body_chars:0};
|
|
124
|
-
const fitted={...card,omitted};
|
|
125
|
-
// Counted once per item (its bytes and a separator), then checked whole: a card of megabytes fits in one pass.
|
|
126
|
-
let over=size(fitted)-limit;
|
|
127
|
-
for(const field of ['comments','attempts','reviews']){
|
|
128
|
-
const items=card[field]??[];let drop=0;
|
|
129
|
-
while(drop<items.length&&over>0){over-=bytes(items[drop])+1;drop+=1;}
|
|
130
|
-
fitted[field]=items.slice(drop);omitted[field]=drop;
|
|
131
|
-
}
|
|
132
|
-
for(const field of ['comments','attempts','reviews']){
|
|
133
|
-
while(fitted[field].length&&size(fitted)>limit){fitted[field]=fitted[field].slice(1);omitted[field]+=1;}
|
|
134
|
-
}
|
|
135
|
-
const body=String(card.body??'');
|
|
136
|
-
for(let keep=body.length;size(fitted)>limit&&keep>0;){
|
|
137
|
-
keep=Math.max(0,Math.min(keep-1,keep-(size(fitted)-limit)-64));
|
|
138
|
-
fitted.body=body.slice(0,keep);omitted.body_chars=body.length-keep;
|
|
139
|
-
}
|
|
140
|
-
return fitted;
|
|
141
|
-
}
|
package/board/wake.mjs
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import { watch, existsSync, readFileSync, readdirSync, writeFileSync, renameSync, unlinkSync, realpathSync } from 'node:fs';
|
|
4
4
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
|
-
import { boardEventRelay } from './event-relay.mjs';
|
|
7
6
|
import { fileURLToPath } from 'node:url';
|
|
8
7
|
|
|
9
8
|
export function signalBoardWrite(root, actor) {
|
|
@@ -20,13 +19,11 @@ export function signalBoardWrite(root, actor) {
|
|
|
20
19
|
export function watchBoard({ root, binary, env, log = () => {} }) {
|
|
21
20
|
const watchers = new Map();
|
|
22
21
|
let pending = null, waiter = null, closed = false;
|
|
23
|
-
let relay = null;
|
|
24
22
|
const wake = (source, at = Date.now()) => {
|
|
25
23
|
if (closed) return;
|
|
26
24
|
if (!pending) pending = { at, sources: new Set() };
|
|
27
25
|
pending.at = Math.min(pending.at, at);
|
|
28
26
|
pending.sources.add(source);
|
|
29
|
-
void relay?.wake();
|
|
30
27
|
waiter?.();
|
|
31
28
|
};
|
|
32
29
|
const directory = (path, key, accept) => {
|
|
@@ -91,9 +88,6 @@ export function watchBoard({ root, binary, env, log = () => {} }) {
|
|
|
91
88
|
}
|
|
92
89
|
};
|
|
93
90
|
refresh();
|
|
94
|
-
const supercodeHome = env.SUPERCODE_HOME || (env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME,'supercode') : join(env.HOME ?? '', '.config','supercode'));
|
|
95
|
-
relay = boardEventRelay({root,supercodeHome,log});
|
|
96
|
-
void relay.wake();
|
|
97
91
|
return {
|
|
98
92
|
refresh,
|
|
99
93
|
wake,
|
|
@@ -109,7 +103,7 @@ export function watchBoard({ root, binary, env, log = () => {} }) {
|
|
|
109
103
|
return result ? { at: result.at, sources: [...result.sources] } : null;
|
|
110
104
|
},
|
|
111
105
|
close() {
|
|
112
|
-
closed = true; waiter?.();
|
|
106
|
+
closed = true; waiter?.();
|
|
113
107
|
for (const handle of watchers.values()) handle.close();
|
|
114
108
|
watchers.clear();
|
|
115
109
|
},
|
package/board/workflow.mjs
CHANGED
|
@@ -374,9 +374,6 @@ function queueTransitionNotice(db, board, entry, task, from, name, payload, befo
|
|
|
374
374
|
const afterState = beforeRun && json(one(db, 'SELECT metadata FROM task_runs WHERE id=?', beforeRun.id)?.metadata, {})?.supercode?.state;
|
|
375
375
|
if (!beforeRun || (state(task, from) === state(after, to) && beforeState === afterState) || effects.some((e) => e.kind === 'session_notice' && e.runId === beforeRun.id)) return;
|
|
376
376
|
const session = json(beforeRun.metadata, {})?.supercode;
|
|
377
|
-
const cancellation = session?.launch_cancellation;
|
|
378
|
-
if (cancellation?.key === session?.launch_key && cancellation?.status === 'cancelled' && cancellation?.cancelled === true &&
|
|
379
|
-
cancellation?.outcome === 'not_issued' && cancellation?.live === false && cancellation?.pane === null) return;
|
|
380
377
|
const address = session?.address ?? (session?.session_id && session.machine ? `sc:${session.machine}:${session.harness ?? 'claude-code'}:${session.session_id}` : null);
|
|
381
378
|
if (!address && !session?.launch_key && !session?.pane) return;
|
|
382
379
|
// The session that made this change learns it from its own command's answer; a notice about
|
package/board.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
15
15
|
import { summary } from './board/cli.mjs';
|
|
16
16
|
import { commandEnv, notifyChats, startDelivery, supercodeBin } from './board/dispatch.mjs';
|
|
17
17
|
import { watchBoard } from './board/wake.mjs';
|
|
18
|
+
import { runtimeDoor } from './board/runtime-door.mjs';
|
|
18
19
|
import { workflowEntry } from './board/workflow.mjs';
|
|
19
20
|
import { evaluate, holds } from './board/expr.mjs';
|
|
20
21
|
|
|
@@ -101,6 +102,9 @@ export async function startBoard({ root, orchestration = null, log = () => {}, a
|
|
|
101
102
|
}
|
|
102
103
|
};
|
|
103
104
|
const events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log: message => log('warn', message) });
|
|
105
|
+
// The board's events are published by this machine's connector (D137, D138 step 4): the home is named to it once.
|
|
106
|
+
{ const named = runtimeDoor(null, 'harness.v1.boards.register', { root }, { timeout: 30_000 });
|
|
107
|
+
log(named.code === 0 ? 'info' : 'warn', named.code === 0 ? 'board: its events are published by the machine\'s connector' : `board: events not published; the machine daemon did not take this home: ${(named.stderr || named.stdout).trim().split('\n').at(-1)}`); }
|
|
104
108
|
void (async () => {
|
|
105
109
|
let wakeReason = null;
|
|
106
110
|
while (!stopped) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.42",
|
|
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": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@volter/supercode-harness-sdk": "^0.3.60",
|
|
28
|
-
"@volter/teams": "0.2.
|
|
28
|
+
"@volter/teams": "0.2.14",
|
|
29
29
|
"@volter/ztrack": "^2.4.23",
|
|
30
30
|
"yaml": "^2.9.0"
|
|
31
31
|
},
|
package/board/event-relay.mjs
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
// App-owned source names/maps live here; the Teams core knows only a scoped event publisher.
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import { eventClient, spentClause } from '@volter/teams/event-client';
|
|
4
|
-
import { EVENT_DATA_LIMIT } from '@volter/teams/event-limits';
|
|
5
|
-
import { boardSourceName, publishableCard, recordCardPublications } from './published.mjs';
|
|
6
|
-
import { listBoards, openBoard } from './store.mjs';
|
|
7
|
-
|
|
8
|
-
export function boardEventRelay({root,context,supercodeHome,log=()=>{}}) {
|
|
9
|
-
let work=null,dirty=false,closed=false,retry=null,reconcile=true,heldUntil=0;const sources=new Map();
|
|
10
|
-
const run=async()=>{
|
|
11
|
-
if(reconcile){
|
|
12
|
-
// Establish every native source head before any network operation. A
|
|
13
|
-
// delivery retry cannot leave later boards without their initial inventory.
|
|
14
|
-
for(const slug of listBoards(root)){const board=openBoard(root,slug);try{board.tx(db=>recordCardPublications(db,null));}finally{board.close();}}
|
|
15
|
-
reconcile=false;
|
|
16
|
-
}
|
|
17
|
-
const {team,context:audience}=eventClient({context,supercodeHome});
|
|
18
|
-
for(const slug of listBoards(root)){
|
|
19
|
-
const board=openBoard(root,slug);
|
|
20
|
-
try{
|
|
21
|
-
const key=JSON.stringify({server:audience.server,org:audience.org,principal:audience.principal,board:slug});
|
|
22
|
-
let source=sources.get(key);
|
|
23
|
-
if(!source){source=(await team.events.createSource({name:boardSourceName(root,slug)},{caller:'board-relay source'})).data.id;sources.set(key,source);}
|
|
24
|
-
const destination=createHash('sha256').update(`${key}:${source}`).digest('hex');
|
|
25
|
-
for(;;){
|
|
26
|
-
const rows=board.db.prepare('SELECT * FROM supercode_publications p WHERE NOT EXISTS (SELECT 1 FROM supercode_publication_receipts r WHERE r.event_id=p.id AND r.destination=?) ORDER BY sequence LIMIT 32').all(destination);if(!rows.length)break;
|
|
27
|
-
for(const row of rows){
|
|
28
|
-
if(closed)return;
|
|
29
|
-
const id=`event_${createHash('sha256').update(`${row.id}:${destination}`).digest('hex')}`;
|
|
30
|
-
await team.events.publish(source,{id,kind:row.kind,resource:{kind:'task',id:`${source}:${row.resource_id}`},revision:row.sequence,data:{board:slug,card:publishableCard(JSON.parse(row.snapshot),slug,EVENT_DATA_LIMIT)},actor:{session:row.actor?`session:${row.actor}`:null}},{idempotencyKey:id,caller:'board-relay publish'})
|
|
31
|
-
.catch(error=>{throw Object.assign(error,{message:`${row.resource_id} ${row.kind} (sequence ${row.sequence}): ${error.message}`});});
|
|
32
|
-
board.tx(db=>db.prepare('INSERT OR IGNORE INTO supercode_publication_receipts VALUES (?,?)').run(row.id,destination));
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}finally{board.close();}
|
|
36
|
-
}
|
|
37
|
-
};
|
|
38
|
-
const wake=(refresh=true)=>{
|
|
39
|
-
reconcile ||= refresh;dirty=true;if(work||closed)return work;
|
|
40
|
-
// Refused for the rate limit, delivery waits out the server's retry-after: a board write meanwhile would only ask
|
|
41
|
-
// again and spend the allowance the credential shares with every other caller.
|
|
42
|
-
if(Date.now()<heldUntil)return null;
|
|
43
|
-
work=(async()=>{while(dirty&&!closed){dirty=false;await run();}})().catch(error=>{
|
|
44
|
-
log(`board event delivery pending: ${error.code?`${error.code}: `:''}${error.message}${spentClause(error)}`);
|
|
45
|
-
// Retry durable delivery, not a read of the board's current state.
|
|
46
|
-
const wait=error.code==='rate_limited'?Math.max(5000,Number(error.details?.retry_after_seconds??30)*1000):5000;heldUntil=Date.now()+wait;
|
|
47
|
-
clearTimeout(retry);retry=setTimeout(()=>{heldUntil=0;wake(false);},wait);retry.unref?.();
|
|
48
|
-
}).finally(()=>{work=null;});return work;
|
|
49
|
-
};
|
|
50
|
-
return {wake,close(){closed=true;clearTimeout(retry);}};
|
|
51
|
-
}
|