@volter/supercode-orchestrator 0.4.103 → 0.4.105

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 +43 -13
  2. package/package.json +1 -1
@@ -176,6 +176,16 @@ export function lanesOf(profiles = {}) {
176
176
 
177
177
  function supervise(board, fleet, settings, report, entry, effects) {
178
178
  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();
179
+ // The catalog can lag a pane. Read the actual machines once, before any board mutation.
180
+ const observed = new Map();
181
+ for (const { id } of rows) {
182
+ const s = currentRun(board.db, id)?.metadata?.supercode;
183
+ if (!s?.pane || observed.has(s.machine)) continue;
184
+ const r = sc(['teams', 'panes', 'ls', '--machine', s.machine ?? localMachine()], { timeout: 60_000 });
185
+ const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
186
+ observed.set(s.machine, Array.isArray(list) ? new Set(list.map((p) => p.pane)) : null);
187
+ if (!Array.isArray(list)) report.failed.push([id, `pane liveness unavailable; retaining run: ${(r.stderr || r.stdout).trim().slice(-200)}`]);
188
+ }
179
189
  for (const { id } of rows) {
180
190
  tx(board, (db) => {
181
191
  const task = getTask(db, id);
@@ -197,7 +207,9 @@ function supervise(board, fleet, settings, report, entry, effects) {
197
207
  };
198
208
  const limits = limitsOf(db, board, entry, task, session);
199
209
  // Hermes's orphan reconciliation: a running card whose claim bookkeeping is broken, with nothing live behind it
200
- const live = session?.surface === 'headless' ? pidAlive(session.pid) : session?.session_id ? (fleet.ok ? sessionAlive(fleet, session) : true) : false;
210
+ const paneReading = session?.pane ? observed.get(session.machine) : undefined;
211
+ const paneLive = paneReading instanceof Set ? paneReading.has(session.pane) : null;
212
+ const live = session?.surface === 'headless' ? pidAlive(session.pid) : session?.pane ? paneLive !== false : session?.session_id ? (fleet.ok ? sessionAlive(fleet, session) : true) : false;
201
213
  // (whether it is reconciled is the workflow's: its `orphaned` transitions and their guard)
202
214
  if ((!task.claim_lock || !task.claim_expires) && !live) {
203
215
  observe('orphaned', { reason: 'orphaned_running', claim_lock: task.claim_lock, claim_expires: task.claim_expires, error: 'orphaned running card (broken claim bookkeeping)' }, 'reclaimed');
@@ -226,20 +238,21 @@ function supervise(board, fleet, settings, report, entry, effects) {
226
238
  return;
227
239
  }
228
240
  if (session?.session_id) {
229
- if (!fleet.ok) return; // no reading, no verdict
230
- const alive = sessionAlive(fleet, session);
231
- const machineUp = fleet.online.has(session.machine);
241
+ if (session.pane && paneLive === null) return; // no machine reading, no loss verdict
242
+ if (!session.pane && !fleet.ok) return;
243
+ const alive = session.pane ? paneLive : sessionAlive(fleet, session);
244
+ const machineUp = paneReading instanceof Set || fleet.online.has(session.machine);
232
245
  const young = t - Number(session.launched_at ?? 0) < Number(limits.grace ?? settings.grace);
233
246
  const missing = !alive && machineUp && !young;
234
247
  const next = { ...session };
235
248
  // a machine that is off the fleet: how long, for the workflow's machine_offline (the claim is kept meanwhile)
236
- if (machineUp) delete next.offline_since; else next.offline_since ??= t;
249
+ if (machineUp) next.offline_since = null; else next.offline_since ??= t;
237
250
  if (!machineUp) {
238
251
  setRunSession(db, runRow.id, next);
239
252
  observe('machine_offline', { machine: session.machine, seconds: t - next.offline_since }, null);
240
253
  if (getTask(db, id)?.current_run_id !== runRow.id) return; // the workflow moved it
241
254
  }
242
- if (alive) { next.seen_at ??= t; delete next.missing_since; }
255
+ if (alive) { next.seen_at ??= t; next.missing_since = null; }
243
256
  if (missing) next.missing_since ??= t;
244
257
  if (missing && t - next.missing_since >= Number(limits.grace ?? settings.grace)) {
245
258
  // lost: gone from an online machine for the whole grace. A resume that never came up is not resumable.
@@ -500,7 +513,7 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
500
513
  if (current.close_error !== error) event(db, row.task_id, 'session_close_failed', { session_id: session.session_id, pane: session.pane, error, retry: true }, row.id);
501
514
  } else {
502
515
  const { close_error: _error, ...rest } = current;
503
- setRunSession(db, row.id, { ...rest, closed_at: at, close_confirmed_at: at });
516
+ setRunSession(db, row.id, { ...rest, close_error: null, closed_at: at, close_confirmed_at: at });
504
517
  const address = addressOf(session);
505
518
  const subscribed = active.some((r) => addressOf(json(r.metadata, {})?.supercode) === address && r.id !== row.id);
506
519
  if (!subscribed) db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?").run(row.task_id, address);
@@ -651,6 +664,12 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
651
664
  // the slot's last session: kept when live, resumed when lost, never another slot's (a reviewer's is its own)
652
665
  // a close its session's own command asked for is carried out first, so its pane is gone before the slot's next
653
666
  // session starts (never two processes on one session)
667
+ // Every old pane in the slot must be settled, including a failed launch with no session id.
668
+ closeRequested(board, { undelivered: [] }, { taskId: task.id });
669
+ const pending = view(board, (db) => db.prepare('SELECT id, ended_at, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId))
670
+ .map((r) => ({ ...r, session: json(r.metadata, {})?.supercode }))
671
+ .find((r) => r.session?.pane && (r.session.slot ?? 'worker') === slot && (r.ended_at || r.session.close_requested) && !r.session.close_confirmed_at);
672
+ 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'}`);
654
673
  let prev = view(board, (db) => lastSession(db, task.id, slot));
655
674
  if (prev?.close_requested && !prev.closed_at) {
656
675
  const report = { undelivered: [] };
@@ -671,7 +690,10 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
671
690
  };
672
691
  let session = null;
673
692
  let replaced = null;
674
- if (prev?.forgotten) replaced = { session_id: prev.session_id, why: 'the workflow let it go' };
693
+ if (prev?.forgotten) {
694
+ if (prev.pane && panePresent(prev.machine, prev.pane)) throw new Error(`forgotten session ${prev.session_id} still has live pane ${prev.pane}; close it before replacement`);
695
+ replaced = { session_id: prev.session_id, why: 'the workflow let it go' };
696
+ }
675
697
  else if (prev && (prev.harness ?? 'claude-code') !== harness) {
676
698
  // the card was reassigned to a lane of another harness: that harness starts it fresh (the card's text and thread
677
699
  // are its context), and the last session, if still open, is closed so two harnesses never work one card
@@ -680,7 +702,9 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
680
702
  } else if (prev && prev.machine === machine) {
681
703
  // a session the board closed, or one whose close its own command asked for (the next tick's sweep closes that
682
704
  // run's pane), is not kept, whatever a fleet reading taken before the close still lists
683
- if (!prev.closed_at && !prev.close_requested && sessionAlive(fleet, prev)) {
705
+ const paneLive = prev.pane ? panePresent(prev.machine, prev.pane) : false;
706
+ if (paneLive && (prev.closed_at || prev.close_requested || prev.forgotten)) throw new Error(`session ${prev.session_id} still has live pane ${prev.pane}; cannot resume or replace it`);
707
+ if (!prev.closed_at && !prev.close_requested && (prev.pane ? paneLive : sessionAlive(fleet, prev))) {
684
708
  // the session is kept; the heartbeat's count belongs to the run that ended, so the new run starts it again
685
709
  const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, close_requested: _q, closed_at: _c, close_confirmed_at: _cc, close_error: _ce, close_attempts: _ca, close_attempted_at: _ct, ...kept } = prev;
686
710
  session = { ...kept, mode: 'adopt', launched_at: now(), seen_at: now() };
@@ -759,12 +783,18 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
759
783
  return session;
760
784
  }
761
785
 
762
- /** Whether a pane just launched on `machine` is still there after the harness had a moment to start. */
786
+ /** A machine reading, not a missing catalog entry, establishes whether a pane is alive. */
787
+ function panePresent(machine, pane) {
788
+ const r = sc(['teams', 'panes', 'ls', '--machine', machine ?? localMachine()], { timeout: 60_000 });
789
+ const panes = r.code === 0 ? json(r.stdout.trim(), null) : null;
790
+ if (!Array.isArray(panes)) throw new Error(`cannot establish pane ${pane} liveness on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
791
+ return panes.some((p) => p.pane === pane);
792
+ }
793
+
794
+ /** Whether a pane just launched is still there after the harness had a moment to start. */
763
795
  function paneSurvives(machine, pane) {
764
796
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 6000);
765
- const r = sc(['teams', 'panes', 'ls', '--machine', machine], { timeout: 60_000 });
766
- const panes = r.code === 0 ? json(r.stdout.trim(), null) : null;
767
- return Array.isArray(panes) ? panes.some((p) => p.pane === pane) : true; // no reading: the grace decides
797
+ return panePresent(machine, pane);
768
798
  }
769
799
 
770
800
  /** Hermes's own worker argv and task environment, run on this machine; watched by its pid. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.103",
3
+ "version": "0.4.105",
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": {