@volter/supercode-orchestrator 0.4.118 → 0.4.120

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.
Files changed (2) hide show
  1. package/board/dispatch.mjs +105 -34
  2. package/package.json +1 -1
@@ -116,11 +116,13 @@ export function observeFleet() {
116
116
  const list = sc(['message', 'list', '--json']);
117
117
  const sessions = list.code === 0 ? json(list.stdout.trim(), null) : null;
118
118
  if (!Array.isArray(sessions)) return { ok: false, alive: new Set(), online: new Set(), error: `message list: ${(list.stderr || list.stdout).trim().slice(-300)}` };
119
- const machines = sc(['teams', 'machines', 'list', '--json']);
120
- const rows = machines.code === 0 ? lastJson(machines.stdout)?.items : null;
119
+ const machines = sc(['teams', 'machines', 'events', '--json']);
120
+ const presence = machines.code === 0 ? lastJson(machines.stdout) : null;
121
121
  const online = new Set([localMachine()]);
122
- for (const m of rows ?? []) if (m.enrollment === 'active' && m.connection === 'online') online.add(normalMachine(m.name));
123
- return { ok: true, sessions, alive: new Set(sessions.map((s) => s.address).filter(Boolean)), online, error: rows ? null : 'teams machines list gave no answer; only this machine counts as online' };
122
+ for (const m of presence?.items ?? []) if (m.linked) online.add(normalMachine(m.name));
123
+ return { ok: true, sessions, alive: new Set(sessions.map((s) => s.address).filter(Boolean)), online,
124
+ presenceKnown: presence?.connected === true, machineEvents: presence,
125
+ error: presence?.connected ? null : 'machine event stream unavailable; retaining existing runs until availability is known' };
124
126
  }
125
127
 
126
128
  // how the launcher opens each harness a pane lane can run, and how it names the session: Claude Code is told the id
@@ -199,27 +201,39 @@ export function lanesOf(profiles = {}) {
199
201
  return out;
200
202
  }
201
203
 
204
+ const onMachine = machine => normalMachine(machine ?? localMachine()) === localMachine() ? [] : ['--machine', machine];
205
+ const unavailable = message => Object.assign(new Error(message), { launchDeferred: true });
206
+
202
207
  // ------------------------------------------------------------------ supervise
203
208
 
204
209
  function supervise(board, fleet, settings, report, entry, effects) {
205
210
  const rows = 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();
206
211
  // The catalog can lag a pane. Read the actual machines once, before any board mutation.
207
212
  const observed = new Map();
213
+ const exits = new Map();
208
214
  for (const { id } of rows) {
209
215
  const s = currentRun(board.db, id)?.metadata?.supercode;
210
216
  if (!s?.pane || observed.has(s.machine)) continue;
211
- const r = sc(['teams', 'panes', 'ls', '--machine', s.machine ?? localMachine()], { timeout: 60_000 });
217
+ const r = sc(['teams', 'panes', 'ls', ...onMachine(s.machine)], { timeout: 60_000 });
212
218
  const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
213
- observed.set(s.machine, Array.isArray(list) ? new Set(list.map((p) => p.pane)) : null);
219
+ observed.set(s.machine, Array.isArray(list) ? new Set(list.filter(p => !p.dead).map((p) => p.pane)) : null);
214
220
  if (!Array.isArray(list)) report.failed.push([id, `pane liveness unavailable; retaining run: ${(r.stderr || r.stdout).trim().slice(-200)}`]);
215
221
  }
222
+ for (const {id} of rows) {
223
+ const session=currentRun(board.db,id)?.metadata?.supercode;
224
+ const panes=observed.get(session?.machine);
225
+ if(session?.launch_key && panes instanceof Set && !panes.has(session.pane)) {
226
+ try { const receipt=launchReceipt(board,session);if(receipt?.live===false&&receipt.exit)exits.set(id,receipt.exit); }
227
+ catch(error){report.failed.push([id,`exit receipt unavailable; retaining liveness observation: ${error.message}`]);}
228
+ }
229
+ }
216
230
  for (const { id } of rows) {
217
231
  tx(board, (db) => {
218
232
  const task = getTask(db, id);
219
233
  if (!task || (entry.workflow.run ? !task.current_run_id : task.status !== 'running')) return;
220
234
  const runRow = currentRun(db, id);
221
235
  let session = runRow?.metadata?.supercode ?? null;
222
- if (session?.launch_pending) {
236
+ if (session?.launch_pending || session?.launch_deferred) {
223
237
  extendClaim(db, id, { ttl: settings.ttl, reason: 'launch_receipt_pending' });
224
238
  return; // An uncertain launch owns this run until a machine receipt settles it.
225
239
  }
@@ -247,6 +261,30 @@ function supervise(board, fleet, settings, report, entry, effects) {
247
261
  }
248
262
  };
249
263
  const limits = limitsOf(db, board, entry, task, session);
264
+ // Availability is the same retained linked/unlinked feed subscribers hear.
265
+ // A failed stream is unknown, not evidence that every remote machine left.
266
+ if (session?.session_id && session.surface !== 'headless') {
267
+ const panes = session.pane ? observed.get(session.machine) : null;
268
+ const reachable = panes instanceof Set || fleet.online.has(session.machine);
269
+ if (!reachable && !fleet.presenceKnown) return;
270
+ if (!reachable) {
271
+ const next = {...session, offline_since: session.offline_since ?? t};
272
+ setRunSession(db,runRow.id,next);
273
+ observe('machine_offline',{machine:session.machine,seconds:t-next.offline_since},null);
274
+ const age = Number(entry.params.block_age_seconds ?? 3600);
275
+ if (t-Number(next.offline_notified_at ?? next.offline_since)>=age) {
276
+ setRunSession(db,runRow.id,{...currentRun(db,id)?.metadata?.supercode,offline_notified_at:t});
277
+ observe('machine_wait_aged',{machine:session.machine,seconds:t-next.offline_since,reason:`machine ${session.machine} has been unlinked for ${t-next.offline_since}s`},null);
278
+ }
279
+ if (getTask(db,id)?.current_run_id===runRow.id) extendClaim(db,id,{ttl:settings.ttl,reason:'machine_offline'});
280
+ return;
281
+ }
282
+ if (session.offline_since != null) {
283
+ setRunSession(db,runRow.id,{...session,offline_since:null,offline_notified_at:null});
284
+ observe('machine_online',{machine:session.machine,reason:'machine linked again'},null);
285
+ session=currentRun(db,id)?.metadata?.supercode ?? session;
286
+ }
287
+ }
250
288
  // Hermes's orphan reconciliation: a running card whose claim bookkeeping is broken, with nothing live behind it
251
289
  const paneReading = session?.pane ? observed.get(session.machine) : undefined;
252
290
  const paneLive = paneReading instanceof Set ? paneReading.has(session.pane) : null;
@@ -279,6 +317,11 @@ function supervise(board, fleet, settings, report, entry, effects) {
279
317
  return;
280
318
  }
281
319
  if (session?.session_id) {
320
+ if(exits.has(id)) {
321
+ const exit=exits.get(id);
322
+ observe('exited',{code:exit.failed?1:0,error:`native session ${session.session_id} ended: ${exit.how}`,session_id:session.session_id,pane:session.pane},'crashed');
323
+ return;
324
+ }
282
325
  if (session.pane && paneLive === null) return; // no machine reading, no loss verdict
283
326
  if (!session.pane && !fleet.ok) return;
284
327
  const alive = session.pane ? paneLive : sessionAlive(fleet, session);
@@ -286,13 +329,6 @@ function supervise(board, fleet, settings, report, entry, effects) {
286
329
  const young = t - Number(session.launched_at ?? 0) < Number(limits.grace ?? settings.grace);
287
330
  const missing = !alive && machineUp && !young;
288
331
  const next = { ...session };
289
- // a machine that is off the fleet: how long, for the workflow's machine_offline (the claim is kept meanwhile)
290
- if (machineUp) next.offline_since = null; else next.offline_since ??= t;
291
- if (!machineUp) {
292
- setRunSession(db, runRow.id, next);
293
- observe('machine_offline', { machine: session.machine, seconds: t - next.offline_since }, null);
294
- if (getTask(db, id)?.current_run_id !== runRow.id) return; // the workflow moved it
295
- }
296
332
  if (alive) { next.seen_at ??= t; next.missing_since = null; }
297
333
  if (missing) next.missing_since ??= t;
298
334
  if (missing && t - next.missing_since >= Number(limits.grace ?? settings.grace)) {
@@ -502,9 +538,23 @@ export function launchRun(board, entry, id, lane, fleet, settings) {
502
538
  if (slot === 'worker' || (worker?.session_id && worker.session_id === reviewer?.session_id)) throw new Error('a reviewer must have a different session from the implementer');
503
539
  }
504
540
  const meta = { status, role: spec.role, slot };
505
- return surface === 'pane'
506
- ? launchPane(board, task, runId, lane, fleet, { input: prompt, slot, meta })
507
- : launchHeadless(board, task, runId, lane, settings, { prompt, skills: spec.skills ?? [], meta });
541
+ try {
542
+ const result = surface === 'pane'
543
+ ? launchPane(board, task, runId, lane, fleet, { input: prompt, slot, meta })
544
+ : launchHeadless(board, task, runId, lane, settings, { prompt, skills: spec.skills ?? [], meta });
545
+ tx(board,db=>setRunSession(db,runId,{launch_deferred:false,launch_deferred_error:null}));
546
+ return result;
547
+ } catch(error) {
548
+ if(error.launchDeferred) {
549
+ tx(board,db=>{
550
+ const previous=currentRun(db,id)?.metadata?.supercode;
551
+ setRunSession(db,runId,{...meta,launch_deferred:true,launch_deferred_error:error.message});
552
+ if(previous?.launch_deferred_error!==error.message)event(db,id,'launch_deferred',{reason:error.message},runId);
553
+ });
554
+ error.launchPending=true;
555
+ }
556
+ throw error;
557
+ }
508
558
  }
509
559
 
510
560
  /** Start one card now by a person's word (a triage card's session, a manager's start): the workflow's start event. */
@@ -570,9 +620,9 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
570
620
  try {
571
621
  const machine = session.machine ?? localMachine();
572
622
  if (!panes.has(machine)) {
573
- const r = sc(['teams', 'panes', 'ls', '--machine', machine], { timeout: 60_000, as: boardAddress(board.label) });
623
+ const r = sc(['teams', 'panes', 'ls', ...onMachine(machine)], { timeout: 60_000, as: boardAddress(board.label) });
574
624
  const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
575
- panes.set(machine, Array.isArray(list) ? { live: new Set(list.map((p) => p.pane)) } : { error: `cannot read panes on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}` });
625
+ panes.set(machine, Array.isArray(list) ? { live: new Set(list.filter(p => !p.dead).map((p) => p.pane)) } : { error: `cannot read panes on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}` });
576
626
  }
577
627
  const observed = panes.get(machine);
578
628
  if (observed.error) throw new Error(observed.error);
@@ -618,7 +668,7 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
618
668
  if (!(entry.workflow.statuses[status]?.on?.session_idle || entry.workflow.on?.session_idle || entry.workflow.run?.on?.session_idle)) return;
619
669
  const runRow = currentRun(db, id);
620
670
  const session = runRow?.metadata?.supercode;
621
- if (!session?.session_id || session.surface !== 'pane') return;
671
+ if (!session?.session_id || session.surface !== 'pane' || session.offline_since != null) return;
622
672
  const live = fleet.sessions.find((x) => x.address?.endsWith(`:${session.session_id}`));
623
673
  const observedAt = now();
624
674
  const lastAt = Math.floor(Number(live?.last_message_at_ms) / 1000);
@@ -727,6 +777,7 @@ function launcherCheck(db, task, lane, fleet) {
727
777
  if (!task.machine) return 'the card names no machine; a pane session runs on the machine its card names';
728
778
  const machine = normalMachine(task.machine);
729
779
  if (!fleet.ok) return `supercode could not read the fleet: ${fleet.error}`;
780
+ if (machine !== localMachine() && !fleet.presenceKnown) return 'machine event stream unavailable; launch waits for a current availability reading';
730
781
  if (!fleet.online.has(machine)) return `machine ${machine} is offline; the card starts when it is back`;
731
782
  // a card on this machine gets its workspace made here (scratch, dir or worktree, as Hermes makes it); another
732
783
  // machine's card names its directory there
@@ -781,12 +832,12 @@ const getTaskNow = (board, id) => view(board, (db) => getTask(db, id));
781
832
 
782
833
  /** A transport error is not a negative launch receipt. The key remains owned by its run. */
783
834
  function launchReceipt(board, session) {
784
- const r = sc(['teams', 'launches', 'get', session.launch_key, '--machine', session.machine],
835
+ const r = sc(['teams', 'launches', 'get', session.launch_key, ...onMachine(session.machine)],
785
836
  { timeout: 30_000, as: boardAddress(board.label) });
786
- if (r.code !== 0) throw new Error(`launch receipt unavailable on ${session.machine}: install the matching Teams CLI and machine capability (teams launches get): ${(r.stderr || r.stdout).trim().slice(-300)}`);
837
+ if (r.code !== 0) throw unavailable(`launch receipt unavailable on ${session.machine}: install the matching Teams CLI and machine capability (teams launches get): ${(r.stderr || r.stdout).trim().slice(-300)}`);
787
838
  if (r.stdout.trim() === 'null') return null;
788
839
  const receipt = lastJson(r.stdout);
789
- if (!receipt || receipt.key !== session.launch_key) throw new Error(`invalid launch receipt for ${session.launch_key} on ${session.machine}`);
840
+ if (!receipt || receipt.key !== session.launch_key) throw unavailable(`invalid launch receipt for ${session.launch_key} on ${session.machine}`);
790
841
  return receipt;
791
842
  }
792
843
 
@@ -834,7 +885,22 @@ export function reconcileCallerLaunch(board, sid) {
834
885
  }
835
886
  }
836
887
 
837
- function reconcileLaunches(board, fleet, report, entry, effects, settings) {
888
+ function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes) {
889
+ const deferred=view(board,db=>db.prepare("SELECT t.id FROM tasks t JOIN task_runs r ON r.id=t.current_run_id WHERE r.ended_at IS NULL AND json_extract(r.metadata,'$.supercode.launch_deferred')=1").all());
890
+ for(const {id} of deferred) {
891
+ try {
892
+ const task=getTaskNow(board,id),status=view(board,db=>statusOf(db,task)),spec=runSpec(entry,status,task);
893
+ const profile=spec&&view(board,db=>evaluate(entry.workflow.roles?.[spec.role]??'card.assignee',scopeOf(db,board,task,{params:entry.params})));
894
+ const lane=lanes?.[profile];if(!lane){report.queued.push([id,'launch waits for its configured lane']);continue;}
895
+ const why=launcherCheck(board.db,task,lane,fleet);if(why){report.queued.push([id,why]);continue;}
896
+ const session=launchRun(board,entry,id,lane,fleet,settings);
897
+ if(session)report.started.push([id,session.address??session.session_id,session.mode]);
898
+ }catch(error){
899
+ if(error.launchPending){report.queued.push([id,error.message]);continue;}
900
+ const moved=tx(board,db=>fire(db,board,entry,id,'spawn_failed',{roles:['platform'],payload:{error:error.message}}));effects.push(...moved.effects);
901
+ report.failed.push([id,error.message]);
902
+ }
903
+ }
838
904
  const rows = view(board, (db) => db.prepare(`SELECT id, task_id, ended_at, metadata FROM task_runs
839
905
  WHERE json_extract(metadata, '$.supercode.launch_pending') = 1
840
906
  AND json_extract(metadata, '$.supercode.close_confirmed_at') IS NULL`).all());
@@ -883,15 +949,15 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
883
949
  const pending = view(board, (db) => db.prepare('SELECT id, ended_at, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId))
884
950
  .map((r) => ({ ...r, session: json(r.metadata, {})?.supercode }))
885
951
  .find((r) => (r.session?.pane || r.session?.launch_key) && !r.session.close_transferred_to && (r.session.slot ?? 'worker') === slot && (r.ended_at || r.session.close_requested) && !r.session.close_confirmed_at);
886
- if (pending) throw new Error(`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'}`);
952
+ 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'}`);
887
953
  let prev = view(board, (db) => lastSession(db, task.id, slot));
888
954
  if (prev?.session_id && !prev.forgotten && (prev.harness ?? 'claude-code') === harness && prev.machine === machine) {
889
955
  const fresh = observeFleet();
890
- if (!fresh.ok) throw new Error(`cannot check for a manual continuation of ${prev.session_id}: ${fresh.error}`);
891
- const r = sc(['teams', 'panes', 'ls', '--machine', machine], { timeout: 60_000 });
956
+ if (!fresh.ok) throw unavailable(`cannot check for a manual continuation of ${prev.session_id}: ${fresh.error}`);
957
+ const r = sc(['teams', 'panes', 'ls', ...onMachine(machine)], { timeout: 60_000 });
892
958
  const listed = r.code === 0 ? json(r.stdout.trim(), null) : null;
893
- if (!Array.isArray(listed)) throw new Error(`cannot read panes before continuing ${prev.session_id}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
894
- const pane = continuationPane(fresh, prev, new Set(listed.map((p) => p.pane)));
959
+ if (!Array.isArray(listed)) throw unavailable(`cannot read panes before continuing ${prev.session_id}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
960
+ const pane = continuationPane(fresh, prev, new Set(listed.filter(p => !p.dead).map((p) => p.pane)));
895
961
  if (pane && pane !== prev.pane) prev = { ...prev, pane, close_requested: null, closed_at: null, close_confirmed_at: null, close_error: null };
896
962
  fleet = fresh;
897
963
  }
@@ -900,7 +966,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
900
966
  closeRequested(board, report, { taskId: task.id });
901
967
  prev = view(board, (db) => lastSession(db, task.id, slot));
902
968
  // a pane that could not be closed still holds the session: no second process is started on it
903
- if (prev?.close_requested && !prev.closed_at) throw new Error(`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'}`);
969
+ 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'}`);
904
970
  }
905
971
  const openOwned = (key, sid, mode, args) => {
906
972
  launchReceipt(board, { machine, launch_key: key }); // Prove the exact receipt door before starting a process.
@@ -1015,10 +1081,10 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
1015
1081
 
1016
1082
  /** A machine reading, not a missing catalog entry, establishes whether a pane is alive. */
1017
1083
  function panePresent(machine, pane) {
1018
- const r = sc(['teams', 'panes', 'ls', '--machine', machine ?? localMachine()], { timeout: 60_000 });
1084
+ const r = sc(['teams', 'panes', 'ls', ...onMachine(machine)], { timeout: 60_000 });
1019
1085
  const panes = r.code === 0 ? json(r.stdout.trim(), null) : null;
1020
- if (!Array.isArray(panes)) throw new Error(`cannot establish pane ${pane} liveness on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
1021
- return panes.some((p) => p.pane === pane);
1086
+ if (!Array.isArray(panes)) throw unavailable(`cannot establish pane ${pane} liveness on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
1087
+ return panes.some((p) => p.pane === pane && !p.dead);
1022
1088
  }
1023
1089
 
1024
1090
  /** Whether a pane just launched is still there after the harness had a moment to start. */
@@ -1438,6 +1504,7 @@ function readMailbox(board, address, report) {
1438
1504
  try { renameSync(path, join(box, 'cur', name)); } catch { continue; } // another reader took it
1439
1505
  // an answer to a question the board asked is what a card waits on (read by the card's workflow), not a verb
1440
1506
  if (envelope.in_reply_to) continue;
1507
+ if (envelope.from === `sc:${localMachine()}:operator:teams-events` && json(envelope.body, {})?.machine_event) { report.mailed.push([envelope.from, 'machine_event']); continue; }
1441
1508
  const argv = json(envelope.body, {})?.workflow;
1442
1509
  let answer;
1443
1510
  if (!Array.isArray(argv) || !argv.length || NOT_BY_MAIL.has(argv[0])) answer = `not applied: a mailed card update is one \`supercode workflow\` verb other than ${[...NOT_BY_MAIL].join(', ')}`;
@@ -1548,10 +1615,14 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
1548
1615
  // where the tick's time goes, phase by phase (ms), printed by `serve --verbose` and in `--json`
1549
1616
  let mark = Date.now();
1550
1617
  const phase = (name) => { const t = Date.now(); report.ms[name] = t - mark; mark = t; };
1618
+ if (!fleet.machineEvents?.subscriptions?.includes(boardAddress(board.label))) {
1619
+ const subscription = sc(['teams','machines','subscribe',boardAddress(board.label),'--json']);
1620
+ if (subscription.code !== 0) report.failed.push(['machine_events',(subscription.stderr || subscription.stdout).trim().slice(-200)]);
1621
+ }
1551
1622
  readMailbox(board, boardAddress(board.label), report); phase('mailbox');
1552
1623
  await refreshRelationAnswers(board); phase('answers');
1553
1624
  const effects = [];
1554
- reconcileLaunches(board, fleet, report, entry, effects, settings); supervise(board, fleet, settings, report, entry, effects); await runEffects(board, effects.splice(0), { retryNotices: true }); closeRequested(board, report); phase('supervise');
1625
+ reconcileLaunches(board, fleet, report, entry, effects, settings, lanes); supervise(board, fleet, settings, report, entry, effects); await runEffects(board, effects.splice(0), { retryNotices: true }); closeRequested(board, report); phase('supervise');
1555
1626
  report.unsettled = [];
1556
1627
  report.promoted = recomputeReady(board, effects, report.unsettled);
1557
1628
  await sendBoardMessages(board, report);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.118",
3
+ "version": "0.4.120",
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": {