@volter/supercode-orchestrator 0.5.55 → 0.5.57
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/bin/orchestrator.mjs +8 -0
- package/board/cli.mjs +58 -9
- package/board/dispatch.mjs +40 -38
- package/board/engine.mjs +4 -4
- package/board/store.mjs +2 -2
- package/board/ztrack.mjs +10 -3
- package/hand-off.mjs +46 -0
- package/package.json +2 -2
package/bin/orchestrator.mjs
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// Any command on a home whose record names another orchestrator is that orchestrator's (hand-off.mjs).
|
|
3
|
+
{
|
|
4
|
+
const { handOff, homeOfArgs } = await import('../hand-off.mjs');
|
|
5
|
+
let handed;
|
|
6
|
+
try { handed = handOff(homeOfArgs(process.argv.slice(2)), process.argv.slice(2)); }
|
|
7
|
+
catch (error) { process.stderr.write(`supercode ${process.argv[2] ?? ''}: ${error.message}\n`); process.exit(1); }
|
|
8
|
+
if (handed !== null) process.exit(await handed);
|
|
9
|
+
}
|
|
2
10
|
// Board commands do not load daemon transports, platform adapters or the MCP server.
|
|
3
11
|
if (process.argv[2] === 'workflow') {
|
|
4
12
|
const [{runWorkflow},{closeWorld},{timeCommand}]=await Promise.all([import('../board/cli.mjs'),import('../orchestration.mjs'),import('@volter/supercode-harness-sdk/slow-log')]);
|
package/board/cli.mjs
CHANGED
|
@@ -8,7 +8,8 @@ import { taskStatusOf } from './published.mjs';
|
|
|
8
8
|
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { spawnSync } from 'node:child_process';
|
|
10
10
|
import { recordSlow, takeSteps } from '@volter/supercode-harness-sdk/slow-log';
|
|
11
|
-
import { basename, join, resolve } from 'node:path';
|
|
11
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
12
13
|
import { homedir, tmpdir, userInfo } from 'node:os';
|
|
13
14
|
import { read } from '../orchestration.mjs';
|
|
14
15
|
import { actorOf, view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, setPrincipal, principalOf, isCaller, tx } from './store.mjs';
|
|
@@ -43,7 +44,7 @@ function boardPublishing(root) {
|
|
|
43
44
|
let here; try { here = realpathSync(root); } catch { here = resolve(root); }
|
|
44
45
|
try { const listed = JSON.parse(read.stdout); const board = listed.boards?.find((b) => b.root === here); return board ? { ...board, refused: listed.refused ?? null } : null; } catch { return null; }
|
|
45
46
|
}
|
|
46
|
-
import { requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, stopDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
|
|
47
|
+
import { requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, stopDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
|
|
47
48
|
import { watchBoard } from './wake.mjs';
|
|
48
49
|
import { streamBoardPublications } from './event-stream.mjs';
|
|
49
50
|
import { completionOwnerOf, isLatestReviewer, loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
|
|
@@ -56,7 +57,7 @@ const textFrom = (file, inline) => file === undefined ? inline : readFileSync(fi
|
|
|
56
57
|
const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
|
|
57
58
|
|
|
58
59
|
The board's verbs, with their flags:
|
|
59
|
-
init [--backing sqlite|files|ztrack] [--document <arcs.md>]
|
|
60
|
+
init [--backing sqlite|files|ztrack] [--document <arcs.md>] [--commit] [--manager <address>] [--archive-max-lines N] [--archive-period month]
|
|
60
61
|
migrate --backing ztrack --document <arcs.md> --status-map <json>
|
|
61
62
|
boards list|create|rm|switch|show|rename|set-default-workdir|export|import
|
|
62
63
|
create <title> [--body | --body-file PATH|-] [--assignee] [--parent]... [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch] [--project]
|
|
@@ -120,7 +121,7 @@ And:
|
|
|
120
121
|
|
|
121
122
|
const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'tag', 'pr', 'pr-head', 'reviewed-sha', 'finding']);
|
|
122
123
|
const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'reclaim', 'dry-run',
|
|
123
|
-
'once', 'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose']);
|
|
124
|
+
'once', 'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose', 'commit']);
|
|
124
125
|
// flags that take every following word up to the next flag (Hermes's nargs='+')
|
|
125
126
|
const GREEDY = new Set(['ids', 'rm']);
|
|
126
127
|
|
|
@@ -240,7 +241,7 @@ let recordedCaller = null;
|
|
|
240
241
|
function callerAddress() {
|
|
241
242
|
const id = callerSession();
|
|
242
243
|
if (!id) return null;
|
|
243
|
-
return recordedCaller ?? `sc:${localMachine()}:${id === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code'}:${id}`;
|
|
244
|
+
return recordedCaller ?? boardCaller()?.address ?? `sc:${localMachine()}:${id === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code'}:${id}`;
|
|
244
245
|
}
|
|
245
246
|
|
|
246
247
|
const callerName = () => process.env.HERMES_PROFILE || process.env.USER || 'user';
|
|
@@ -254,7 +255,7 @@ function callerPrincipal(root, sid, { teams = false } = {}) {
|
|
|
254
255
|
if (process.env.SUPERCODE_AGENT_ID) return `agent:${process.env.SUPERCODE_AGENT_ID}`;
|
|
255
256
|
if (process.env.SUPERCODE_AUTOMATION) return `automation:${process.env.SUPERCODE_AUTOMATION}`;
|
|
256
257
|
if (sid) {
|
|
257
|
-
const harness = sid === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code';
|
|
258
|
+
const harness = boardCaller()?.harness ?? (sid === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code');
|
|
258
259
|
const owner = agentRunForSession(root, [`${harness}:${sid}`]);
|
|
259
260
|
if (owner) return `agent:${owner.agent_id}`;
|
|
260
261
|
// a session an agent declared elsewhere owns (a manager's main session) is that agent's, as Teams records it
|
|
@@ -361,11 +362,20 @@ function sayReleaseBehind() {
|
|
|
361
362
|
if (behind) process.stderr.write(`board: error: ${behind.text}\n`);
|
|
362
363
|
}
|
|
363
364
|
|
|
365
|
+
/**
|
|
366
|
+
* The verbs a session on another machine may not call on a board (through its machine's board door): running the
|
|
367
|
+
* board itself (its dispatcher, its init, its repair and housekeeping, adoption) and verbs that hold the call open (a
|
|
368
|
+
* watch, a tail, an attach, the event stream). They are run on the board's machine. A verb the door started reads this
|
|
369
|
+
* one list and refuses before it does anything.
|
|
370
|
+
*/
|
|
371
|
+
export const NOT_FROM_ANOTHER_MACHINE = new Set(['serve', 'daemon', 'dispatch', 'init', 'adopt', 'boards', 'gc', 'repair', 'migrate', 'watch', 'tail', 'attach', 'events']);
|
|
372
|
+
|
|
364
373
|
export async function runWorkflow(argv) {
|
|
365
374
|
const args = parse(argv);
|
|
366
375
|
const [verb, ...rest] = args._;
|
|
367
376
|
if (!verb || verb === 'help' || args.help) { process.stdout.write(HELP); return 0; }
|
|
368
377
|
const root = homeOf(args);
|
|
378
|
+
if (process.env.SUPERCODE_BOARD_CALL === '1' && NOT_FROM_ANOTHER_MACHINE.has(verb)) throw new Error(`${verb} is run on the board's own machine, not called from another`);
|
|
369
379
|
// the dispatcher's own verbs run every round; its serve says it once when it starts
|
|
370
380
|
if (!['dispatch', 'events'].includes(verb)) sayReleaseBehind();
|
|
371
381
|
if (verb === 'events') return streamBoardPublications({root,after:args.after,once:args.once});
|
|
@@ -423,9 +433,41 @@ export async function runWorkflow(argv) {
|
|
|
423
433
|
return 0;
|
|
424
434
|
}
|
|
425
435
|
case 'init': {
|
|
426
|
-
|
|
436
|
+
// A board on the ztrack backing is made with what its ztrack.json declares besides its document: `--commit` (the
|
|
437
|
+
// documents live in a git checkout, each write committed and pushed), its manager (`--manager`, else the first one
|
|
438
|
+
// the home's workflow names), whose own writes it does not commit, and the archive's rotation. A board already there
|
|
439
|
+
// is kept as it is: one kept by another backing, or naming another document, is refused, naming what it is, and
|
|
440
|
+
// nothing is changed; the same board again is said and left.
|
|
441
|
+
const kept = boardExists(root, slug) ? backingOf(root, slug) : null;
|
|
442
|
+
let declared = null;
|
|
443
|
+
if (args.backing === 'ztrack') {
|
|
444
|
+
if (kept && kept !== 'ztrack') throw new Error(`board ${slug} in ${root} is kept by the ${kept} backing, not ztrack; it is left as it is (\`workflow migrate --backing ztrack\` moves its cards)`);
|
|
445
|
+
if (kept && args.document) {
|
|
446
|
+
const named = JSON.parse(readFileSync(backingPaths(root, slug).ztrack, 'utf8')).document;
|
|
447
|
+
const real = (path) => { try { return join(realpathSync(dirname(resolve(path))), basename(path)); } catch { return resolve(path); } };
|
|
448
|
+
if (real(named) !== real(args.document)) throw new Error(`board ${slug} in ${root} is kept on ${named}, not ${real(args.document)}; it is left as it is`);
|
|
449
|
+
}
|
|
450
|
+
// With no --manager, the home's one manager; a home naming several is asked which (the first is an accident of
|
|
451
|
+
// order), and one naming none makes a board whose every write is committed.
|
|
452
|
+
const managers = [(await loadWorkflow(root).catch(() => null))?.params?.managers ?? []].flat().filter(Boolean);
|
|
453
|
+
if (!args.manager && managers.length > 1) throw new Error(`the home's workflow names ${managers.length} managers (${managers.join(', ')}); name the board's with --manager. Nothing was made`);
|
|
454
|
+
const archive = args['archive-max-lines'] || args['archive-period'] ? { ...(args['archive-max-lines'] ? { max_lines: Number(args['archive-max-lines']) } : {}), ...(args['archive-period'] ? { period: args['archive-period'] } : {}) } : null;
|
|
455
|
+
declared = { commit: Boolean(args.commit), manager: args.manager ?? managers[0] ?? null, archive };
|
|
456
|
+
}
|
|
457
|
+
const made = openBoard(root, slug, { create: true, document: args.document ?? null, backing: args.backing ?? null, declared });
|
|
458
|
+
// A home records the orchestrator that serves it and the supercode it drives (<home>/orchestrator.json, read by
|
|
459
|
+
// hand-off.mjs for every command on the home); an install's keeper writes it, and a home made by hand gets it
|
|
460
|
+
// here, naming this orchestrator and the supercode that ran it. A record already there is the install's and stays.
|
|
461
|
+
const record = join(root, 'orchestrator.json');
|
|
462
|
+
if (!existsSync(record)) {
|
|
463
|
+
const named = (bin) => { try { const entry = realpathSync(bin); let version = null; try { version = JSON.parse(readFileSync(join(dirname(entry), '..', 'package.json'), 'utf8')).version ?? null; } catch { /* none beside it */ } return { entry, version }; } catch { return null; } };
|
|
464
|
+
const own = named(fileURLToPath(new URL('../bin/orchestrator.mjs', import.meta.url)));
|
|
465
|
+
writeFileSync(record, `${JSON.stringify({ package: '@volter/supercode-orchestrator', version: own?.version ?? null, entry: own?.entry ?? null, supercode: process.env.SUPERCODE_BIN ? named(process.env.SUPERCODE_BIN) : null })}\n`);
|
|
466
|
+
}
|
|
467
|
+
const backing = made.backing;
|
|
468
|
+
made.close();
|
|
427
469
|
const { profiles } = await laneState(root).catch(() => ({ profiles: [] }));
|
|
428
|
-
say(args, `${backing === 'sqlite' ? 'Kanban DB' : `Board (${backing} backing)`} initialized at ${boardPath(root, slug)}\n\n${profiles.length ? `Profiles of this home (any can be an --assignee): ${profiles.join(', ')}` : 'No profiles in this home.'}\nThe dispatcher: \`supercode workflow serve\`, or the orchestrator while it serves the home.`, { ok: true, board: slug, path: boardPath(root, slug), backing, profiles });
|
|
470
|
+
say(args, `${backing === 'sqlite' ? 'Kanban DB' : `Board (${backing} backing)`} ${kept ? 'already' : 'initialized'} at ${boardPath(root, slug)}\n\n${profiles.length ? `Profiles of this home (any can be an --assignee): ${profiles.join(', ')}` : 'No profiles in this home.'}\nThe dispatcher: \`supercode workflow serve\`, or the orchestrator while it serves the home.`, { ok: true, board: slug, path: boardPath(root, slug), backing, kept: Boolean(kept), profiles });
|
|
429
471
|
return 0;
|
|
430
472
|
}
|
|
431
473
|
|
|
@@ -1455,9 +1497,16 @@ async function serve(root, args = {}) {
|
|
|
1455
1497
|
const watchRounds = async (reports) => {
|
|
1456
1498
|
await tellReleaseBehind().catch(() => {});
|
|
1457
1499
|
if (reports.every((r) => !r.error)) {
|
|
1500
|
+
// A publication the team refused holds this home's publishing where it is (nothing after it is skipped): a fact of
|
|
1501
|
+
// the same kind as rounds that stopped, so it goes where every board verb's error line reads it. Only a refusal
|
|
1502
|
+
// in hand counts: the team's 4xx answer, which the daemon records with the publication (from supercode-teams
|
|
1503
|
+
// 0.3.44). An attempt still being retried, or a daemon that records no answer, says nothing here.
|
|
1504
|
+
const held = boardPublishing(root)?.failing;
|
|
1505
|
+
const refused = held && Number(held.status) >= 400 && Number(held.status) < 500 ? held : null;
|
|
1506
|
+
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`] : [];
|
|
1458
1507
|
const temporary = `${heartbeat}.${process.pid}.tmp`;
|
|
1459
1508
|
try {
|
|
1460
|
-
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: 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`);
|
|
1509
|
+
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`);
|
|
1461
1510
|
renameSync(temporary, heartbeat);
|
|
1462
1511
|
} catch { /* the next round writes it */ }
|
|
1463
1512
|
}
|
package/board/dispatch.mjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// a crash between the two steps is answered by the next round.
|
|
19
19
|
import { monitorsRuns, failedObservation, surfaceIncident, auditEndedRuns, originOf } from './incidents.mjs';
|
|
20
20
|
import { readMachineFleet } from './machine-fleet.mjs';
|
|
21
|
+
import { boardLabel, boardName } from '@volter/teams/board-name';
|
|
21
22
|
let fleetRoot = null;
|
|
22
23
|
import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
|
|
23
24
|
import { runtimeDoor } from './runtime-door.mjs';
|
|
@@ -91,12 +92,36 @@ const lastJson = (text) => {
|
|
|
91
92
|
* What supercode reports about the fleet now: `{ ok, alive: Set<address>, online: Set<machine>, error }`. `alive` is
|
|
92
93
|
* every session that can be messaged on every machine; `online` every enrolled machine connected to the team.
|
|
93
94
|
*/
|
|
95
|
+
/**
|
|
96
|
+
* The caller of a verb the machine daemon's board door runs here (another machine's session, `sc:<machine>:<harness>:
|
|
97
|
+
* <id>`), or null when the verb was not called through the door. The door is this verb's parent: it named the caller
|
|
98
|
+
* from the calling process itself and writes `{"caller": …}` on the verb's stdin, which this process reads to its end,
|
|
99
|
+
* so nothing it starts inherits it. `SUPERCODE_BOARD_CALL` only says to read it. It is trusted as the machine's user is:
|
|
100
|
+
* any process of that user can start a verb so, as it can name a session in CLAUDE_CODE_SESSION_ID.
|
|
101
|
+
*/
|
|
102
|
+
let readBoardCaller;
|
|
103
|
+
export function boardCaller() {
|
|
104
|
+
if (readBoardCaller !== undefined) return readBoardCaller;
|
|
105
|
+
readBoardCaller = null;
|
|
106
|
+
if (process.env.SUPERCODE_BOARD_CALL !== '1') return null;
|
|
107
|
+
let said = null;
|
|
108
|
+
try { said = JSON.parse(readFileSync(0, 'utf8')); } catch { /* nothing said */ }
|
|
109
|
+
const m = /^sc:([^:\s]+):(claude-code|codex):([^:\s]+)$/.exec(said?.caller ?? '');
|
|
110
|
+
if (!m) throw new Error('this verb was started by the board door, which named no caller for it; nothing was done');
|
|
111
|
+
readBoardCaller = { address: m[0], machine: m[1], harness: m[2], session: m[3] };
|
|
112
|
+
return readBoardCaller;
|
|
113
|
+
}
|
|
114
|
+
|
|
94
115
|
/**
|
|
95
116
|
* The session a command runs in. Each harness names its own in a variable (Claude Code CLAUDE_CODE_SESSION_ID, Codex
|
|
96
117
|
* CODEX_THREAD_ID), and a pane inherits every variable the program that started its terminal server had, so a Codex
|
|
97
118
|
* pane can carry a Claude session's id too: the nearest harness among the command's ancestors says which is its own.
|
|
98
119
|
*/
|
|
99
120
|
export function callerSession() {
|
|
121
|
+
// A verb another session called on this board (the machine daemon's board door): the daemon named its caller from the
|
|
122
|
+
// calling process itself, on the caller's own machine, and passes that address in place of the harness variables.
|
|
123
|
+
const called = boardCaller();
|
|
124
|
+
if (called) return called.session;
|
|
100
125
|
const claude = process.env.CLAUDE_CODE_SESSION_ID || null;
|
|
101
126
|
const codex = process.env.CODEX_THREAD_ID || null;
|
|
102
127
|
if (!claude || !codex) return claude ?? codex;
|
|
@@ -1995,7 +2020,7 @@ export function fileBoardMail(board, report, taskId = null) {
|
|
|
1995
2020
|
}
|
|
1996
2021
|
|
|
1997
2022
|
/** The board's own address: where its notices come from and where card updates are mailed. */
|
|
1998
|
-
export const boardAddress = (label) => `sc:${localMachine()}:board:${label
|
|
2023
|
+
export const boardAddress = (label) => `sc:${localMachine()}:board:${boardName(label)}`;
|
|
1999
2024
|
|
|
2000
2025
|
/** Consecutive failed sends after which a chat-platform subscription is dropped (Hermes's notifier drops a dead chat after 12). */
|
|
2001
2026
|
const MAX_SEND_FAILURES = 12;
|
|
@@ -2291,9 +2316,6 @@ function fireHooks(board, settings, report) {
|
|
|
2291
2316
|
|
|
2292
2317
|
// ------------------------------------------------------------------ the board's mailbox
|
|
2293
2318
|
|
|
2294
|
-
const ENTRY = fileURLToPath(new URL('../bin/orchestrator.mjs', import.meta.url));
|
|
2295
|
-
const NOT_BY_MAIL = new Set(['serve', 'daemon', 'dispatch', 'init', 'adopt', 'boards', 'gc', 'repair', 'watch', 'tail', 'attach']);
|
|
2296
|
-
|
|
2297
2319
|
/** The board's mailbox on this machine: the directory supercode files mail for `address` in, or null. */
|
|
2298
2320
|
function mailboxOf(address) {
|
|
2299
2321
|
const config = process.env.SUPERCODE_HOME || (process.env.XDG_CONFIG_HOME ? join(process.env.XDG_CONFIG_HOME, 'supercode') : join(process.env.HOME ?? '', '.config', 'supercode'));
|
|
@@ -2307,9 +2329,10 @@ function mailboxOf(address) {
|
|
|
2307
2329
|
}
|
|
2308
2330
|
|
|
2309
2331
|
/**
|
|
2310
|
-
*
|
|
2311
|
-
*
|
|
2312
|
-
*
|
|
2332
|
+
* The board's mailbox takes answers to the questions the board asked (what a card waits on, read by the card's
|
|
2333
|
+
* workflow) and notices about it (a machine's event, a delivery notice about a message the board sent), each filed as
|
|
2334
|
+
* read. Any other message is mail it does not take: it is refused to its sender, in its own mailbox, and filed as read
|
|
2335
|
+
* only once that refusal is filed.
|
|
2313
2336
|
*/
|
|
2314
2337
|
function readMailbox(board, address, report) {
|
|
2315
2338
|
const box = mailboxOf(address);
|
|
@@ -2318,30 +2341,15 @@ function readMailbox(board, address, report) {
|
|
|
2318
2341
|
const path = join(box, 'new', name);
|
|
2319
2342
|
let envelope;
|
|
2320
2343
|
try { envelope = JSON.parse(readFileSync(path, 'utf8')); } catch { continue; }
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
if (envelope.kind === 'notice' || envelope.reply_via?.mode === 'none') {
|
|
2328
|
-
report.mailed.push([envelope.from, envelope.from === `sc:${localMachine()}:operator:teams-events` ? 'machine_event' : 'notice']);
|
|
2329
|
-
continue;
|
|
2330
|
-
}
|
|
2331
|
-
const argv = json(envelope.body, {})?.workflow;
|
|
2332
|
-
let answer;
|
|
2333
|
-
if (!Array.isArray(argv) || !argv.length || NOT_BY_MAIL.has(argv[0])) answer = `not applied: a mailed card update is one \`supercode workflow\` verb other than ${[...NOT_BY_MAIL].join(', ')}`;
|
|
2334
|
-
else {
|
|
2335
|
-
const clean = [];
|
|
2336
|
-
for (let i = 0; i < argv.length; i += 1) { if (argv[i] === '--root' || argv[i] === '--board') i += 1; else clean.push(String(argv[i])); }
|
|
2337
|
-
const sid = String(envelope.from ?? '').split(':')[3] ?? '';
|
|
2338
|
-
const r = spawnSync(process.execPath, [ENTRY, 'workflow', ...clean, '--root', board.root, '--board', board.slug], {
|
|
2339
|
-
encoding: 'utf8', timeout: 180_000, env: { ...process.env, CLAUDE_CODE_SESSION_ID: sid, CODEX_THREAD_ID: '' },
|
|
2340
|
-
});
|
|
2341
|
-
answer = `${clean.slice(0, 2).join(' ')}: ${(r.status === 0 ? r.stdout : r.stderr || r.stdout).trim().slice(-600) || `exit ${r.status}`}`;
|
|
2344
|
+
const notice = envelope.kind === 'notice' || envelope.reply_via?.mode === 'none';
|
|
2345
|
+
if (!envelope.in_reply_to && !notice) {
|
|
2346
|
+
try {
|
|
2347
|
+
fileMail(board, envelope.from, `${address} takes answers to its questions and notices only; this message was not taken.`, `board-refused-${envelope.id}`);
|
|
2348
|
+
} catch (error) { report.undelivered.push(['mail', envelope.from, String(error.message).slice(-200)]); continue; }
|
|
2349
|
+
report.mailed.push([envelope.from, 'refused']);
|
|
2342
2350
|
}
|
|
2343
|
-
|
|
2344
|
-
report.
|
|
2351
|
+
try { renameSync(path, join(box, 'cur', name)); } catch { continue; } // another reader took it
|
|
2352
|
+
if (!envelope.in_reply_to && notice) report.mailed.push([envelope.from, envelope.from === `sc:${localMachine()}:operator:teams-events` ? 'machine_event' : 'notice']);
|
|
2345
2353
|
}
|
|
2346
2354
|
}
|
|
2347
2355
|
|
|
@@ -2351,11 +2359,8 @@ function readMailbox(board, address, report) {
|
|
|
2351
2359
|
|
|
2352
2360
|
// ------------------------------------------------------------------ the tick
|
|
2353
2361
|
|
|
2354
|
-
/** The board's name in notices: the home's owner folder (
|
|
2355
|
-
export
|
|
2356
|
-
const home = basename(root) === 'home' ? basename(dirname(root)) : basename(root);
|
|
2357
|
-
return slug && slug !== 'default' ? `${home}/${slug}` : home;
|
|
2358
|
-
}
|
|
2362
|
+
/** The board's name in notices: the home's owner folder, and the slug (@volter/teams/board-name, the rule the daemon reads too). */
|
|
2363
|
+
export { boardLabel };
|
|
2359
2364
|
|
|
2360
2365
|
// A dispatcher told to stop (its serve's SIGTERM, SIGINT or SIGHUP) starts nothing more in the round it is in: no further
|
|
2361
2366
|
// board, card start, board message or notice. What it leaves unfiled is not a failure; the next dispatcher files it.
|
|
@@ -2565,8 +2570,6 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
2565
2570
|
for (const slug of boards) {
|
|
2566
2571
|
if (stopping) break;
|
|
2567
2572
|
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 };
|
|
2568
|
-
// the mailbox's answers in flight (not part of the report the caller prints)
|
|
2569
|
-
Object.defineProperty(report, 'answers', { value: [], enumerable: false });
|
|
2570
2573
|
const path = boardPath(root, slug);
|
|
2571
2574
|
const marker = `${path}.corrupt-seen`;
|
|
2572
2575
|
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; }
|
|
@@ -2610,7 +2613,6 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
2610
2613
|
report.error = error.message;
|
|
2611
2614
|
if (CORRUPT.test(error.message)) writeFileSync(marker, fingerprint(path));
|
|
2612
2615
|
} finally {
|
|
2613
|
-
await Promise.all(report.answers);
|
|
2614
2616
|
board?.close();
|
|
2615
2617
|
}
|
|
2616
2618
|
out.push(report);
|
package/board/engine.mjs
CHANGED
|
@@ -985,10 +985,10 @@ export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, stran
|
|
|
985
985
|
? { kind: 'effect_failed', severity: 'warning', title: `its ${kind} failed for good after ${row.attempts} attempts: ${row.last_error ?? 'no reason recorded'}`, data: { effect: row.id, kind, attempts: row.attempts }, actions: [] }
|
|
986
986
|
: { kind: 'effect_stalled', severity: 'error', title: `its ${kind} has failed ${row.attempts} times and is still tried: ${row.last_error ?? 'no reason recorded'}`, data: { effect: row.id, kind, attempts: row.attempts }, actions: [] });
|
|
987
987
|
}
|
|
988
|
-
//
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
}
|
|
988
|
+
// a publication of the card the team refuses: the home's publishing holds on it (none after it is published past it)
|
|
989
|
+
// and the machine's connector tries it again
|
|
990
|
+
const failing = publishing?.failing;
|
|
991
|
+
if (failing?.card === task.id) out.push({ kind: 'publication_refused', severity: 'error', title: `its ${failing.kind} (sequence ${failing.sequence}) is refused by the team (${failing.status} ${failing.code ?? ''}: ${failing.error}), tried ${failing.attempts} times; this board's publishing waits on it`, data: failing });
|
|
992
992
|
// mail the board filed for the card that the mailbox still has to deliver (waiting), or gave up on (expired: it stays
|
|
993
993
|
// unread there)
|
|
994
994
|
if (expiredMail?.size || waitingMail?.size) {
|
package/board/store.mjs
CHANGED
|
@@ -168,7 +168,7 @@ function noteOrigin(root) {
|
|
|
168
168
|
copies.set(root, origin === here ? null : origin);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing = null, document = null } = {}) {
|
|
171
|
+
export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing = null, document = null, declared = null } = {}) {
|
|
172
172
|
noteOrigin(root);
|
|
173
173
|
const kept = backingOf(root, slug);
|
|
174
174
|
if (!kept && !create) throw new Error(`no board ${slug} in ${root} (${boardPath(root, slug)})`);
|
|
@@ -176,7 +176,7 @@ export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing
|
|
|
176
176
|
const use = kept ?? backing ?? 'sqlite';
|
|
177
177
|
if (!BACKINGS.includes(use)) throw new Error(`a board's backing is ${BACKINGS.join(' | ')}, not ${use}`);
|
|
178
178
|
const path = backingPaths(root, slug)[use];
|
|
179
|
-
if (use === 'ztrack') return { ...openZtrackBoard(path, { create, document, init: layOut, root, slug }), root, slug, path, backing: use };
|
|
179
|
+
if (use === 'ztrack') return { ...openZtrackBoard(path, { create, document, declared, init: layOut, root, slug }), root, slug, path, backing: use };
|
|
180
180
|
if (use === 'files') return { ...openFilesBoard(path, { create, init: layOut }), root, slug, path, backing: use };
|
|
181
181
|
if (!kept) mkdirSync(dirname(path), { recursive: true });
|
|
182
182
|
const db = new DatabaseSync(path);
|
package/board/ztrack.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// region the edit touched. So a write costs what it changes, not the size of the board.
|
|
10
10
|
import { createRequire } from 'node:module';
|
|
11
11
|
import { createHash, randomBytes } from 'node:crypto';
|
|
12
|
-
import { existsSync, linkSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { existsSync, linkSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
14
14
|
import { DatabaseSync } from 'node:sqlite';
|
|
15
15
|
import { actorOf, contention, event, json, now, principalOf } from './store.mjs';
|
|
@@ -59,11 +59,18 @@ DROP TABLE IF EXISTS board_export;`;
|
|
|
59
59
|
// written to the document). board_tasks: a subtask's key on its arc's Tasks list
|
|
60
60
|
// (`<arc>:c3`) and its `source:` lines, which live only in the document.
|
|
61
61
|
|
|
62
|
-
export function openZtrackBoard(marker, { create = false, document = null, seed = null, init, root, slug }) {
|
|
62
|
+
export function openZtrackBoard(marker, { create = false, document = null, declared = null, seed = null, init, root, slug }) {
|
|
63
63
|
if (!existsSync(marker)) {
|
|
64
64
|
if (!create) throw new Error(`no ztrack board ${marker}`);
|
|
65
65
|
mkdirSync(dirname(marker), { recursive: true });
|
|
66
|
-
|
|
66
|
+
// What its creator declares besides the document (`init --commit --manager`, the archive's rotation) is written once,
|
|
67
|
+
// with it; a value left unnamed is not written.
|
|
68
|
+
const said = Object.fromEntries(Object.entries(declared ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== false));
|
|
69
|
+
// The document by its real path (its folder's symlinks followed; the document itself may not exist yet): a commit
|
|
70
|
+
// names the document relative to its checkout, as git names the checkout's files, and a path through a symlink (a
|
|
71
|
+
// short state root) names a file outside it.
|
|
72
|
+
const named = resolve(document ?? resolve(dirname(marker), 'arcs.md'));
|
|
73
|
+
atomic(marker, JSON.stringify({ format: 1, document: join(realpathSync(dirname(named)), basename(named)), ...said }) + '\n');
|
|
67
74
|
}
|
|
68
75
|
const config = JSON.parse(read(marker));
|
|
69
76
|
const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug });
|
package/hand-off.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// THE ONE PLACE A HOME'S ORCHESTRATOR IS DECIDED (D152 row 11). Every command on a home ends in an orchestrator's entry
|
|
2
|
+
// (bin/orchestrator.mjs), whichever way it came: a shell's or a pane's supercode, the native binary (its \`workflow\`,
|
|
3
|
+
// \`orchestrator start\`, \`apps\`, \`access\` and its cold write door all run \`node <entry> … --root <home>\` with the
|
|
4
|
+
// entry it finds: SUPERCODE_ORCHESTRATOR_ENTRY, else the \`supercode-orchestrator\` on PATH, the machine's own), the
|
|
5
|
+
// machine daemon's follower and board door, \`supercode --quiet workflow …\`. A home whose install pins its own
|
|
6
|
+
// orchestrator names it in its record (<home>/orchestrator.json, written by the install's keeper and by init): the entry
|
|
7
|
+
// a command reached hands the command to the one named, driving the supercode named beside it, and runs it itself only
|
|
8
|
+
// when it is that one or when the home has no record (a home made by hand, served by the machine's own). So no home is
|
|
9
|
+
// served by two orchestrators. Nothing in Rust changes (D155); an orchestrator older than this one hands nothing off, so
|
|
10
|
+
// the machine's own moves to this release with the landing.
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
13
|
+
import { join, resolve } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
/** The home a command (the whole of it, after the entry) names: --root, wherever it stands (the gateway's form,
|
|
17
|
+
* `node bin/orchestrator.mjs --root <home>`, has no verb before it); for `import`, which writes the home it names
|
|
18
|
+
* with --into, that; else the environment the orchestrator reads (SUPERCODE_ORCHESTRATOR_HOME, HERMES_HOME).
|
|
19
|
+
* (`export` names the home it reads with --from and writes none.) */
|
|
20
|
+
export function homeOfArgs(argv, env = process.env) {
|
|
21
|
+
const flag = argv[0] === 'import' ? '--into' : '--root';
|
|
22
|
+
const at = argv.findIndex((arg) => arg === flag || arg.startsWith(`${flag}=`));
|
|
23
|
+
const named = at < 0 ? env.SUPERCODE_ORCHESTRATOR_HOME ?? env.HERMES_HOME : argv[at].startsWith(`${flag}=`) ? argv[at].slice(flag.length + 1) : argv[at + 1];
|
|
24
|
+
return named ? resolve(named) : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The command (\`argv\`, after node and the entry) handed to the orchestrator the home's record names: its exit code, or
|
|
28
|
+
* null when this orchestrator serves it (it is the one named, or the home has no record). */
|
|
29
|
+
export function handOff(root, argv) {
|
|
30
|
+
if (!root) return null;
|
|
31
|
+
let record;
|
|
32
|
+
try { record = JSON.parse(readFileSync(join(root, 'orchestrator.json'), 'utf8')); } catch { return null; }
|
|
33
|
+
const own = realpathSync(fileURLToPath(new URL('./bin/orchestrator.mjs', import.meta.url)));
|
|
34
|
+
const version = (() => { try { return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version; } catch { return 'unknown'; } })();
|
|
35
|
+
let named;
|
|
36
|
+
try { named = realpathSync(record.entry); } catch { throw new Error(`${root} is served by orchestrator ${record.version ?? 'unknown'} (${record.entry ?? 'its record names none'}), which is not on this machine; this one is ${version}. Nothing was run`); }
|
|
37
|
+
const supercode = record.supercode?.entry;
|
|
38
|
+
if (!supercode || !existsSync(supercode)) throw new Error(`${root} is served by supercode ${record.supercode?.version ?? 'unknown'} (${supercode ?? 'its record names none'}), which is not on this machine. Nothing was run`);
|
|
39
|
+
if (named === own) { process.env.SUPERCODE_BIN = supercode; return null; }
|
|
40
|
+
return new Promise((done) => {
|
|
41
|
+
const child = spawn(process.execPath, [named, ...argv], { stdio: 'inherit', env: { ...process.env, SUPERCODE_BIN: supercode } });
|
|
42
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, () => { try { child.kill(signal); } catch { /* gone */ } });
|
|
43
|
+
child.on('error', (error) => { process.stderr.write(`supercode: could not run orchestrator ${record.version ?? ''} (${named}): ${error.message}\n`); done(1); });
|
|
44
|
+
child.on('close', (code, signal) => done(signal ? 1 : code ?? 1));
|
|
45
|
+
});
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.57",
|
|
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": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@volter/supercode-harness-sdk": "^0.3.60",
|
|
28
|
-
"@volter/teams": "0.2.
|
|
28
|
+
"@volter/teams": "0.2.19",
|
|
29
29
|
"@volter/ztrack": "^2.4.23",
|
|
30
30
|
"yaml": "^2.9.0"
|
|
31
31
|
},
|