@volter/supercode-orchestrator 0.5.46 → 0.5.47
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/runtime.mjs +4 -12
- package/board/cli.mjs +2 -25
- package/board/dispatch.mjs +64 -21
- package/board/wake.mjs +1 -1
- package/loop.mjs +3 -14
- package/ontology.d.ts +77 -19
- package/package.json +1 -1
- package/board.mjs +0 -125
package/bin/runtime.mjs
CHANGED
|
@@ -674,20 +674,13 @@ async function main() {
|
|
|
674
674
|
return;
|
|
675
675
|
}
|
|
676
676
|
const root = resolve(args.root ?? defaultRoot());
|
|
677
|
-
// `SUPERCODE_BOARD_DISPATCHER=serve`: whoever started this daemon also runs `workflow serve` on the home, the board's
|
|
678
|
-
// one dispatcher, so this daemon runs no dispatch round (board.mjs). It is this process's alone: the sessions it
|
|
679
|
-
// launches do not inherit it. Any other value is refused before this daemon takes its lease or a lock.
|
|
680
|
-
const boardDispatcher = process.env.SUPERCODE_BOARD_DISPATCHER || null;
|
|
681
|
-
delete process.env.SUPERCODE_BOARD_DISPATCHER;
|
|
682
|
-
if (boardDispatcher !== null && boardDispatcher !== 'serve') {
|
|
683
|
-
say('error', { root, message: `SUPERCODE_BOARD_DISPATCHER is \`${boardDispatcher}\`: the one value it takes is \`serve\` (a \`workflow serve\` beside this daemon dispatches the board); unset, the board workflow's dispatch.serve decides` });
|
|
684
|
-
process.exit(2);
|
|
685
|
-
}
|
|
686
677
|
let crashed = false;
|
|
687
678
|
let gatewayLock = null;
|
|
688
679
|
if (!args.once) {
|
|
689
|
-
//
|
|
690
|
-
//
|
|
680
|
+
// One daemon per home: its lease (and the acquisition guard serializing a lease's replacement) keeps a second daemon
|
|
681
|
+
// from firing the home's cron jobs, holding its channel adapters and driving its bindings twice, and tells a restart
|
|
682
|
+
// that its predecessor crashed (§4.7). None of it guards the board, whose one dispatcher is `workflow serve`. An
|
|
683
|
+
// acquisition guard left by a crash fails closed; inspect it before removing it manually.
|
|
691
684
|
// A service manager's restart starts this one while the last is still stopping (launchd's kickstart -k did, and
|
|
692
685
|
// this one exited 'already serving'): a live predecessor gets a moment to let go before it counts as serving.
|
|
693
686
|
for (let waited = 0; waited < LEASE_HANDOFF_MS; waited += 250) {
|
|
@@ -756,7 +749,6 @@ async function main() {
|
|
|
756
749
|
// build's, not whatever `supercode` a PATH lookup happens to find.
|
|
757
750
|
const client = new SupercodeHarnessClient(process.env.SUPERCODE_BIN ? { command: process.env.SUPERCODE_BIN } : {});
|
|
758
751
|
const orchestrator = new Orchestrator({
|
|
759
|
-
boardDispatcher,
|
|
760
752
|
executeJob: args.executorUrl ? httpJobExecutor({ url: args.executorUrl, tokenFile: args.executorTokenFile, root }) : null,
|
|
761
753
|
state,
|
|
762
754
|
vault,
|
package/board/cli.mjs
CHANGED
|
@@ -1479,31 +1479,8 @@ async function serve(root, args = {}) {
|
|
|
1479
1479
|
let wakeReason = null;
|
|
1480
1480
|
try {
|
|
1481
1481
|
while (!stopped) {
|
|
1482
|
-
//
|
|
1483
|
-
//
|
|
1484
|
-
// A replacement serve sees the same request before its first tick.
|
|
1485
|
-
// A request whose installer is gone (its pane closed, or it was killed before clearing it) is void.
|
|
1486
|
-
const installation = join(root, 'installation-window.json');
|
|
1487
|
-
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
1488
|
-
const request = existsSync(installation) ? readJson(installation) : null;
|
|
1489
|
-
const installerAlive = (pid) => { try { process.kill(Number(pid), 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
1490
|
-
if (request?.token && request.pid && !installerAlive(request.pid)) {
|
|
1491
|
-
log(`installation window ${request.token} of ${request.owner} is void: its installer ${request.pid} is gone`);
|
|
1492
|
-
for (const path of [installation, join(root, 'installation-window.ack.json')]) if (readJson(path)?.token === request.token) try { unlinkSync(path); } catch { /* gone */ }
|
|
1493
|
-
} else if (request?.token) {
|
|
1494
|
-
const acknowledgment = join(root, 'installation-window.ack.json');
|
|
1495
|
-
const previous = readJson(acknowledgment);
|
|
1496
|
-
if (previous?.token !== request.token || previous?.pid !== process.pid) {
|
|
1497
|
-
const temporary = `${acknowledgment}.${process.pid}`;
|
|
1498
|
-
writeFileSync(temporary, JSON.stringify({ token: request.token, pid: process.pid, at: Date.now() }));
|
|
1499
|
-
renameSync(temporary, acknowledgment);
|
|
1500
|
-
log(`installation window held by ${request.owner}; dispatch waits`);
|
|
1501
|
-
}
|
|
1502
|
-
await new Promise(resolve => setTimeout(resolve, 250));
|
|
1503
|
-
continue;
|
|
1504
|
-
}
|
|
1505
|
-
// A serve never ticks on code older than the installed orchestrator, also right after an
|
|
1506
|
-
// installation window: its keeper starts the new one, whose board store this one does not keep.
|
|
1482
|
+
// A serve never ticks on code older than the installed orchestrator: after its round it stops, and its keeper
|
|
1483
|
+
// starts the new one. Nothing waits for an installation; a second round, old or new, is harmless.
|
|
1507
1484
|
const replaced = (() => { const now = installed(); return running && now && now !== running ? now : null; })();
|
|
1508
1485
|
if (replaced) { log(`orchestrator ${replaced} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
|
|
1509
1486
|
pausedSaid = Boolean(paused(root)) && pausedSaid;
|
package/board/dispatch.mjs
CHANGED
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
// - `headless`: the profile's worker for one query on this machine (`bin/hermes-worker.mjs`, Hermes's worker
|
|
13
13
|
// argv and task environment), watched by its pid as Hermes watches its own.
|
|
14
14
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
15
|
+
// `workflow serve` is the board's one dispatcher, and a second round beside it (another serve, a one-shot
|
|
16
|
+
// `workflow dispatch`) is harmless: every act of a round is a compare-and-set claim, a transaction that re-reads what it
|
|
17
|
+
// acts on and does nothing when another round already acted (see `tick`). Every side effect follows a committed claim, so
|
|
18
|
+
// a crash between the two steps is answered by the next round.
|
|
18
19
|
import { monitorsRuns, failedObservation, surfaceIncident, auditEndedRuns, originOf } from './incidents.mjs';
|
|
19
20
|
import { readMachineFleet } from './machine-fleet.mjs';
|
|
20
21
|
let fleetRoot = null;
|
|
@@ -309,12 +310,16 @@ function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
309
310
|
if (!session?.launch_key || !fleet.ok || sessionAlive(fleet, session) !== false) continue;
|
|
310
311
|
// how a keyed launch ended, where its machine keeps that record: read when it can be, never waited on
|
|
311
312
|
if (!fleet.capabilities?.get(normalMachine(session.machine))?.launches) continue;
|
|
312
|
-
try { const receipt = launchReceipt(board, session); if (receipt?.live === false && receipt.exit) exits.set(id, receipt.exit); }
|
|
313
|
+
try { const receipt = launchReceipt(board, session); if (receipt?.live === false && receipt.exit) exits.set(id, { ...receipt.exit, launch_key: session.launch_key }); }
|
|
313
314
|
catch (error) { report.failed.push([id, `exit receipt unavailable; retaining liveness observation: ${error.message}`]); }
|
|
314
315
|
}
|
|
315
316
|
for (const { id } of rows) readBefore(fleet, currentRun(board.db, id)?.metadata?.supercode);
|
|
316
317
|
for (const { id } of rows) {
|
|
318
|
+
// A transaction that meets another round's write runs again from its start: what it asks for is staged per attempt
|
|
319
|
+
// and kept only once it committed, so a second round never carries out a move that was rolled back.
|
|
320
|
+
let staged = [];
|
|
317
321
|
txRead(board, (db) => {
|
|
322
|
+
staged = [];
|
|
318
323
|
const task = getTask(db, id);
|
|
319
324
|
if (!task || (entry.workflow.run ? !task.current_run_id : task.status !== 'running')) return;
|
|
320
325
|
const runRow = currentRun(db, id);
|
|
@@ -334,7 +339,7 @@ function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
334
339
|
return;
|
|
335
340
|
}
|
|
336
341
|
const r = fire(db, board, entry, id, name, { roles: ['platform'], payload });
|
|
337
|
-
|
|
342
|
+
staged.push(...r.effects);
|
|
338
343
|
if (bucket) report[bucket].push(id);
|
|
339
344
|
if (r.to !== r.from && entry.workflow.statuses[r.to]?.stored_as === 'blocked') report.auto_blocked.push(id);
|
|
340
345
|
} catch (error) {
|
|
@@ -419,7 +424,8 @@ function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
419
424
|
return;
|
|
420
425
|
}
|
|
421
426
|
if (session?.session_id) {
|
|
422
|
-
|
|
427
|
+
// an exit read for this run's own launch, never one an earlier launch of the card left
|
|
428
|
+
if(exits.has(id)&&exits.get(id).launch_key===session.launch_key) {
|
|
423
429
|
const exit=exits.get(id);
|
|
424
430
|
// an interactive session that ended cleanly was closed (by a person, or by itself): the workflow says what
|
|
425
431
|
// follows, and it is not a crash to restart
|
|
@@ -428,7 +434,7 @@ function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
428
434
|
if (monitorsRuns(entry)) { observe('session_closed', {...ended,exit},'crashed'); return; }
|
|
429
435
|
try {
|
|
430
436
|
const r = fire(db, board, entry, id, 'session_closed', { roles: ['platform'], payload: ended });
|
|
431
|
-
|
|
437
|
+
staged.push(...r.effects); (report.closed_by_hand ??= []).push(id);
|
|
432
438
|
return;
|
|
433
439
|
} catch (error) { if (!error.noTransition) { report.failed.push([id, `session_closed: ${String(error.message).slice(-200)}`]); return; } }
|
|
434
440
|
}
|
|
@@ -462,6 +468,7 @@ function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
462
468
|
// the claim's own expiry answers it, as Hermes's does
|
|
463
469
|
if (task.claim_expires && Number(task.claim_expires) < t) observe('orphaned', { stale_lock: task.claim_lock, claim_expires: task.claim_expires, error: `stale_lock=${task.claim_lock}` }, 'reclaimed');
|
|
464
470
|
});
|
|
471
|
+
effects.push(...staged);
|
|
465
472
|
}
|
|
466
473
|
}
|
|
467
474
|
|
|
@@ -660,6 +667,17 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
|
|
|
660
667
|
try {
|
|
661
668
|
checked = checkWorkspace(board, task);
|
|
662
669
|
moved = tx(board, (db) => {
|
|
670
|
+
// The claim: another round (a second serve, a one-shot dispatch) may have started this card, or others that count
|
|
671
|
+
// against the same caps, since this round read the board. Its starts are counted, and this card starts only if it
|
|
672
|
+
// still waits where it did and still fits.
|
|
673
|
+
const now = getTask(db, task.id);
|
|
674
|
+
if (!now || now.current_run_id || now.archived || statusOf(db, now) !== scope.card.status) throw Object.assign(new Error('started or moved by another round'), { claimedElsewhere: true });
|
|
675
|
+
for (const row of db.prepare('SELECT t.id, t.assignee, t.model_override, c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.current_run_id IS NOT NULL').all()) {
|
|
676
|
+
const lane = lanes[row.assignee] ?? {}, status = statusOf(db, row);
|
|
677
|
+
caps.take({ card: row.id, board: board.slug, machine: normalMachine(row.machine ?? localMachine()), assignee: row.assignee ?? null, status, stored: status, model: row.model_override ?? lane.model ?? null, harness: lane.harness ?? null });
|
|
678
|
+
}
|
|
679
|
+
const refused = caps.admit(waitingEntries[i], scope, waitingEntries);
|
|
680
|
+
if (refused) throw Object.assign(new Error(refused.why), { claimedElsewhere: true });
|
|
663
681
|
assertWorkspaceOwner(db, task, checked);
|
|
664
682
|
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, task.id);
|
|
665
683
|
if (!task.assignee && plan.profile) {
|
|
@@ -670,7 +688,8 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
|
|
|
670
688
|
});
|
|
671
689
|
} catch (error) {
|
|
672
690
|
const reason = error.refusal ?? String(error.message).slice(-300);
|
|
673
|
-
|
|
691
|
+
// another round's start is that round's to say; this one writes nothing for it
|
|
692
|
+
if (!error.claimedElsewhere) tx(board, (db) => noteGuard(db, task.id, reason));
|
|
674
693
|
report.queued.push([task.id, reason]);
|
|
675
694
|
continue;
|
|
676
695
|
}
|
|
@@ -927,7 +946,8 @@ export function closeRequested(board, report, { taskId = null, caller = null, tt
|
|
|
927
946
|
// Retain every attempt's latest fact, but avoid an unbounded thread of identical failures.
|
|
928
947
|
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);
|
|
929
948
|
if (final) event(db, row.task_id, 'run_incident', { observation: 'close_failed', run: row.id, reason: `the board could not close ${addressOf(session)} and will not try again: ${error}`, session: session.session_id, machine: session.machine, pane: session.pane ?? null, evidence: { error } }, row.id);
|
|
930
|
-
} else {
|
|
949
|
+
} else if (!current.close_confirmed_at) {
|
|
950
|
+
// a close another closer already confirmed (a claim that expired under it) is said once, by that closer
|
|
931
951
|
const { close_error: _error, ...rest } = current;
|
|
932
952
|
setRunSession(db, row.id, { ...rest, close_error: null, closed_at: at, close_confirmed_at: at });
|
|
933
953
|
const address = addressOf(session);
|
|
@@ -964,7 +984,9 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
964
984
|
const running = board.db.prepare(entry.workflow.run ? "SELECT id FROM tasks WHERE current_run_id IS NOT NULL" : "SELECT id FROM tasks WHERE status = 'running'").all();
|
|
965
985
|
for (const { id } of running) { const session = currentRun(board.db, id)?.metadata?.supercode; readBefore(fleet, session); if (session?.session_id) updatedAt(session); }
|
|
966
986
|
for (const { id } of running) {
|
|
987
|
+
let staged = [];
|
|
967
988
|
txRead(board, (db) => {
|
|
989
|
+
staged = [];
|
|
968
990
|
const task = getTask(db, id);
|
|
969
991
|
const status = statusOf(db, task);
|
|
970
992
|
if (!(entry.workflow.statuses[status]?.on?.session_idle || entry.workflow.on?.session_idle || entry.workflow.run?.on?.session_idle)) return;
|
|
@@ -1024,7 +1046,7 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1024
1046
|
// the ask names what the session may do, as its run's prompt did
|
|
1025
1047
|
const said = doors(entry.workflow, status, session.role ?? runSpec(entry, status)?.role ?? 'implementer', id, where, session.surface !== 'headless' ? 'pane' : 'headless');
|
|
1026
1048
|
const r = fire(db, board, entry, id, 'session_idle', { roles: ['platform'], payload: { ...reading, turns: reading.turns_before, worked }, extra: { where, doors: said } });
|
|
1027
|
-
|
|
1049
|
+
staged.push(...r.effects);
|
|
1028
1050
|
const after = currentRun(db, id);
|
|
1029
1051
|
const stored = json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(runRow.id)?.metadata, {})?.supercode;
|
|
1030
1052
|
const observation = retain('dispatched', { turns_after: Number(stored?.goal_turns ?? 0), nudge_queued: r.effects.some(e => e.kind === 'send_to_actor'), run_ended: after?.id !== runRow.id });
|
|
@@ -1036,6 +1058,7 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1036
1058
|
report.failed.push([id, `session_idle: ${String(error.message).slice(-200)}`]);
|
|
1037
1059
|
}
|
|
1038
1060
|
});
|
|
1061
|
+
effects.push(...staged);
|
|
1039
1062
|
}
|
|
1040
1063
|
}
|
|
1041
1064
|
|
|
@@ -1263,7 +1286,8 @@ function bindLaunch(board, row, session, pane, sid) {
|
|
|
1263
1286
|
return tx(board, (db) => {
|
|
1264
1287
|
const current = db.prepare('SELECT ended_at, metadata FROM task_runs WHERE id=?').get(row.id);
|
|
1265
1288
|
const saved = json(current?.metadata, {})?.supercode;
|
|
1266
|
-
|
|
1289
|
+
// a launch another round already bound, or whose session was closed, is not bound again
|
|
1290
|
+
if (!saved || saved.launch_key !== session.launch_key || !saved.launch_pending || saved.close_confirmed_at || (saved.session_id && sid && saved.session_id !== sid)) return false;
|
|
1267
1291
|
const address = sid ? `sc:${session.machine}:${session.harness}:${sid}` : null;
|
|
1268
1292
|
setRunSession(db, row.id, { ...saved, pane, session_id: sid ?? saved.session_id,
|
|
1269
1293
|
...(address ? { address, launch_pending: false, launch_error: null, seen_at: now() } : {}) });
|
|
@@ -1364,14 +1388,20 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes
|
|
|
1364
1388
|
// clock moves with the wait instead of running out (every resumed card failed so in the 22:17Z event-stream outage).
|
|
1365
1389
|
for(const {id,run_id,launched_at} of deferred) {
|
|
1366
1390
|
if (now() - launched_at >= settings.launchWindow) continue;
|
|
1367
|
-
|
|
1391
|
+
// the run's launch is claimed before anything is done for it: another round's claim, or its launch, skips it here
|
|
1392
|
+
const stillDeferred=db=>{const run=db.prepare('SELECT ended_at,metadata FROM task_runs WHERE id=?').get(run_id);return Boolean(run&&!run.ended_at&&getTask(db,id)?.current_run_id===run_id&&json(run.metadata,{})?.supercode?.launch_deferred);};
|
|
1393
|
+
if (!fleet.ok) { tx(board,db=>{if(stillDeferred(db))setRunSession(db,run_id,{launched_at:now()});}); report.queued.push([id,`launch waits for the fleet: ${fleet.error}`]); continue; }
|
|
1368
1394
|
try {
|
|
1369
1395
|
const task=getTaskNow(board,id),status=view(board,db=>statusOf(db,task)),spec=runSpec(entry,status,task);
|
|
1370
1396
|
const profile=spec&&view(board,db=>evaluate(entry.workflow.roles?.[spec.role]??'card.assignee',scopeOf(db,board,task,{params:entry.params})));
|
|
1371
1397
|
const lane=lanes?.[profile];if(!lane){report.queued.push([id,'launch waits for its configured lane']);continue;}
|
|
1372
1398
|
const why=launcherCheck(board.db,task,lane,fleet);if(why){report.queued.push([id,why]);continue;}
|
|
1399
|
+
const claimed=tx(board,db=>{if(!stillDeferred(db))return false;setRunSession(db,run_id,{launch_deferred:false});return true;});
|
|
1400
|
+
if(!claimed){report.queued.push([id,'its launch was taken by another round']);continue;}
|
|
1373
1401
|
const session=launchRun(board,entry,id,lane,fleet,settings);
|
|
1374
1402
|
if(session)report.started.push([id,session.address??session.session_id,session.mode]);
|
|
1403
|
+
// nothing to launch now (its status runs nothing yet): the run waits deferred, as before the claim
|
|
1404
|
+
else tx(board,db=>{const run=db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(run_id);const saved=json(run?.metadata,{})?.supercode??{};if(!saved.launch_key&&!saved.pid)setRunSession(db,run_id,{launch_deferred:true});});
|
|
1375
1405
|
}catch(error){
|
|
1376
1406
|
if(error.launchPending){report.queued.push([id,error.message]);continue;}
|
|
1377
1407
|
const moved=tx(board,db=>monitorsRuns(entry)?(surfaceIncident(db,board,entry,id,'spawn_failed',{error:error.message,origin:originOf(error)}),{effects:[]}):fire(db,board,entry,id,'spawn_failed',{roles:['platform'],payload:{error:error.message}}));effects.push(...moved.effects);
|
|
@@ -1396,6 +1426,9 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes
|
|
|
1396
1426
|
if (!row.ended_at && now() - Number(s.launched_at) > settings.grace && launchReading(fleet, s) === 'absent') {
|
|
1397
1427
|
const moved = tx(board, (db) => {
|
|
1398
1428
|
if (getTask(db, row.task_id)?.current_run_id !== row.id) return null;
|
|
1429
|
+
// another round may have bound this launch since the reading: only a launch still pending fails here
|
|
1430
|
+
const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(row.id)?.metadata, {})?.supercode;
|
|
1431
|
+
if (!current?.launch_pending || current.launch_key !== s.launch_key || current.address) return null;
|
|
1399
1432
|
// a fresh launch that never showed in a reading has no session to resume: its next run starts fresh
|
|
1400
1433
|
setRunSession(db, row.id, { launch_pending: false, ...(s.mode === 'fresh' ? { forgotten: true } : {}) });
|
|
1401
1434
|
const payload = { error: `launch ${s.launch_key} ended before session binding` };
|
|
@@ -1567,11 +1600,14 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1567
1600
|
Object.assign(session, meta);
|
|
1568
1601
|
const kind = { adopt: 'session_adopted', resume: 'session_resumed', fresh: replaced ? 'session_replaced' : 'session_started' }[session.mode];
|
|
1569
1602
|
const recorded = tx(board, (db) => {
|
|
1570
|
-
const row = db.prepare('SELECT ended_at FROM task_runs WHERE id = ?').get(runId);
|
|
1603
|
+
const row = db.prepare('SELECT ended_at, metadata FROM task_runs WHERE id = ?').get(runId);
|
|
1571
1604
|
if (row?.ended_at || getTask(db, task.id)?.current_run_id !== runId) {
|
|
1572
1605
|
requestSessionClose(db, task.id, { runId });
|
|
1573
1606
|
return false;
|
|
1574
1607
|
}
|
|
1608
|
+
// this session recorded on the run already (by a binding that got here first) is said once
|
|
1609
|
+
const saved = json(row?.metadata, {})?.supercode;
|
|
1610
|
+
if (saved?.address === session.address && !saved.launch_pending) return true;
|
|
1575
1611
|
setRunSession(db, runId, session);
|
|
1576
1612
|
event(db, task.id, kind, { session_id: session.session_id, machine, pane: session.pane, address: session.address, mode: session.mode, ...(replaced ? { replaced } : {}) }, runId);
|
|
1577
1613
|
// the working session hears updates to its card; the slot's previous session no longer does (a reviewer starting
|
|
@@ -2661,14 +2697,21 @@ export async function deliver({ root, slugs = null }) {
|
|
|
2661
2697
|
|
|
2662
2698
|
/**
|
|
2663
2699
|
* One tick over every board of the home at `root`. `lanes` from `lanesOf`, `settings` from `boardSettings`.
|
|
2664
|
-
* `only` limits starting to one card (a create that answers whether its card started).
|
|
2665
|
-
*
|
|
2666
|
-
*
|
|
2667
|
-
*
|
|
2668
|
-
*
|
|
2669
|
-
* deferred
|
|
2670
|
-
*
|
|
2671
|
-
*
|
|
2700
|
+
* `only` limits starting to one card (a create that answers whether its card started). No dispatcher file lock: two
|
|
2701
|
+
* rounds may run at once, and every act is a claim taken in a transaction that re-reads its basis (a deferred `BEGIN`
|
|
2702
|
+
* that runs again from its start when it meets another round's write):
|
|
2703
|
+
* - a card start: the card still waits where the round read it, with no current run, and still fits the caps once the
|
|
2704
|
+
* runs other rounds started on this board are counted (startCards);
|
|
2705
|
+
* - a deferred launch: the run is still deferred, current and unended (reconcileLaunches), so one round launches it;
|
|
2706
|
+
* - a launch binding: the run's launch is still pending and its session not closed (bindLaunch); a launch's failure at
|
|
2707
|
+
* the end of its grace: still pending and unbound; a launched session is recorded once (launchPane);
|
|
2708
|
+
* - supervision and idle observation: each card re-read in its transaction, the effects a move asks for kept only once
|
|
2709
|
+
* that move committed (a retried attempt's are dropped), an exit receipt applied only to the launch it was read for;
|
|
2710
|
+
* - an adoption, a session close and its confirmation, a manual continuation: claimed (`newClaim`) or re-read.
|
|
2711
|
+
* What two rounds may still both do is read-only or in-memory: a door read inside a retried transaction (an idle
|
|
2712
|
+
* session's screen), a report line. A cap across boards counts other rounds' starts on this board only. A round runs no
|
|
2713
|
+
* effect and delivers nothing: it records effects (`queueEffects`) and the delivery pass (`deliver`) carries them out.
|
|
2714
|
+
* Answers one report per board.
|
|
2672
2715
|
*/
|
|
2673
2716
|
export async function tick({ root, lanes, settings, only = null, slugs = null, dispatch = true, params = {}, deploymentReceipt = false }) {
|
|
2674
2717
|
fleetRoot = root;
|
package/board/wake.mjs
CHANGED
|
@@ -53,7 +53,7 @@ export function watchBoard({ root, binary, env, log = () => {} }) {
|
|
|
53
53
|
try { const receipt = JSON.parse(readFileSync(join(root, name), 'utf8')); wake('board-write', receipt.at); } catch { wake('board-write'); }
|
|
54
54
|
return false;
|
|
55
55
|
});
|
|
56
|
-
for (const name of ['workflow.yaml', 'config.yaml', '
|
|
56
|
+
for (const name of ['workflow.yaml', 'config.yaml', 'ESTOP']) file(join(root, name), name);
|
|
57
57
|
file(fileURLToPath(new URL('../package.json', import.meta.url)), 'installation');
|
|
58
58
|
const boards = join(root, 'kanban', 'boards');
|
|
59
59
|
directory(join(root, 'kanban'), 'board-registry', name => ['boards', 'ztrack.json', 'current'].includes(name));
|
package/loop.mjs
CHANGED
|
@@ -22,7 +22,6 @@ import { mountOrchestratorMcp } from './mcp/mount.mjs';
|
|
|
22
22
|
import { performRemoveProfileDir } from './operator.mjs';
|
|
23
23
|
import { harnessHomes } from './mcp/verbs.mjs';
|
|
24
24
|
import { checkMonitor, runJobScript } from './gates.mjs';
|
|
25
|
-
import { startBoard } from './board.mjs';
|
|
26
25
|
import { SenderIdentityRecord } from '@volter/teams/sender-identity';
|
|
27
26
|
import { agentScope,localProfileAgent,localAgentRecord,localAgentSessions,localForkAgent,handOffAgents,localReaderContext,agentRunForKey,agentPreamble,agentEnvironment,recordAgentRun,recordNativeAgentSession,agentRunForSession } from './agents.mjs';
|
|
28
27
|
import { materializeAgentHome } from './agent-home.mjs';
|
|
@@ -54,10 +53,8 @@ export class Orchestrator {
|
|
|
54
53
|
* @param {(profile, worker, env) => object|undefined} [o.launchFor] launch override per worker (tests)
|
|
55
54
|
* @param {(level, message) => void} [o.log]
|
|
56
55
|
*/
|
|
57
|
-
constructor({ state, vault = new Map(), client, adapters = {}, clock = null, launchFor = null, executeJob = null,
|
|
56
|
+
constructor({ state, vault = new Map(), client, adapters = {}, clock = null, launchFor = null, executeJob = null, log = () => {} }) {
|
|
58
57
|
this.state = state;
|
|
59
|
-
// who dispatches the home's board: 'serve' when a `workflow serve` beside this daemon does (board.mjs)
|
|
60
|
-
this.boardDispatcher = boardDispatcher;
|
|
61
58
|
this.executeJob = executeJob;
|
|
62
59
|
this.hostedTasks = new Set();
|
|
63
60
|
this.identityRecord = new SenderIdentityRecord();
|
|
@@ -96,15 +93,8 @@ export class Orchestrator {
|
|
|
96
93
|
// the queue already logs a failed event; a voided dispatch must not also
|
|
97
94
|
// reject unhandled, which Node treats as fatal and which stopped the daemon
|
|
98
95
|
if (this.autoTick) { this.timer = setInterval(() => { this.tick().catch(() => undefined); }, TICK_MS); this.timer.unref?.(); }
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
root: this.state.root, orchestration: this.state, log: this.log, adapters: this.adapters, dispatcher: this.boardDispatcher,
|
|
102
|
-
// a board notice as a turn in the subscribed chat's own session (Hermes's wake), sent as the subscriber
|
|
103
|
-
wake: (sub, text) => this.dispatchLogged(events.inbound({
|
|
104
|
-
profile: sub.notifier_profile || 'default', key: { platform: sub.platform, kind: sub.chat_type || 'dm', chat_id: sub.chat_id, ...(sub.thread_id ? { thread_id: sub.thread_id } : {}) },
|
|
105
|
-
sender: sub.user_id || 'kanban-notifier', sender_name: 'board', text, at: this.clock(),
|
|
106
|
-
})),
|
|
107
|
-
});
|
|
96
|
+
// The home's board is dispatched by `supercode workflow serve`, its one dispatcher; this daemon runs no round. A board
|
|
97
|
+
// subscription naming a chat platform is told by the agent mailbox (D140, supercode#975), never from here.
|
|
108
98
|
}
|
|
109
99
|
|
|
110
100
|
/**
|
|
@@ -228,7 +218,6 @@ export class Orchestrator {
|
|
|
228
218
|
|
|
229
219
|
async stop() {
|
|
230
220
|
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
|
231
|
-
this.board?.stop(); this.board = null;
|
|
232
221
|
// Before any runtime closes: each binding records the runtime to resume (§4.7), while adapters can still deliver.
|
|
233
222
|
await this.dispatch(events.stopping(this.clock())).catch((error) => this.log('error', `stopping: ${error.message}`));
|
|
234
223
|
for (const timer of this.adapterRetries?.values() ?? []) clearTimeout(timer);
|
package/ontology.d.ts
CHANGED
|
@@ -141,6 +141,72 @@ export type BudgetPeriod =
|
|
|
141
141
|
/** A calendar month. */
|
|
142
142
|
| 'month';
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* A cap on what the dispatcher starts, in the board's one capacity language (docs/adr/0009-board-capacity.md):
|
|
146
|
+
* what it counts, in which scope, among which cards and sessions, at most how many. Every cap applies; a card starts
|
|
147
|
+
* only when each cap that matches it has room.
|
|
148
|
+
*/
|
|
149
|
+
export interface Cap {
|
|
150
|
+
/** What the cap is called where a card waits on it and in the capacity view. */
|
|
151
|
+
name: string;
|
|
152
|
+
/** Where one count is kept: one for the whole home, or one per board, machine or profile. */
|
|
153
|
+
scope: CapScope;
|
|
154
|
+
/** What is counted. */
|
|
155
|
+
counts: CapCount;
|
|
156
|
+
/** Only what matches counts, and only a card that matches is held (every key that is given must match). */
|
|
157
|
+
where?: CapFilter;
|
|
158
|
+
/** What matches this is neither counted nor held, whatever `where` says (a standing service that holds no slot). */
|
|
159
|
+
unless?: CapFilter;
|
|
160
|
+
/** The most there may be in each count; a max that evaluates to nothing caps nothing. */
|
|
161
|
+
max: Expr;
|
|
162
|
+
/** Slots of each count held back for the cards this matches while any waits (Hermes's review reservation). */
|
|
163
|
+
reserve?: Reserve | null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** What a cap counts. */
|
|
167
|
+
export type CapCount =
|
|
168
|
+
/** Cards with a live run. */
|
|
169
|
+
| 'runs'
|
|
170
|
+
/** Cards, each once whatever its runs and sessions (a cap's `where` names the statuses that hold a slot). */
|
|
171
|
+
| 'cards'
|
|
172
|
+
/** Live sessions the home's cards hold, in every slot (implementer and reviewer), idle or working. */
|
|
173
|
+
| 'sessions'
|
|
174
|
+
/** Those of the sessions that are in a turn now. */
|
|
175
|
+
| 'turns'
|
|
176
|
+
/** Cards started in this tick. */
|
|
177
|
+
| 'starts';
|
|
178
|
+
|
|
179
|
+
/** Which cards and sessions a cap is about: each key given narrows it; a key's names are alternatives. */
|
|
180
|
+
export interface CapFilter {
|
|
181
|
+
/** The card itself, by id. */
|
|
182
|
+
card?: Names;
|
|
183
|
+
/** The model a session runs (the card's model, else its profile's). */
|
|
184
|
+
model?: Names;
|
|
185
|
+
/** The harness a session runs (`claude-code`, `codex`, …). */
|
|
186
|
+
harness?: Names;
|
|
187
|
+
/** The card's assignee (its profile). */
|
|
188
|
+
assignee?: Names;
|
|
189
|
+
/** The card's status (for a card being started, the status its start moves it to). */
|
|
190
|
+
status?: Names;
|
|
191
|
+
/** The status the board stores for the card (`blocked` for a paused card or one blocked on a message). */
|
|
192
|
+
stored?: Names;
|
|
193
|
+
/** The machine the card names. */
|
|
194
|
+
machine?: Names;
|
|
195
|
+
/** The card's board. */
|
|
196
|
+
board?: Names;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Where a cap keeps one count. */
|
|
200
|
+
export type CapScope =
|
|
201
|
+
/** One count across every board of the home and every machine its cards run on. */
|
|
202
|
+
| 'global'
|
|
203
|
+
/** One count per board. */
|
|
204
|
+
| 'board'
|
|
205
|
+
/** One count per machine a card names. */
|
|
206
|
+
| 'machine'
|
|
207
|
+
/** One count per profile (the lane a card's run is started in). */
|
|
208
|
+
| 'profile';
|
|
209
|
+
|
|
144
210
|
/** One platform's configuration. */
|
|
145
211
|
export interface ChannelConfig {
|
|
146
212
|
/** The platform name (`telegram`, `slack`, `api_server`, …). */
|
|
@@ -215,8 +281,6 @@ export interface Delayed {
|
|
|
215
281
|
|
|
216
282
|
/** The dispatcher: when it runs, what it may start, in what order, under which caps. */
|
|
217
283
|
export interface Dispatch {
|
|
218
|
-
/** Whether the served home dispatches its boards (an expression; Hermes's `dispatch_in_gateway`). */
|
|
219
|
-
serve?: Expr | null;
|
|
220
284
|
/** Seconds between ticks. */
|
|
221
285
|
tick: Expr;
|
|
222
286
|
/** The event the dispatcher sends a card it starts (to cards whose status has a transition for it). */
|
|
@@ -225,8 +289,8 @@ export interface Dispatch {
|
|
|
225
289
|
eligible?: Expr | null;
|
|
226
290
|
/** The order it starts them in: sort keys, `-` before one for descending. */
|
|
227
291
|
order?: Expr[];
|
|
228
|
-
/** The caps. */
|
|
229
|
-
|
|
292
|
+
/** The caps, in the capacity language. */
|
|
293
|
+
capacity?: Cap[];
|
|
230
294
|
}
|
|
231
295
|
|
|
232
296
|
/** Why a binding ended (`docs/ORCHESTRATOR-IR.md` §2.5). */
|
|
@@ -402,6 +466,11 @@ export interface Job {
|
|
|
402
466
|
* `isolated` (a new one) or `session:<id>` (that one). Absent: the source's own default.
|
|
403
467
|
*/
|
|
404
468
|
session_target?: string | null;
|
|
469
|
+
/**
|
|
470
|
+
* The machine whose session runs each fire (supercode's: Hermes runs every fire on its own host). Absent, a
|
|
471
|
+
* fire runs where the orchestrator does.
|
|
472
|
+
*/
|
|
473
|
+
machine?: string | null;
|
|
405
474
|
/** Remaining-runs counter for `once` jobs. */
|
|
406
475
|
repeat?: Repeat | null;
|
|
407
476
|
/** Whether it fires at all. */
|
|
@@ -430,19 +499,8 @@ export interface JobOrigin {
|
|
|
430
499
|
thread_id?: string | null;
|
|
431
500
|
}
|
|
432
501
|
|
|
433
|
-
/**
|
|
434
|
-
export
|
|
435
|
-
/** What the cap is about, for its message. */
|
|
436
|
-
description?: string | null;
|
|
437
|
-
/** The group a card falls in (GitHub's `concurrency.group`): `'home'`, `card.board`, `card.assignee`, … */
|
|
438
|
-
group: Expr;
|
|
439
|
-
/** Which cards count against it. */
|
|
440
|
-
counts: Expr;
|
|
441
|
-
/** The cap; a cap that evaluates to nothing caps nothing. */
|
|
442
|
-
max: Expr;
|
|
443
|
-
/** Slots held back for the cards this matches while any waits (Hermes's review reservation). */
|
|
444
|
-
reserve?: Reserve | null;
|
|
445
|
-
}
|
|
502
|
+
/** Names written as one name or a list of them. */
|
|
503
|
+
export type Names = string[];
|
|
446
504
|
|
|
447
505
|
/** One delivery obligation. */
|
|
448
506
|
export interface Obligation {
|
|
@@ -650,10 +708,10 @@ export interface Repeat {
|
|
|
650
708
|
completed?: number;
|
|
651
709
|
}
|
|
652
710
|
|
|
653
|
-
/** Slots of a
|
|
711
|
+
/** Slots of a cap held for some cards. */
|
|
654
712
|
export interface Reserve {
|
|
655
713
|
/** The cards the slots are held for. */
|
|
656
|
-
for:
|
|
714
|
+
for: CapFilter;
|
|
657
715
|
/** How many. */
|
|
658
716
|
slots: Expr;
|
|
659
717
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.47",
|
|
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": {
|
package/board.mjs
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
// The home's board, dispatched while the orchestrator serves the home (docs/architecture/orchestrator.md §2.9,
|
|
2
|
-
// docs/plans/hermes-compat.md row 14). The board and its dispatcher are supercode's own (`board/`): Hermes Kanban's
|
|
3
|
-
// format and lifecycle, each profile's lane decided by its `worker` in the IR. Where a Hermes gateway would run
|
|
4
|
-
// its dispatcher, the daemon runs this one the same way: when the board's workflow says so (its `dispatch.serve`),
|
|
5
|
-
// taking no file locks, waking on events with `dispatch.tick` as a safety sweep.
|
|
6
|
-
// One dispatcher per board holds by construction, not by a lock: whoever starts the daemon beside a
|
|
7
|
-
// `supercode workflow serve` on the same home (an Open Autonomy install's start) says so (`dispatcher: 'serve'`, from
|
|
8
|
-
// SUPERCODE_BOARD_DISPATCHER), and the daemon then runs no round and no board event relay of its own. Chat-platform
|
|
9
|
-
// subscriptions are delivered here whoever dispatches: the daemon hosts the channel adapters and a serve hosts none.
|
|
10
|
-
// Each tick runs as
|
|
11
|
-
// `workflow dispatch` in a child process: a tick waits on supercode (the fleet, launches, mail), and the daemon's
|
|
12
|
-
// own loop never does.
|
|
13
|
-
import { spawn } from 'node:child_process';
|
|
14
|
-
import { fileURLToPath } from 'node:url';
|
|
15
|
-
import { summary } from './board/cli.mjs';
|
|
16
|
-
import { commandEnv, notifyChats, startDelivery, supercodeBin } from './board/dispatch.mjs';
|
|
17
|
-
import { watchBoard } from './board/wake.mjs';
|
|
18
|
-
import { runtimeDoor } from './board/runtime-door.mjs';
|
|
19
|
-
import { workflowEntry } from './board/workflow.mjs';
|
|
20
|
-
import { evaluate, holds } from './board/expr.mjs';
|
|
21
|
-
|
|
22
|
-
const ENTRY = fileURLToPath(new URL('./bin/orchestrator.mjs', import.meta.url));
|
|
23
|
-
|
|
24
|
-
function dispatchOnce(root) {
|
|
25
|
-
return new Promise((done) => {
|
|
26
|
-
const child = spawn(process.execPath, [ENTRY, 'workflow', 'dispatch', '--root', root, '--json'], { stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
|
|
27
|
-
let out = '';
|
|
28
|
-
let err = '';
|
|
29
|
-
child.stdout.setEncoding('utf8').on('data', (c) => { out += c; });
|
|
30
|
-
child.stderr.setEncoding('utf8').on('data', (c) => { err = (err + c).slice(-2000); });
|
|
31
|
-
child.on('error', (error) => done({ error: error.message }));
|
|
32
|
-
child.on('close', () => {
|
|
33
|
-
// `workflow dispatch --json` prints its reports as one indented JSON document: the whole of stdout is the answer
|
|
34
|
-
// (its last line alone is a closing bracket, and every tick read as failed, its chat notices never sent).
|
|
35
|
-
try { done({ reports: JSON.parse(out) }); } catch { done({ error: (err || out).trim().slice(-500) || 'no answer' }); }
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* The chat-platform notices alone, for a daemon that does not dispatch the board: each sweep sends what the board's
|
|
42
|
-
* subscriptions have not yet been told through this daemon's channel adapters. Null when there is nothing it could send.
|
|
43
|
-
*/
|
|
44
|
-
function startChatNotices({ root, entry, seconds, log, adapters, wake }) {
|
|
45
|
-
if (!Object.keys(entry.workflow.notify?.chat ?? {}).length) return null;
|
|
46
|
-
if (!Object.keys(adapters).length) { log('warn', 'board: the board workflow tells chats (notify.chat) and this daemon hosts no channel adapter to tell them'); return null; }
|
|
47
|
-
let running = false;
|
|
48
|
-
const sweep = async () => {
|
|
49
|
-
if (running) return;
|
|
50
|
-
running = true;
|
|
51
|
-
try { await notifyChats(root, adapters, log, wake); } catch (error) { log('warn', `board: chat notices failed: ${error.message}`); } finally { running = false; }
|
|
52
|
-
};
|
|
53
|
-
const timer = setInterval(() => { void sweep(); }, seconds * 1000);
|
|
54
|
-
timer.unref?.();
|
|
55
|
-
void sweep();
|
|
56
|
-
log('info', `board: chat notices every ${seconds}s through this daemon's channel adapters`);
|
|
57
|
-
return { tick() { void sweep(); }, stop() { clearInterval(timer); } };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Start the board's dispatcher for the home at `root`; answers `{ tick, stop }`, or null when it neither dispatches
|
|
62
|
-
* nor has chat notices to send. `dispatcher: 'serve'` says a `supercode workflow serve` dispatches this home's board:
|
|
63
|
-
* the daemon then runs no round of its own, only the chat notices its adapters deliver.
|
|
64
|
-
*/
|
|
65
|
-
export async function startBoard({ root, orchestration = null, log = () => {}, adapters = {}, wake = null, dispatcher = null }) {
|
|
66
|
-
// whether and how often the served home dispatches is the board workflow's (`dispatch.serve`, `dispatch.tick`)
|
|
67
|
-
let entry;
|
|
68
|
-
try { entry = workflowEntry(orchestration); } catch (error) { log('warn', `board: not dispatched: ${error.message}`); return null; }
|
|
69
|
-
const seconds = Math.max(Number(evaluate(entry.workflow.dispatch.tick, { params: entry.params })) || 60, 1);
|
|
70
|
-
if (dispatcher === 'serve') { log('info', 'board: not dispatched here (its dispatcher is the `workflow serve` beside this daemon)'); return startChatNotices({ root, entry, seconds, log, adapters, wake }); }
|
|
71
|
-
if (!holds(entry.workflow.dispatch.serve ?? null, { params: entry.params })) { log('info', 'board: not dispatched (the board workflow\'s dispatch.serve)'); return startChatNotices({ root, entry, seconds, log, adapters, wake }); }
|
|
72
|
-
let running = false;
|
|
73
|
-
let stopped = false;
|
|
74
|
-
const said = new Set();
|
|
75
|
-
const stuck = {};
|
|
76
|
-
const warned = {};
|
|
77
|
-
const tick = async () => {
|
|
78
|
-
if (running || stopped) return;
|
|
79
|
-
running = true;
|
|
80
|
-
try {
|
|
81
|
-
const { reports, error } = await dispatchOnce(root);
|
|
82
|
-
if (error) { log('warn', `board: tick failed: ${error}`); return; }
|
|
83
|
-
// what the round recorded is delivered by a pass of its own, which this loop never waits for
|
|
84
|
-
startDelivery(root, { log: (level, line) => log(level, `board: ${line}`) });
|
|
85
|
-
// chat-platform subscriptions are delivered here, through the daemon's own channel adapters
|
|
86
|
-
try { await notifyChats(root, adapters, log, wake); } catch (error) { log('warn', `board: chat notices failed: ${error.message}`); }
|
|
87
|
-
for (const r of reports) {
|
|
88
|
-
const line = summary(r);
|
|
89
|
-
stuck[r.board] = r.ready_waiting && !r.started?.length && !r.paused ? (stuck[r.board] || Date.now()) : 0;
|
|
90
|
-
if (stuck[r.board] && Date.now() - stuck[r.board] >= seconds * 6000 && Date.now() - (warned[r.board] ?? 0) >= 300_000) { log('warn', `board ${r.board} stuck: ${r.ready_waiting} ready card(s) and nothing started for ${Math.floor((Date.now() - stuck[r.board]) / 1000)}s`); warned[r.board] = Date.now(); }
|
|
91
|
-
if (r.paused && !said.has(`paused:${r.board}`)) { said.add(`paused:${r.board}`); log('info', `board ${r.board}: new starts paused by ${r.paused}`); }
|
|
92
|
-
if (!r.paused) said.delete(`paused:${r.board}`);
|
|
93
|
-
if (!line.endsWith('nothing to do')) log(r.error ? 'warn' : 'info', line);
|
|
94
|
-
for (const [task, why] of r.failed ?? []) log('warn', `board ${r.board}: ${task} did not start: ${why}`);
|
|
95
|
-
for (const [task, to, why, what] of r.undelivered ?? []) log('warn', what === 'close' ? `board ${r.board}: the close of ${to} for ${task} waits: ${why}` : `board ${r.board}: a notice for ${task} to ${to} was not delivered: ${why}`);
|
|
96
|
-
const unserved = r.chat_subscriptions && !Object.keys(adapters).length;
|
|
97
|
-
if (unserved && !said.has(`subs:${r.board}`)) { said.add(`subs:${r.board}`); log('warn', `board ${r.board}: ${r.chat_subscriptions} subscription(s) name a chat platform and this daemon hosts no channel adapter to tell them`); }
|
|
98
|
-
if (r.nonspawnable?.length && !said.has(`lane:${r.board}`)) { said.add(`lane:${r.board}`); log('warn', `board ${r.board}: ${r.nonspawnable.length} card(s) name an assignee that is no profile of this home, and wait`); }
|
|
99
|
-
}
|
|
100
|
-
} finally {
|
|
101
|
-
running = false;
|
|
102
|
-
}
|
|
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)}`); }
|
|
108
|
-
void (async () => {
|
|
109
|
-
let wakeReason = null;
|
|
110
|
-
while (!stopped) {
|
|
111
|
-
const began = Date.now();
|
|
112
|
-
if (wakeReason) log('info', `board: dispatch wake ${wakeReason.sources.join(', ')}; event age ${began - wakeReason.at}ms`);
|
|
113
|
-
await tick();
|
|
114
|
-
events.refresh();
|
|
115
|
-
wakeReason = await events.wait(Math.max(0, seconds * 1000 - (Date.now() - began)));
|
|
116
|
-
const remaining = 1000 - (Date.now() - began);
|
|
117
|
-
if (remaining > 0 && !stopped) await new Promise(done => setTimeout(done, remaining));
|
|
118
|
-
}
|
|
119
|
-
})().catch(error => log('warn', `board: dispatch loop failed: ${error.message}`));
|
|
120
|
-
log('info', `board: dispatching on events, safety sweep every ${seconds}s, each card as its profile's worker says`);
|
|
121
|
-
return {
|
|
122
|
-
tick() { events.wake('requested'); },
|
|
123
|
-
stop() { stopped = true; events.close(); },
|
|
124
|
-
};
|
|
125
|
-
}
|