@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/agents.mjs +34 -24
- package/bin/orchestrator.mjs +2 -0
- package/board/agent-declarations.mjs +5 -5
- package/board/ci.mjs +98 -102
- package/board/cli.mjs +36 -56
- package/board/dispatch.mjs +314 -190
- package/board/engine.mjs +50 -28
- package/board/git-document.mjs +68 -20
- package/board/machine-fleet.mjs +27 -14
- package/board/owner-health.mjs +30 -0
- package/board/runtime-door.mjs +17 -14
- package/board/tools.mjs +2 -2
- package/board/workflow.mjs +13 -12
- package/board/workspace.mjs +22 -15
- package/board/ztrack.mjs +14 -15
- package/hermes-cli.mjs +4 -12
- package/loop.mjs +7 -7
- package/package.json +2 -2
- package/bin/machine-fleet.mjs +0 -38
- package/bin/runtime-door.mjs +0 -23
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
|
|
33
|
-
try { return new Map((JSON.parse(
|
|
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
|
|
42
|
-
try { return new Map((JSON.parse(
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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();
|