@volter/supercode-orchestrator 0.5.73 → 0.5.75
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 +44 -57
- 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/runtime-door.mjs +17 -14
- package/board/store.mjs +1 -1
- package/board/tools.mjs +2 -2
- package/board/workflow.mjs +13 -12
- package/board/workspace.mjs +22 -15
- package/board/ztrack.mjs +22 -12
- 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,12 +14,14 @@ 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
20
|
import { dispatcherNotice, holdOwnerHealth, holdRound, holdSwitch } from './owner-health.mjs';
|
|
21
21
|
import { boardTool } from './tools.mjs';
|
|
22
22
|
import { recordSlow, takeSteps } from '@volter/supercode-harness-sdk/slow-log';
|
|
23
|
+
// read by name, so an older harness SDK without them still loads: the owner then keeps no per-verb or loop lines
|
|
24
|
+
import * as slowLog from '@volter/supercode-harness-sdk/slow-log';
|
|
23
25
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
24
26
|
import { fileURLToPath } from 'node:url';
|
|
25
27
|
import { homedir, tmpdir, userInfo } from 'node:os';
|
|
@@ -28,19 +30,23 @@ import { isLocalWorkspace } from './workspace.mjs';
|
|
|
28
30
|
import { openZtrackBoard } from './ztrack.mjs';
|
|
29
31
|
import * as engine from './engine.mjs';
|
|
30
32
|
|
|
33
|
+
// A mailbox read through the mailbox's own command, awaited (never a wait that holds the event loop). Gap A31: a
|
|
34
|
+
// helper process until the daemon owns mail (order 3 PR 4).
|
|
35
|
+
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 ?? '')));
|
|
36
|
+
|
|
31
37
|
/** The messages on this machine the mailbox still has to deliver (`supercode message waiting`), by message id. */
|
|
32
|
-
function mailWaiting() {
|
|
33
|
-
const
|
|
34
|
-
try { return new Map((JSON.parse(
|
|
38
|
+
async function mailWaiting() {
|
|
39
|
+
const stdout = await mailRead('waiting');
|
|
40
|
+
try { return new Map((JSON.parse(stdout) ?? []).map((mail) => [mail.id, mail])); } catch { return new Map(); }
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
/**
|
|
38
44
|
* The messages on this machine whose delivery the mailbox gave up on (`supercode message expired`), by message id:
|
|
39
45
|
* filed by the board, never delivered within the mailbox's bound, kept unread. Empty when the mailbox does not answer.
|
|
40
46
|
*/
|
|
41
|
-
function mailExpiries() {
|
|
42
|
-
const
|
|
43
|
-
try { return new Map((JSON.parse(
|
|
47
|
+
async function mailExpiries() {
|
|
48
|
+
const stdout = await mailRead('expired');
|
|
49
|
+
try { return new Map((JSON.parse(stdout) ?? []).map((expiry) => [expiry.id, expiry])); } catch { return new Map(); }
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
/**
|
|
@@ -319,34 +325,6 @@ const metadataOf = (v) => {
|
|
|
319
325
|
return got;
|
|
320
326
|
};
|
|
321
327
|
|
|
322
|
-
/**
|
|
323
|
-
* What is wrong with this orchestrator's version, or null: a build no release names (a hand install of a branch) or one
|
|
324
|
-
* older than npm's latest. Every workflow call says it and the dispatcher tells its managers; action is required until
|
|
325
|
-
* the release is installed. The latest is read from the registry at most once an hour.
|
|
326
|
-
*/
|
|
327
|
-
function releaseBehind() {
|
|
328
|
-
let running;
|
|
329
|
-
try { running = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; } catch { return null; }
|
|
330
|
-
const cache = join(tmpdir(), 'supercode-orchestrator-latest.json');
|
|
331
|
-
let latest = null;
|
|
332
|
-
let fresh = false;
|
|
333
|
-
try { const seen = JSON.parse(readFileSync(cache, 'utf8')); if (Date.now() - seen.at < 3_600_000) { fresh = true; latest = seen.latest; } } catch { /* read below */ }
|
|
334
|
-
if (!fresh) {
|
|
335
|
-
// a failed read is kept for the hour too: offline, no call waits on the registry more than once an hour
|
|
336
|
-
const r = spawnSync('npm', ['view', '@volter/supercode-orchestrator', 'version'], { encoding: 'utf8', timeout: 5000 });
|
|
337
|
-
latest = r.status === 0 ? r.stdout.trim() || null : null;
|
|
338
|
-
try { writeFileSync(cache, JSON.stringify({ at: Date.now(), latest })); } catch { /* read again next call */ }
|
|
339
|
-
}
|
|
340
|
-
if (!latest || running === latest) return null;
|
|
341
|
-
const parts = (v) => v.split('-')[0].split('.').map(Number);
|
|
342
|
-
const [a, b] = [parts(running), parts(latest)];
|
|
343
|
-
const older = a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
344
|
-
const fix = `npm install -g @volter/supercode-orchestrator@${latest} (and each install's pinned copy)`;
|
|
345
|
-
if (running.includes('-')) return { key: `${running}>${latest}`, text: `orchestrator ${running} is not a published release (latest ${latest}); action required: ${fix}` };
|
|
346
|
-
if (older < 0) return { key: `${running}>${latest}`, text: `orchestrator ${running} is behind the published ${latest}; action required: ${fix}` };
|
|
347
|
-
return null;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
328
|
/** One verb in full, in the call it runs in (call.mjs): a caller's in the owner, or the process's own here. */
|
|
351
329
|
export async function runVerb(argv) {
|
|
352
330
|
await knowLocalMachine();
|
|
@@ -362,7 +340,7 @@ export async function runVerb(argv) {
|
|
|
362
340
|
const slug = viewTag ? DEFAULT_BOARD : named;
|
|
363
341
|
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; };
|
|
364
342
|
const withBoard = (fn, create = false) => { const b = open(create); try { return fn(b); } finally { b.close(); } };
|
|
365
|
-
const withBoardAsync = async (fn) => { const b = open(); try { return await fn(b); } finally { b.close(); } };
|
|
343
|
+
const withBoardAsync = async (fn, create = false) => { const b = open(create); try { return await fn(b); } finally { b.close(); } };
|
|
366
344
|
const id = rest[0];
|
|
367
345
|
const need = (n, what) => { if (rest.length < n) throw new Error(`${verb} needs ${what}`); };
|
|
368
346
|
const ids = (first = rest) => [...first, ...(args.ids ?? [])];
|
|
@@ -380,7 +358,7 @@ export async function runVerb(argv) {
|
|
|
380
358
|
if (sid && !READS.has(verb) && boardExists(root, slug)) {
|
|
381
359
|
// binding the caller's launch is a write: only the owner, answering the caller's own verb, makes it (a local read
|
|
382
360
|
// never writes)
|
|
383
|
-
|
|
361
|
+
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); });
|
|
384
362
|
}
|
|
385
363
|
// the board's workflow (the IR's): what each verb does, and who may send it, is its
|
|
386
364
|
// every verb reads the board through its workflow (a list settles cards too), so every verb loads it
|
|
@@ -472,7 +450,7 @@ export async function runVerb(argv) {
|
|
|
472
450
|
// --goal / --no-goal are the create's words; without either, the workflow's initial decides
|
|
473
451
|
// a person filing for themselves sponsors it; an agent filing a person's request names them (proven) or records them unverified
|
|
474
452
|
const sponsorship = args.sponsor || args['requested-by'] ? await requestedSponsor(args) : principalOf()?.startsWith('person:') ? { sponsor: principalOf().slice('person:'.length) } : {};
|
|
475
|
-
const created =
|
|
453
|
+
const created = await withBoardAsync(async (b) => engine.createCard(b, await engine.prepareCard(b, {
|
|
476
454
|
title: rest.join(' '), body: textFrom(args['body-file'], args.body), assignee: args.assignee === 'none' ? null : args.assignee, machine: args.machine,
|
|
477
455
|
workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
|
|
478
456
|
triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
|
|
@@ -482,7 +460,7 @@ export async function runVerb(argv) {
|
|
|
482
460
|
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,
|
|
483
461
|
tags: [...(viewTag ? [viewTag] : []), ...(args.tag ?? [])],
|
|
484
462
|
...sponsorship,
|
|
485
|
-
}), true);
|
|
463
|
+
})), true);
|
|
486
464
|
await withBoardAsync(b=>engine.runEffects(b,created.effects??[]));
|
|
487
465
|
let answer = { id: created.id, existing: created.existing };
|
|
488
466
|
if (args.adopt) {
|
|
@@ -881,10 +859,9 @@ export async function runVerb(argv) {
|
|
|
881
859
|
|
|
882
860
|
case 'diagnostics': case 'diag': {
|
|
883
861
|
const { settings } = await laneState(root).catch(() => ({ settings: boardSettings({}) }));
|
|
884
|
-
const fleet = observeFleet(root);
|
|
862
|
+
const fleet = await observeFleet(root);
|
|
885
863
|
const publishing = await boardPublishing(root);
|
|
886
|
-
const expiredMail = mailExpiries();
|
|
887
|
-
const waitingMail = mailWaiting();
|
|
864
|
+
const [expiredMail, waitingMail] = await Promise.all([await mailExpiries(), await mailWaiting()]);
|
|
888
865
|
const order = ['warning', 'error', 'critical'];
|
|
889
866
|
const found = withBoard((b) => {
|
|
890
867
|
const cards = args.task ? [engine.getTask(b.db, args.task)].filter(Boolean) : engine.listCards(b.db, {});
|
|
@@ -1146,7 +1123,7 @@ export async function runVerb(argv) {
|
|
|
1146
1123
|
}
|
|
1147
1124
|
case 'close': {
|
|
1148
1125
|
need(1, 'a card id');
|
|
1149
|
-
const closed =
|
|
1126
|
+
const closed = await withBoardAsync((b) => closeSession(b, id));
|
|
1150
1127
|
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 });
|
|
1151
1128
|
return 0;
|
|
1152
1129
|
}
|
|
@@ -1453,13 +1430,13 @@ function authorize(db, verb, rest, args, own) {
|
|
|
1453
1430
|
}
|
|
1454
1431
|
|
|
1455
1432
|
/** Close the live sessions of a card that is not running (its worker's and its reviewers'), freeing its machine's place. */
|
|
1456
|
-
function closeSession(board, id) {
|
|
1433
|
+
async function closeSession(board, id) {
|
|
1457
1434
|
const task = engine.getTask(board.db, id);
|
|
1458
1435
|
if (!task) throw new Error(`no card ${id}`);
|
|
1459
1436
|
if (task.current_run_id != null) throw new Error(`${id} has an active run; pause, block or reclaim it before closing its session`);
|
|
1460
1437
|
tx(board, (db) => requestSessionClose(db, id));
|
|
1461
1438
|
const report = { undelivered: [], closed: [] };
|
|
1462
|
-
closeRequested(board, report, { taskId: id, caller: callerSession() });
|
|
1439
|
+
await closeRequested(board, report, { taskId: id, caller: callerSession() });
|
|
1463
1440
|
if (report.undelivered.length) throw new Error(`close remains queued for retry: ${report.undelivered.map((r) => r[2]).join('; ')}`);
|
|
1464
1441
|
return report.closed.length ? report.closed : null;
|
|
1465
1442
|
}
|
|
@@ -1543,8 +1520,20 @@ async function answerVerbs(root, log) {
|
|
|
1543
1520
|
return () => { server.close(); try { unlinkSync(path); } catch { /* gone */ } lock.release(); };
|
|
1544
1521
|
}
|
|
1545
1522
|
|
|
1546
|
-
/** One request to the owner, answered whole: the verb run in the caller's call, its output kept for the answer.
|
|
1523
|
+
/** One request to the owner, answered whole: the verb run in the caller's call, its output kept for the answer. A
|
|
1524
|
+
* verb that takes more than a second is a slow line of its own, with the steps that ended while it ran (its own and,
|
|
1525
|
+
* in this one process, any other call's that held it). */
|
|
1547
1526
|
async function answerOne(root, line) {
|
|
1527
|
+
const began = performance.now();
|
|
1528
|
+
let named = null, by = null;
|
|
1529
|
+
try { return await answerRequest(root, line, (verb, caller) => { named = verb; by = caller; }); }
|
|
1530
|
+
finally {
|
|
1531
|
+
const ms = performance.now() - began;
|
|
1532
|
+
if (named) recordSlow({ kind: 'call', name: `owner verb ${named}`, ms, slowest: null, extra: { caller: by, steps: slowLog.stepsWithin?.(began, performance.now()) ?? null } });
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
async function answerRequest(root, line, naming) {
|
|
1548
1537
|
try {
|
|
1549
1538
|
const request = JSON.parse(line);
|
|
1550
1539
|
const tool = request?.tool && typeof request.tool.name === 'string' ? request.tool : null;
|
|
@@ -1554,6 +1543,7 @@ async function answerOne(root, line) {
|
|
|
1554
1543
|
if (BOARD_OPERATION_VERBS.has(verb)) return { code: 1, stdout: '', stderr: `supercode workflow: ${verb} is run on the board's machine as its own act, not sent to the owner\n` };
|
|
1555
1544
|
const session = request.session && typeof request.session.id === 'string' && ['claude-code', 'codex'].includes(request.session.harness) ? request.session : null;
|
|
1556
1545
|
const caller = typeof request.caller === 'string' ? request.caller : session ? `sc:${localMachine()}:${session.harness}:${session.id}` : null;
|
|
1546
|
+
naming(tool ? `tool ${tool.name}` : verb, caller);
|
|
1557
1547
|
const facts = Object.fromEntries(Object.entries(request.facts ?? {}).filter(([key, value]) => CALLER_FACTS.includes(key) && typeof value === 'string'));
|
|
1558
1548
|
// a caller names its Teams context (the CLI and the daemon always do): one that names none is refused before anything
|
|
1559
1549
|
// is done, never answered with the owner's
|
|
@@ -1604,17 +1594,7 @@ async function serve(root, args = {}) {
|
|
|
1604
1594
|
const FAILED_ROUNDS_ALERT = 5;
|
|
1605
1595
|
const failing = new Map();
|
|
1606
1596
|
let round = 0;
|
|
1607
|
-
// A dispatcher that is not the published release is told up the chain, once per version pair: it keeps running, and
|
|
1608
|
-
// its manager hears that action is required (a caller's stderr line alone reached no one).
|
|
1609
|
-
const versionsTold = new Set();
|
|
1610
|
-
const tellReleaseBehind = async () => {
|
|
1611
|
-
const behind = releaseBehind();
|
|
1612
|
-
if (!behind || versionsTold.has(behind.key)) return;
|
|
1613
|
-
// the send's key makes a retry safe; a pair counts as told only once a manager got it
|
|
1614
|
-
if (await alertManagers(root, 'default', `The dispatcher of ${root} runs ${behind.text}`, `release-behind-${behind.key}`) > 0) versionsTold.add(behind.key);
|
|
1615
|
-
};
|
|
1616
1597
|
const watchRounds = async (reports) => {
|
|
1617
|
-
await tellReleaseBehind().catch(() => {});
|
|
1618
1598
|
if (reports.every((r) => !r.error)) {
|
|
1619
1599
|
// A publication the team refused holds this home's publishing where it is (nothing after it is skipped): a fact of
|
|
1620
1600
|
// the same kind as rounds that stopped, so it goes where every board verb's error line reads it. Only a refusal
|
|
@@ -1660,8 +1640,13 @@ async function serve(root, args = {}) {
|
|
|
1660
1640
|
onChange: (name) => log(`home changed (${name}): read again on its next use`),
|
|
1661
1641
|
onLost: (error) => { log(`the home's files can no longer be watched (${error.message}); stopping, for the keeper to start the owner again`); stop(); },
|
|
1662
1642
|
});
|
|
1643
|
+
// Hermes's install is located once per process (a launch's memory snapshot reads it): here, before any verb is
|
|
1644
|
+
// answered, so no verb or round waits on it
|
|
1645
|
+
try { (await import('../hermes-cli.mjs')).hermesInstall(); } catch { /* none installed */ }
|
|
1663
1646
|
let closeVerbs;
|
|
1664
1647
|
try { closeVerbs = await answerVerbs(root, log); } catch (error) { unfollowHome(); throw error; }
|
|
1648
|
+
// a held event loop is every caller's wait at once: each hold over a second is a slow line naming what held it
|
|
1649
|
+
const unwatchLoop = slowLog.watchEventLoop?.({ name: 'board owner event loop blocked' });
|
|
1665
1650
|
log(`owning the boards of ${root} on ${localMachine()} (orchestrator ${running ?? 'unknown'}); dispatching where a board's dispatching is on (\`supercode workflow dispatching\`)`);
|
|
1666
1651
|
events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log });
|
|
1667
1652
|
// The board's events are published by this machine's connector, not by the dispatcher (D137, D138 step 4): the home is
|
|
@@ -1748,7 +1733,9 @@ async function serve(root, args = {}) {
|
|
|
1748
1733
|
if (moved) { log(`orchestrator ${moved} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
|
|
1749
1734
|
}
|
|
1750
1735
|
} finally {
|
|
1751
|
-
|
|
1736
|
+
// a fetch in flight lands, and what it brought is merged, while this owner still holds the home's lock
|
|
1737
|
+
try { const { settleDocuments } = await import('./git-document.mjs'); await settleDocuments(); } catch { /* nothing in flight */ }
|
|
1738
|
+
unwatchLoop?.(); closeVerbs();
|
|
1752
1739
|
unfollowHome();
|
|
1753
1740
|
await closeDoors();
|
|
1754
1741
|
events?.close();
|