@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 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
- // Serialize lease replacement before loading state. An acquisition guard
690
- // left by a crash fails closed; inspect it before removing it manually.
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
- // An installer owns this operational window, not a card pause. Finish the
1483
- // current tick, acknowledge the request, then leave every run untouched.
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;
@@ -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
- // Every side effect follows a committed transition: a card is claimed before its session is launched, and a
16
- // subscriber's cursor moves only after its notice was delivered, so two dispatchers over one board never both
17
- // launch and a crash between the two steps is answered by the next tick.
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
- effects.push(...r.effects);
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
- if(exits.has(id)) {
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
- effects.push(...r.effects); (report.closed_by_hand ??= []).push(id);
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
- tx(board, (db) => noteGuard(db, task.id, reason));
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
- effects.push(...r.effects);
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
- if (!saved || saved.launch_key !== session.launch_key || (saved.session_id && sid && saved.session_id !== sid)) return false;
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
- if (!fleet.ok) { tx(board,db=>setRunSession(db,run_id,{launched_at:now()})); report.queued.push([id,`launch waits for the fleet: ${fleet.error}`]); continue; }
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). Each board is ticked under its
2665
- * version-checked card claims; no dispatcher file lock. Two rounds may run at once (the dispatcher's and a one-shot
2666
- * `workflow dispatch`). An adoption request and a session close are claimed in a transaction (`newClaim`) before
2667
- * anything is done for them outside the board; card starts, supervision, review requests, mailed verbs and manual
2668
- * continuations rely on their per-card transactions. Two rounds on one board at once can still both act on:
2669
- * deferred-launch retries (two panes), launch binding (bindLaunch), and board-only writes
2670
- * that do not re-check. A round runs no effect and delivers nothing: it records effects (`queueEffects`) and the
2671
- * delivery pass (`deliver`) carries them out. Answers one report per board.
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', 'installation-window.json', 'ESTOP']) file(join(root, name), name);
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, boardDispatcher = null, log = () => {} }) {
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
- // the home's board, dispatched as a Hermes gateway dispatches it (docs/plans/hermes-compat.md row 14)
100
- if (this.autoTick) this.board = await startBoard({
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
- limits?: Limit[];
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
- /** A cap on what the dispatcher starts: cards counted per group, at most `max` in each. */
434
- export interface Limit {
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 limit held for some cards. */
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: Expr;
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.46",
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
- }