@volter/supercode-orchestrator 0.4.28 → 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();
@@ -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
 
@@ -922,9 +924,10 @@ function readMailbox(board, address, report) {
922
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.
923
925
  * Whether the work is done is the session's call through its card, not an auxiliary judge's.
924
926
  */
925
- function pursueGoals(board, fleet, settings, report) {
927
+ async function pursueGoals(board, fleet, settings, report) {
926
928
  if (!fleet.ok) return;
927
929
  const cards = board.db.prepare("SELECT id FROM tasks WHERE status = 'running'").all();
930
+ const nudges = [];
928
931
  for (const { id } of cards) {
929
932
  const nudge = tx(board, (db) => {
930
933
  const task = getTask(db, id);
@@ -958,11 +961,12 @@ function pursueGoals(board, fleet, settings, report) {
958
961
  + `A session that stays idle without deciding has its card blocked for its creator after ${budget}.`;
959
962
  return { address: live.address, text, kind: task.goal_mode ? 'goal_continued' : 'decision_asked' };
960
963
  });
961
- if (nudge) {
962
- try { send(boardAddress(board.label), board.label, nudge.address, nudge.text, `board-goal-${id}-${now()}`); report.notified.push([id, nudge.kind, nudge.address]); }
963
- catch (error) { report.undelivered.push([id, nudge.address, String(error.message).slice(-200)]); }
964
- }
964
+ if (nudge) nudges.push({ id, ...nudge });
965
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
+ });
966
970
  }
967
971
 
968
972
  /** Hermes's auto-decompose: triage cards with a pane lane get a session that splits them, a few per tick. */
@@ -991,6 +995,32 @@ export function boardLabel(root, slug) {
991
995
  return slug && slug !== 'default' ? `${home}/${slug}` : home;
992
996
  }
993
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
+
994
1024
  /**
995
1025
  * One tick over every board of the home at `root`. `lanes` from `lanesOf`, `settings` from `boardSettings`.
996
1026
  * `only` limits starting to one card (a create that answers whether its card started). Answers one report per board.
@@ -1006,7 +1036,9 @@ const CORRUPT = /malformed|not a database|corrupt|disk image/i;
1006
1036
  */
1007
1037
  export async function tick({ root, lanes, settings, only = null, slugs = null, dispatch = true }) {
1008
1038
  const needFleet = Object.values(lanes).some((l) => l.surface === 'pane');
1039
+ const fleetAt = Date.now();
1009
1040
  const fleet = needFleet ? observeFleet() : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
1041
+ const fleetMs = Date.now() - fleetAt;
1010
1042
  const boards = slugs ?? listBoards(root);
1011
1043
  // the running cards of every board of the home, for kanban.max_in_progress (host-wide, as Hermes counts it)
1012
1044
  let hostRunning = 0;
@@ -1015,7 +1047,9 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
1015
1047
  }
1016
1048
  const out = [];
1017
1049
  for (const slug of boards) {
1018
- 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 });
1019
1053
  const path = boardPath(root, slug);
1020
1054
  const marker = `${path}.corrupt-seen`;
1021
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; }
@@ -1025,14 +1059,18 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
1025
1059
  try {
1026
1060
  board = openBoard(root, slug);
1027
1061
  board.label = boardLabel(root, slug);
1028
- readMailbox(board, boardAddress(board.label), report);
1029
- 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');
1030
1067
  report.promoted = recomputeReady(board, settings.failureLimit);
1031
1068
  const before = report.started.length;
1032
1069
  if (dispatch) start(board, fleet, lanes, settings, report, { only, hostRunning });
1033
- hostRunning += report.started.length - before;
1034
- if (dispatch && !only && !report.paused) { pursueGoals(board, fleet, settings, report); autoDecompose(board, lanes, settings, report); }
1035
- 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');
1036
1074
  fireHooks(board, settings, report);
1037
1075
  if (String(board.db.prepare('PRAGMA journal_mode').get()?.journal_mode).toLowerCase() === 'wal') board.db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get();
1038
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);
@@ -1043,6 +1081,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
1043
1081
  report.error = error.message;
1044
1082
  if (CORRUPT.test(error.message)) writeFileSync(marker, fingerprint(path));
1045
1083
  } finally {
1084
+ await Promise.all(report.answers);
1046
1085
  board?.close();
1047
1086
  lock.release();
1048
1087
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.28",
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": {