@volter/supercode-orchestrator 0.4.27 → 0.4.29

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/board/cli.mjs CHANGED
@@ -1020,6 +1020,7 @@ async function serve(root, args = {}) {
1020
1020
  while (!stopped) {
1021
1021
  pausedSaid = Boolean(paused(root)) && pausedSaid;
1022
1022
  let interval = 60;
1023
+ const began = Date.now();
1023
1024
  try {
1024
1025
  const state = await laneState(root);
1025
1026
  const settings = { ...state.settings, ...override };
@@ -1027,6 +1028,7 @@ async function serve(root, args = {}) {
1027
1028
  for (const r of await tick({ root, lanes: state.lanes, settings })) {
1028
1029
  const said = summary(r);
1029
1030
  if (!said.endsWith('nothing to do') || args.verbose) log(said);
1031
+ if (args.verbose && r.ms) log(` took ${Object.entries(r.ms).map(([k, v]) => `${k} ${(v / 1000).toFixed(1)}s`).join(', ')}`);
1030
1032
  if (r.paused && !pausedSaid) { log(`new starts paused by ${r.paused} (\`supercode workflow resume\` lifts it)`); pausedSaid = true; }
1031
1033
  // Hermes's stuck warning: ready work for six ticks running and nothing started
1032
1034
  stuck[r.board] = r.ready_waiting && !r.started.length && !r.paused ? (stuck[r.board] ?? 0) + 1 : 0;
@@ -1038,7 +1040,9 @@ async function serve(root, args = {}) {
1038
1040
  } catch (error) {
1039
1041
  log(`tick failed: ${error.message}`);
1040
1042
  }
1041
- for (let waited = 0; waited < interval && !stopped; waited += 1) await new Promise((done) => setTimeout(done, 1000));
1043
+ // the next tick is due an interval after this one began (a long tick is not followed by a full interval's wait)
1044
+ const rest = Math.max(1, interval - Math.floor((Date.now() - began) / 1000));
1045
+ for (let waited = 0; waited < rest && !stopped; waited += 1) await new Promise((done) => setTimeout(done, 1000));
1042
1046
  }
1043
1047
  } finally {
1044
1048
  lock.release();
@@ -475,7 +475,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null } = {}) {
475
475
  if (prev && prev.machine === machine) {
476
476
  if (sessionAlive(fleet, prev)) {
477
477
  // the session is kept; the heartbeat's count belongs to the run that ended, so the new run starts it again
478
- const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _i, worked_since_ask: _w, ...kept } = prev;
478
+ const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, ...kept } = prev;
479
479
  session = { ...kept, mode: 'adopt', launched_at: now(), seen_at: now() };
480
480
  } else if (prev.resumable !== false) {
481
481
  const r = sc([...base, '--key', `board-${task.id}-r${runId}-resume`, '--input',
@@ -677,14 +677,16 @@ function notice(kind, task, p, role) {
677
677
  }
678
678
  }
679
679
 
680
- function send(from, label, to, body, key, { queue = false } = {}) {
680
+ async function send(from, label, to, body, key, { queue = false } = {}) {
681
681
  const request = {
682
682
  op: 'send', from, from_name: `${label} board`, to, queue, id: key,
683
683
  body: `[Board notice from the ${label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)`,
684
684
  };
685
- const r = sc(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 60_000 });
685
+ // a delivery to a busy session can take a minute on a loaded machine; what it answered is the verdict (a process
686
+ // killed after it answered `code: 0` delivered)
687
+ const r = await scAsync(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 180_000 });
686
688
  const answer = lastJson(r.stdout) ?? {};
687
- if (r.code !== 0 || answer.code !== 0) throw new Error((answer.text || r.stderr || 'no answer').trim().slice(-300));
689
+ if (answer.code !== 0) throw new Error((answer.text || r.stderr || `no answer (exit ${r.code})`).trim().slice(-300));
688
690
  }
689
691
 
690
692
  /** The board's own address: where its notices come from and where card updates are mailed. */
@@ -693,20 +695,20 @@ export const boardAddress = (label) => `sc:${localMachine()}:board:${label.repla
693
695
  /** Consecutive failed sends after which a subscription is dropped (Hermes's notifier drops a dead chat after 12). */
694
696
  const MAX_SEND_FAILURES = 12;
695
697
 
696
- function notify(board, fleet, report) {
698
+ async function notify(board, fleet, report) {
697
699
  const from = boardAddress(board.label);
698
- for (const sub of subscriptions(board.db)) {
699
- if (sub.platform !== 'supercode') continue;
700
+ // each subscription's notices go in order; different subscribers are told side by side
701
+ await pool(subscriptions(board.db).filter((sub) => sub.platform === 'supercode'), SEND_CONCURRENCY, async (sub) => {
700
702
  const role = ['worker', 'dependency'].includes(sub.delivery_metadata?.role) ? sub.delivery_metadata.role : 'creator';
701
703
  // a session that worked or watched a card and is gone hears nothing more; a creator is retried until it is back
702
704
  if (role !== 'creator' && fleet.ok && !fleet.alive.has(sub.chat_id)) {
703
705
  tx(board, (db) => db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?").run(sub.task_id, sub.chat_id));
704
- continue;
706
+ return;
705
707
  }
706
708
  const kinds = MAIL_KINDS[role];
707
709
  const events = board.db.prepare(`SELECT id, kind, payload, created_at FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id`)
708
710
  .all(sub.task_id, sub.last_event_id, ...kinds);
709
- if (!events.length) continue;
711
+ if (!events.length) return;
710
712
  const task = tx(board, (db) => getTask(db, sub.task_id));
711
713
  let cursor = Number(sub.last_event_id);
712
714
  let failed = null;
@@ -721,7 +723,7 @@ function notify(board, fleet, report) {
721
723
  } else text = notice(ev.kind, task, p, role);
722
724
  if (text) {
723
725
  // Hermes's delivery modes: `notify` is passive (read at the session's next turn), `wake`/`notify+wake` start its turn
724
- send(from, board.label, sub.chat_id, text, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { queue: sub.delivery_mode === 'notify' });
726
+ await send(from, board.label, sub.chat_id, text, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { queue: sub.delivery_mode === 'notify' });
725
727
  report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
726
728
  }
727
729
  cursor = ev.id;
@@ -739,7 +741,7 @@ function notify(board, fleet, report) {
739
741
  if (failures !== Number(sub.delivery_metadata?.failures ?? 0)) db.prepare(`UPDATE kanban_notify_subs SET delivery_metadata = ? ${where}`).run(JSON.stringify({ ...sub.delivery_metadata, failures }), sub.task_id, sub.chat_id);
740
742
  if (task?.status === 'archived' && cursor >= events.at(-1).id) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(sub.task_id, sub.chat_id);
741
743
  });
742
- }
744
+ });
743
745
  }
744
746
 
745
747
  // Hermes's terminal kinds for a chat subscriber, in its order of silence: archived and unblocked move the cursor and say nothing
@@ -905,7 +907,7 @@ function readMailbox(board, address, report) {
905
907
  answer = `${clean.slice(0, 2).join(' ')}: ${(r.status === 0 ? r.stdout : r.stderr || r.stdout).trim().slice(-600) || `exit ${r.status}`}`;
906
908
  }
907
909
  report.mailed.push([envelope.from, argv?.[0] ?? '?']);
908
- try { send(address, board.label, envelope.from, answer, `board-answer-${envelope.id}`); } catch (error) { report.undelivered.push(['mail', envelope.from, String(error.message).slice(-200)]); }
910
+ report.answers.push(send(address, board.label, envelope.from, answer, `board-answer-${envelope.id}`).catch((error) => { report.undelivered.push(['mail', envelope.from, String(error.message).slice(-200)]); }));
909
911
  }
910
912
  }
911
913
 
@@ -915,14 +917,17 @@ function readMailbox(board, address, report) {
915
917
  * The board's heartbeat for a pane session: a running card's session never sits idle without deciding. Each tick, a
916
918
  * session that is idle while its card is still running is asked to decide: `request-review` when the work is done,
917
919
  * `block` with its kind when it cannot go on (waiting on a person, a capability, another card), or keep working. A
918
- * goal card (Hermes's goal loop) is told to keep going. A session is asked once it has been idle a whole tick; one
919
- * seen working since the last ask answered it, and its count starts again. After the turn budget (`goal_max_turns`, else
920
+ * goal card (Hermes's goal loop) is told to keep going. Idle is judged by the session's last message time (supercode
921
+ * reads it from the transcript), never by its status label: a session is asked once its last message is a whole tick
922
+ * old; one whose messages ran on a whole tick past the last ask answered it by working, and its count starts again
923
+ * (a reply to the ask alone is not work). After the turn budget (`goal_max_turns`, else
920
924
  * `kanban.goal_max_turns`) of asks in a row with no work and no decision (a goal card: of turns), the card is blocked `needs_input` and its creator is told.
921
925
  * Whether the work is done is the session's call through its card, not an auxiliary judge's.
922
926
  */
923
- function pursueGoals(board, fleet, settings, report) {
927
+ async function pursueGoals(board, fleet, settings, report) {
924
928
  if (!fleet.ok) return;
925
929
  const cards = board.db.prepare("SELECT id FROM tasks WHERE status = 'running'").all();
930
+ const nudges = [];
926
931
  for (const { id } of cards) {
927
932
  const nudge = tx(board, (db) => {
928
933
  const task = getTask(db, id);
@@ -930,23 +935,22 @@ function pursueGoals(board, fleet, settings, report) {
930
935
  const session = runRow?.metadata?.supercode;
931
936
  if (!session?.session_id || session.surface !== 'pane') return null;
932
937
  const live = fleet.sessions.find((x) => x.address?.endsWith(`:${session.session_id}`));
933
- if (!live) return null;
934
- // a session seen working since the last ask answered it by working: its count starts again (a goal card keeps
935
- // Hermes's budget of turns); one idle for less than a whole tick is between turns, not idle
936
- if (live.status !== 'idle') {
937
- if (session.idle_since || (session.goal_nudged_at && !session.worked_since_ask)) setRunSession(db, runRow.id, { ...session, idle_since: null, worked_since_ask: true });
938
- return null;
939
- }
940
- if (!session.idle_since) { setRunSession(db, runRow.id, { ...session, idle_since: now() }); return null; }
941
- if (now() - session.idle_since < settings.interval) return null;
938
+ // a supercode that reports no last message time gives no reading, and no reading gives no verdict
939
+ const lastAt = Math.floor(Number(live.last_message_at_ms) / 1000);
940
+ if (!(lastAt > 0)) return null;
941
+ // a message within the last tick is a session working or between turns, not idle
942
+ if (now() - lastAt < settings.interval) return null;
942
943
  if (session.goal_nudged_at && now() - session.goal_nudged_at < settings.interval * 2) return null;
943
- const turns = session.worked_since_ask && !task.goal_mode ? 0 : Number(session.goal_turns ?? 0);
944
+ // messages that ran on a whole tick past the last ask answered it by working: the count starts again (a goal
945
+ // card keeps Hermes's budget of turns)
946
+ const worked = Boolean(session.goal_nudged_at) && lastAt >= session.goal_nudged_at + settings.interval;
947
+ const turns = worked && !task.goal_mode ? 0 : Number(session.goal_turns ?? 0);
944
948
  const budget = task.goal_max_turns ?? settings.goalMaxTurns;
945
949
  if (turns >= budget) {
946
950
  blockIn(db, id, { reason: `its session went idle ${budget} times without deciding done or blocked; review it`, kind: 'needs_input' });
947
951
  return null;
948
952
  }
949
- setRunSession(db, runRow.id, { ...session, goal_turns: turns + 1, goal_nudged_at: now(), worked_since_ask: false });
953
+ setRunSession(db, runRow.id, { ...session, goal_turns: turns + 1, goal_nudged_at: now() });
950
954
  event(db, id, task.goal_mode ? 'goal_continued' : 'decision_asked', { turn: turns + 1, budget }, runRow.id);
951
955
  const local = normalMachine(task.machine) === localMachine();
952
956
  const where = local ? `--root ${board.root}${board.slug !== 'default' ? ` --board ${board.slug}` : ''}` : `--at ${boardAddress(board.label)}`;
@@ -957,11 +961,12 @@ function pursueGoals(board, fleet, settings, report) {
957
961
  + `A session that stays idle without deciding has its card blocked for its creator after ${budget}.`;
958
962
  return { address: live.address, text, kind: task.goal_mode ? 'goal_continued' : 'decision_asked' };
959
963
  });
960
- if (nudge) {
961
- try { send(boardAddress(board.label), board.label, nudge.address, nudge.text, `board-goal-${id}-${now()}`); report.notified.push([id, nudge.kind, nudge.address]); }
962
- catch (error) { report.undelivered.push([id, nudge.address, String(error.message).slice(-200)]); }
963
- }
964
+ if (nudge) nudges.push({ id, ...nudge });
964
965
  }
966
+ await pool(nudges, SEND_CONCURRENCY, async (nudge) => {
967
+ try { await send(boardAddress(board.label), board.label, nudge.address, nudge.text, `board-goal-${nudge.id}-${now()}`); report.notified.push([nudge.id, nudge.kind, nudge.address]); }
968
+ catch (error) { report.undelivered.push([nudge.id, nudge.address, String(error.message).slice(-200)]); }
969
+ });
965
970
  }
966
971
 
967
972
  /** Hermes's auto-decompose: triage cards with a pane lane get a session that splits them, a few per tick. */
@@ -990,6 +995,32 @@ export function boardLabel(root, slug) {
990
995
  return slug && slug !== 'default' ? `${home}/${slug}` : home;
991
996
  }
992
997
 
998
+ /** `sc` without blocking the tick: notices to many sessions go out side by side. */
999
+ function scAsync(args, { input = null, timeout = 120_000 } = {}) {
1000
+ return new Promise((done) => {
1001
+ const child = spawnChild(supercodeBin(), args, { cwd: tmpdir(), env: process.env, stdio: ['pipe', 'pipe', 'pipe'] });
1002
+ let stdout = '';
1003
+ let stderr = '';
1004
+ const timer = setTimeout(() => { stderr += ` timed out after ${timeout}ms`; child.kill('SIGKILL'); }, timeout);
1005
+ child.stdout.setEncoding('utf8').on('data', (c) => { stdout += c; });
1006
+ child.stderr.setEncoding('utf8').on('data', (c) => { stderr += c; });
1007
+ child.on('error', (error) => { clearTimeout(timer); done({ code: null, stdout, stderr: stderr + error.message }); });
1008
+ child.on('close', (code) => { clearTimeout(timer); done({ code, stdout, stderr }); });
1009
+ child.stdin.end(input ?? '');
1010
+ });
1011
+ }
1012
+
1013
+ // how many deliveries a tick has in flight at once (each is a `supercode message` process of a few seconds)
1014
+ const SEND_CONCURRENCY = 8;
1015
+
1016
+ /** Run `fn` over `items`, at most `n` at a time. */
1017
+ async function pool(items, n, fn) {
1018
+ let next = 0;
1019
+ await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
1020
+ while (next < items.length) { const i = next; next += 1; await fn(items[i]); }
1021
+ }));
1022
+ }
1023
+
993
1024
  /**
994
1025
  * One tick over every board of the home at `root`. `lanes` from `lanesOf`, `settings` from `boardSettings`.
995
1026
  * `only` limits starting to one card (a create that answers whether its card started). Answers one report per board.
@@ -1005,7 +1036,9 @@ const CORRUPT = /malformed|not a database|corrupt|disk image/i;
1005
1036
  */
1006
1037
  export async function tick({ root, lanes, settings, only = null, slugs = null, dispatch = true }) {
1007
1038
  const needFleet = Object.values(lanes).some((l) => l.surface === 'pane');
1039
+ const fleetAt = Date.now();
1008
1040
  const fleet = needFleet ? observeFleet() : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
1041
+ const fleetMs = Date.now() - fleetAt;
1009
1042
  const boards = slugs ?? listBoards(root);
1010
1043
  // the running cards of every board of the home, for kanban.max_in_progress (host-wide, as Hermes counts it)
1011
1044
  let hostRunning = 0;
@@ -1014,7 +1047,9 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
1014
1047
  }
1015
1048
  const out = [];
1016
1049
  for (const slug of boards) {
1017
- const report = { board: slug, mailed: [], started: [], queued: [], failed: [], lost: [], crashed: [], timed_out: [], reclaimed: [], auto_blocked: [], promoted: 0, unassigned: [], nonspawnable: [], notified: [], undelivered: [], fleet: fleet.ok ? 'read' : fleet.error };
1050
+ const report = { board: slug, ms: { fleet: fleetMs }, mailed: [], started: [], queued: [], failed: [], lost: [], crashed: [], timed_out: [], reclaimed: [], auto_blocked: [], promoted: 0, unassigned: [], nonspawnable: [], notified: [], undelivered: [], fleet: fleet.ok ? 'read' : fleet.error };
1051
+ // the mailbox's answers in flight (not part of the report the caller prints)
1052
+ Object.defineProperty(report, 'answers', { value: [], enumerable: false });
1018
1053
  const path = boardPath(root, slug);
1019
1054
  const marker = `${path}.corrupt-seen`;
1020
1055
  if (existsSync(marker) && readFileSync(marker, 'utf8').trim() === fingerprint(path)) { report.error = 'skipped: the board file was corrupt and has not changed since (`supercode workflow repair`)'; out.push(report); continue; }
@@ -1024,14 +1059,18 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
1024
1059
  try {
1025
1060
  board = openBoard(root, slug);
1026
1061
  board.label = boardLabel(root, slug);
1027
- readMailbox(board, boardAddress(board.label), report);
1028
- supervise(board, fleet, settings, report);
1062
+ // where the tick's time goes, phase by phase (ms), printed by `serve --verbose` and in `--json`
1063
+ let mark = Date.now();
1064
+ const phase = (name) => { const t = Date.now(); report.ms[name] = t - mark; mark = t; };
1065
+ readMailbox(board, boardAddress(board.label), report); phase('mailbox');
1066
+ supervise(board, fleet, settings, report); phase('supervise');
1029
1067
  report.promoted = recomputeReady(board, settings.failureLimit);
1030
1068
  const before = report.started.length;
1031
1069
  if (dispatch) start(board, fleet, lanes, settings, report, { only, hostRunning });
1032
- hostRunning += report.started.length - before;
1033
- if (dispatch && !only && !report.paused) { pursueGoals(board, fleet, settings, report); autoDecompose(board, lanes, settings, report); }
1034
- notify(board, fleet, report);
1070
+ hostRunning += report.started.length - before; phase('start');
1071
+ if (dispatch && !only && !report.paused) { await pursueGoals(board, fleet, settings, report); autoDecompose(board, lanes, settings, report); }
1072
+ phase('goals');
1073
+ await notify(board, fleet, report); phase('notify');
1035
1074
  fireHooks(board, settings, report);
1036
1075
  if (String(board.db.prepare('PRAGMA journal_mode').get()?.journal_mode).toLowerCase() === 'wal') board.db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get();
1037
1076
  report.ready_waiting = Number(board.db.prepare("SELECT COUNT(*) AS n FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.status = 'ready' AND t.assignee IS NOT NULL AND c.subtask_of IS NULL").get().n);
@@ -1042,6 +1081,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
1042
1081
  report.error = error.message;
1043
1082
  if (CORRUPT.test(error.message)) writeFileSync(marker, fingerprint(path));
1044
1083
  } finally {
1084
+ await Promise.all(report.answers);
1045
1085
  board?.close();
1046
1086
  lock.release();
1047
1087
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.27",
3
+ "version": "0.4.29",
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": {