@volter/supercode-orchestrator 0.5.72 → 0.5.74

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
@@ -14,9 +14,10 @@ import { taskStatusOf } from './published.mjs';
14
14
  import { createServer } from 'node:net';
15
15
  import { chmodSync, existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
16
16
  import { notRuntime, ownHome } from './owner-files.mjs';
17
- import { spawnSync } from 'node:child_process';
17
+ import { execFile, spawnSync } from 'node:child_process';
18
18
  import { holdLock } from './lock-holder.mjs';
19
19
  import { takeEpoch } from './fence.mjs';
20
+ import { dispatcherNotice, holdOwnerHealth, holdRound, holdSwitch } from './owner-health.mjs';
20
21
  import { boardTool } from './tools.mjs';
21
22
  import { recordSlow, takeSteps } from '@volter/supercode-harness-sdk/slow-log';
22
23
  import { basename, dirname, join, resolve } from 'node:path';
@@ -27,19 +28,23 @@ import { isLocalWorkspace } from './workspace.mjs';
27
28
  import { openZtrackBoard } from './ztrack.mjs';
28
29
  import * as engine from './engine.mjs';
29
30
 
31
+ // A mailbox read through the mailbox's own command, awaited (never a wait that holds the event loop). Gap A31: a
32
+ // helper process until the daemon owns mail (order 3 PR 4).
33
+ const mailRead = (verb) => new Promise((resolve) => execFile(supercodeBin(), ['message', verb, '--json'], { encoding: 'utf8', env: commandEnv(), timeout: 15_000, maxBuffer: 64 * 1024 * 1024 }, (_, stdout) => resolve(stdout ?? '')));
34
+
30
35
  /** The messages on this machine the mailbox still has to deliver (`supercode message waiting`), by message id. */
31
- function mailWaiting() {
32
- const r = spawnSync(supercodeBin(), ['message', 'waiting', '--json'], { encoding: 'utf8', env: commandEnv(), timeout: 15_000 });
33
- try { return new Map((JSON.parse(r.stdout) ?? []).map((mail) => [mail.id, mail])); } catch { return new Map(); }
36
+ async function mailWaiting() {
37
+ const stdout = await mailRead('waiting');
38
+ try { return new Map((JSON.parse(stdout) ?? []).map((mail) => [mail.id, mail])); } catch { return new Map(); }
34
39
  }
35
40
 
36
41
  /**
37
42
  * The messages on this machine whose delivery the mailbox gave up on (`supercode message expired`), by message id:
38
43
  * filed by the board, never delivered within the mailbox's bound, kept unread. Empty when the mailbox does not answer.
39
44
  */
40
- function mailExpiries() {
41
- const r = spawnSync(supercodeBin(), ['message', 'expired', '--json'], { encoding: 'utf8', env: commandEnv(), timeout: 15_000 });
42
- try { return new Map((JSON.parse(r.stdout) ?? []).map((expiry) => [expiry.id, expiry])); } catch { return new Map(); }
45
+ async function mailExpiries() {
46
+ const stdout = await mailRead('expired');
47
+ try { return new Map((JSON.parse(stdout) ?? []).map((expiry) => [expiry.id, expiry])); } catch { return new Map(); }
43
48
  }
44
49
 
45
50
  /**
@@ -318,34 +323,6 @@ const metadataOf = (v) => {
318
323
  return got;
319
324
  };
320
325
 
321
- /**
322
- * What is wrong with this orchestrator's version, or null: a build no release names (a hand install of a branch) or one
323
- * older than npm's latest. Every workflow call says it and the dispatcher tells its managers; action is required until
324
- * the release is installed. The latest is read from the registry at most once an hour.
325
- */
326
- function releaseBehind() {
327
- let running;
328
- try { running = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; } catch { return null; }
329
- const cache = join(tmpdir(), 'supercode-orchestrator-latest.json');
330
- let latest = null;
331
- let fresh = false;
332
- try { const seen = JSON.parse(readFileSync(cache, 'utf8')); if (Date.now() - seen.at < 3_600_000) { fresh = true; latest = seen.latest; } } catch { /* read below */ }
333
- if (!fresh) {
334
- // a failed read is kept for the hour too: offline, no call waits on the registry more than once an hour
335
- const r = spawnSync('npm', ['view', '@volter/supercode-orchestrator', 'version'], { encoding: 'utf8', timeout: 5000 });
336
- latest = r.status === 0 ? r.stdout.trim() || null : null;
337
- try { writeFileSync(cache, JSON.stringify({ at: Date.now(), latest })); } catch { /* read again next call */ }
338
- }
339
- if (!latest || running === latest) return null;
340
- const parts = (v) => v.split('-')[0].split('.').map(Number);
341
- const [a, b] = [parts(running), parts(latest)];
342
- const older = a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
343
- const fix = `npm install -g @volter/supercode-orchestrator@${latest} (and each install's pinned copy)`;
344
- if (running.includes('-')) return { key: `${running}>${latest}`, text: `orchestrator ${running} is not a published release (latest ${latest}); action required: ${fix}` };
345
- if (older < 0) return { key: `${running}>${latest}`, text: `orchestrator ${running} is behind the published ${latest}; action required: ${fix}` };
346
- return null;
347
- }
348
-
349
326
  /** One verb in full, in the call it runs in (call.mjs): a caller's in the owner, or the process's own here. */
350
327
  export async function runVerb(argv) {
351
328
  await knowLocalMachine();
@@ -361,7 +338,7 @@ export async function runVerb(argv) {
361
338
  const slug = viewTag ? DEFAULT_BOARD : named;
362
339
  const open = (create = false) => { const b = openBoard(root, slug, { create, document: args.document ?? null, backing: create ? args.backing ?? null : null }); b.label = boardLabel(root, slug); return b; };
363
340
  const withBoard = (fn, create = false) => { const b = open(create); try { return fn(b); } finally { b.close(); } };
364
- const withBoardAsync = async (fn) => { const b = open(); try { return await fn(b); } finally { b.close(); } };
341
+ const withBoardAsync = async (fn, create = false) => { const b = open(create); try { return await fn(b); } finally { b.close(); } };
365
342
  const id = rest[0];
366
343
  const need = (n, what) => { if (rest.length < n) throw new Error(`${verb} needs ${what}`); };
367
344
  const ids = (first = rest) => [...first, ...(args.ids ?? [])];
@@ -374,10 +351,12 @@ export async function runVerb(argv) {
374
351
  current().recorded = sid && boardExists(root, slug) ? withBoard((b) => b.db.prepare("SELECT json_extract(metadata, '$.supercode.address') AS address FROM task_runs WHERE json_extract(metadata, '$.supercode.session_id') = ? ORDER BY id DESC LIMIT 1").get(sid)?.address) ?? null : null;
375
352
  if (sid) setActor(callerAddress());
376
353
  setPrincipal(await callerPrincipal(root, sid, { teams: !sid && !READS.has(verb) }));
354
+ // the dispatcher as the owner holds it, said once per call (owner-health.mjs; nothing is read for it)
355
+ if (!current().said.has(`dispatcher:${slug}`)) { current().said.add(`dispatcher:${slug}`); for (const line of dispatcherNotice(slug)) current().err(`${line}\n`); }
377
356
  if (sid && !READS.has(verb) && boardExists(root, slug)) {
378
357
  // binding the caller's launch is a write: only the owner, answering the caller's own verb, makes it (a local read
379
358
  // never writes)
380
- withBoard((b) => { if (isCallersCall()) reconcileCallerLaunch(b, sid); const own = ownCard(b.db, sid); if (own) authorize(b.db, verb, rest, args, own); });
359
+ await withBoardAsync(async (b) => { if (isCallersCall()) await reconcileCallerLaunch(b, sid); const own = ownCard(b.db, sid); if (own) authorize(b.db, verb, rest, args, own); });
381
360
  }
382
361
  // the board's workflow (the IR's): what each verb does, and who may send it, is its
383
362
  // every verb reads the board through its workflow (a list settles cards too), so every verb loads it
@@ -469,7 +448,7 @@ export async function runVerb(argv) {
469
448
  // --goal / --no-goal are the create's words; without either, the workflow's initial decides
470
449
  // a person filing for themselves sponsors it; an agent filing a person's request names them (proven) or records them unverified
471
450
  const sponsorship = args.sponsor || args['requested-by'] ? await requestedSponsor(args) : principalOf()?.startsWith('person:') ? { sponsor: principalOf().slice('person:'.length) } : {};
472
- const created = withBoard((b) => engine.createCard(b, {
451
+ const created = await withBoardAsync(async (b) => engine.createCard(b, await engine.prepareCard(b, {
473
452
  title: rest.join(' '), body: textFrom(args['body-file'], args.body), assignee: args.assignee === 'none' ? null : args.assignee, machine: args.machine,
474
453
  workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
475
454
  triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
@@ -479,7 +458,7 @@ export async function runVerb(argv) {
479
458
  project: args.project ?? meta.project_id ?? null, goal: args.goal ? { maxTurns: args['goal-max-turns'] ? Number(args['goal-max-turns']) : null } : args['no-goal'] ? false : null,
480
459
  tags: [...(viewTag ? [viewTag] : []), ...(args.tag ?? [])],
481
460
  ...sponsorship,
482
- }), true);
461
+ })), true);
483
462
  await withBoardAsync(b=>engine.runEffects(b,created.effects??[]));
484
463
  let answer = { id: created.id, existing: created.existing };
485
464
  if (args.adopt) {
@@ -878,10 +857,9 @@ export async function runVerb(argv) {
878
857
 
879
858
  case 'diagnostics': case 'diag': {
880
859
  const { settings } = await laneState(root).catch(() => ({ settings: boardSettings({}) }));
881
- const fleet = observeFleet(root);
860
+ const fleet = await observeFleet(root);
882
861
  const publishing = await boardPublishing(root);
883
- const expiredMail = mailExpiries();
884
- const waitingMail = mailWaiting();
862
+ const [expiredMail, waitingMail] = await Promise.all([await mailExpiries(), await mailWaiting()]);
885
863
  const order = ['warning', 'error', 'critical'];
886
864
  const found = withBoard((b) => {
887
865
  const cards = args.task ? [engine.getTask(b.db, args.task)].filter(Boolean) : engine.listCards(b.db, {});
@@ -1143,7 +1121,7 @@ export async function runVerb(argv) {
1143
1121
  }
1144
1122
  case 'close': {
1145
1123
  need(1, 'a card id');
1146
- const closed = withBoard((b) => closeSession(b, id));
1124
+ const closed = await withBoardAsync((b) => closeSession(b, id));
1147
1125
  say(args, closed ? closed.map((c) => `closed ${c.session_id} (pane ${c.pane} on ${c.machine})`).join('\n') : `${id} has no live session`, { ok: true, closed });
1148
1126
  return 0;
1149
1127
  }
@@ -1450,13 +1428,13 @@ function authorize(db, verb, rest, args, own) {
1450
1428
  }
1451
1429
 
1452
1430
  /** Close the live sessions of a card that is not running (its worker's and its reviewers'), freeing its machine's place. */
1453
- function closeSession(board, id) {
1431
+ async function closeSession(board, id) {
1454
1432
  const task = engine.getTask(board.db, id);
1455
1433
  if (!task) throw new Error(`no card ${id}`);
1456
1434
  if (task.current_run_id != null) throw new Error(`${id} has an active run; pause, block or reclaim it before closing its session`);
1457
1435
  tx(board, (db) => requestSessionClose(db, id));
1458
1436
  const report = { undelivered: [], closed: [] };
1459
- closeRequested(board, report, { taskId: id, caller: callerSession() });
1437
+ await closeRequested(board, report, { taskId: id, caller: callerSession() });
1460
1438
  if (report.undelivered.length) throw new Error(`close remains queued for retry: ${report.undelivered.map((r) => r[2]).join('; ')}`);
1461
1439
  return report.closed.length ? report.closed : null;
1462
1440
  }
@@ -1487,6 +1465,7 @@ function setDispatching(root, slug, on, reason) {
1487
1465
  db.prepare("INSERT INTO supercode_board_state (key, value) VALUES ('dispatching', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(JSON.stringify(value));
1488
1466
  });
1489
1467
  } finally { board.close(); }
1468
+ holdSwitch(slug, value);
1490
1469
  return value;
1491
1470
  }
1492
1471
 
@@ -1508,6 +1487,10 @@ async function answerVerbs(root, log) {
1508
1487
  ownHome(home);
1509
1488
  // the lock held, this owner takes its fencing epoch and fences every board, before it answers anything (fence.mjs)
1510
1489
  const epoch = takeEpoch(root, () => listBoards(root).map((slug) => openBoard(root, slug)));
1490
+ // what the owner holds of its dispatcher from here on: each board's switch, and its predecessor's last round, read once
1491
+ let lastRound = null;
1492
+ try { const beat = JSON.parse(readFileSync(join(root, 'dispatcher-heartbeat.json'), 'utf8')); lastRound = { at: beat.at, errors: beat.errors ?? [] }; } catch { /* no round recorded yet */ }
1493
+ holdOwnerHealth(listBoards(root).map((slug) => [slug, dispatchingOf(root, slug)]), lastRound);
1511
1494
  log(`owner epoch ${epoch}`);
1512
1495
  const path = boardOwnerSocket(root);
1513
1496
  try { unlinkSync(path); } catch { /* none left */ }
@@ -1596,17 +1579,7 @@ async function serve(root, args = {}) {
1596
1579
  const FAILED_ROUNDS_ALERT = 5;
1597
1580
  const failing = new Map();
1598
1581
  let round = 0;
1599
- // A dispatcher that is not the published release is told up the chain, once per version pair: it keeps running, and
1600
- // its manager hears that action is required (a caller's stderr line alone reached no one).
1601
- const versionsTold = new Set();
1602
- const tellReleaseBehind = async () => {
1603
- const behind = releaseBehind();
1604
- if (!behind || versionsTold.has(behind.key)) return;
1605
- // the send's key makes a retry safe; a pair counts as told only once a manager got it
1606
- if (await alertManagers(root, 'default', `The dispatcher of ${root} runs ${behind.text}`, `release-behind-${behind.key}`) > 0) versionsTold.add(behind.key);
1607
- };
1608
1582
  const watchRounds = async (reports) => {
1609
- await tellReleaseBehind().catch(() => {});
1610
1583
  if (reports.every((r) => !r.error)) {
1611
1584
  // A publication the team refused holds this home's publishing where it is (nothing after it is skipped): a fact of
1612
1585
  // the same kind as rounds that stopped, so it goes where every board verb's error line reads it. Only a refusal
@@ -1617,7 +1590,9 @@ async function serve(root, args = {}) {
1617
1590
  const heldLine = refused ? [`publishing to the team is held: the team refused ${refused.card}'s ${refused.kind} (sequence ${refused.sequence}) with ${refused.status}${refused.code ? ` ${refused.code}` : ''}: ${String(refused.error ?? '').split('\n')[0].slice(0, 200)} (tried ${refused.attempts} time(s)); nothing of this home after it is published until the team takes it`] : [];
1618
1591
  const temporary = `${heartbeat}.${process.pid}.tmp`;
1619
1592
  try {
1620
- writeFileSync(temporary, `${JSON.stringify({ at: new Date().toISOString(), round, pid: process.pid, version: installed(), boards: reports.map((r) => ({ board: r.board, started: r.started?.length ?? 0, queued: r.queued?.length ?? 0, failed: r.failed?.length ?? 0, ms: r.ms ?? null })), errors: [...heldLine, ...reports.flatMap((r) => [...(r.failed ?? []), ...(r.unsettled ?? [])].map(([t, why]) => `${r.board} ${t}: ${String(why).split('\n')[0].slice(0, 300)}`))].slice(0, 20) })}\n`);
1593
+ const beat = { at: new Date().toISOString(), round, pid: process.pid, version: installed(), boards: reports.map((r) => ({ board: r.board, started: r.started?.length ?? 0, queued: r.queued?.length ?? 0, failed: r.failed?.length ?? 0, ms: r.ms ?? null })), errors: [...heldLine, ...reports.flatMap((r) => [...(r.failed ?? []), ...(r.unsettled ?? [])].map(([t, why]) => `${r.board} ${t}: ${String(why).split('\n')[0].slice(0, 300)}`))].slice(0, 20) };
1594
+ holdRound(beat.at, beat.errors);
1595
+ writeFileSync(temporary, `${JSON.stringify(beat)}\n`);
1621
1596
  renameSync(temporary, heartbeat);
1622
1597
  } catch { /* the next round writes it */ }
1623
1598
  }
@@ -1650,6 +1625,9 @@ async function serve(root, args = {}) {
1650
1625
  onChange: (name) => log(`home changed (${name}): read again on its next use`),
1651
1626
  onLost: (error) => { log(`the home's files can no longer be watched (${error.message}); stopping, for the keeper to start the owner again`); stop(); },
1652
1627
  });
1628
+ // Hermes's install is located once per process (a launch's memory snapshot reads it): here, before any verb is
1629
+ // answered, so no verb or round waits on it
1630
+ try { (await import('../hermes-cli.mjs')).hermesInstall(); } catch { /* none installed */ }
1653
1631
  let closeVerbs;
1654
1632
  try { closeVerbs = await answerVerbs(root, log); } catch (error) { unfollowHome(); throw error; }
1655
1633
  log(`owning the boards of ${root} on ${localMachine()} (orchestrator ${running ?? 'unknown'}); dispatching where a board's dispatching is on (\`supercode workflow dispatching\`)`);
@@ -1738,6 +1716,8 @@ async function serve(root, args = {}) {
1738
1716
  if (moved) { log(`orchestrator ${moved} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
1739
1717
  }
1740
1718
  } finally {
1719
+ // a fetch in flight lands, and what it brought is merged, while this owner still holds the home's lock
1720
+ try { const { settleDocuments } = await import('./git-document.mjs'); await settleDocuments(); } catch { /* nothing in flight */ }
1741
1721
  closeVerbs();
1742
1722
  unfollowHome();
1743
1723
  await closeDoors();