@volter/supercode-orchestrator 0.5.69 → 0.5.70
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/agent-door.mjs +75 -0
- package/agents.mjs +42 -23
- package/bin/orchestrator.mjs +1 -2
- package/board/agent-declarations.mjs +3 -1
- package/board/args.mjs +36 -0
- package/board/call.mjs +45 -0
- package/board/caller-session.mjs +25 -0
- package/board/cli.mjs +250 -120
- package/board/client.mjs +87 -0
- package/board/dispatch.mjs +70 -76
- package/board/doors.mjs +60 -0
- package/board/engine.mjs +9 -5
- package/board/event-stream.mjs +7 -7
- package/board/fence.mjs +45 -0
- package/board/files.mjs +23 -3
- package/board/git-document.mjs +2 -1
- package/board/lock-holder.mjs +49 -0
- package/board/owner-files.mjs +19 -0
- package/board/published.mjs +106 -41
- package/board/store.mjs +38 -11
- package/board/workflow.mjs +109 -13
- package/board/ztrack.mjs +24 -14
- package/hand-off.mjs +2 -2
- package/index.d.ts +1 -1
- package/kanban-worker.mjs +11 -16
- package/loop.mjs +3 -2
- package/mcp/server.mjs +12 -9
- package/package.json +2 -2
- package/bin/agent-door.mjs +0 -53
package/board/client.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// `supercode workflow`'s client: it loads nothing of the board. A card verb is sent to the home's owner and its answer
|
|
2
|
+
// written here; only a run-on-the-machine verb (and help) loads the board, to run as its own act.
|
|
3
|
+
import { WorkspaceContexts } from '@volter/teams/context';
|
|
4
|
+
import { supercodeHome } from '@volter/teams/home';
|
|
5
|
+
import { askBoardOwner, BOARD_OPERATION_VERBS, boardHome, callerFacts } from '@volter/teams/board-owner';
|
|
6
|
+
import { mkdirSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { homeOf, parse } from './args.mjs';
|
|
9
|
+
import { processSession } from './caller-session.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `supercode workflow <verb>`: the CLI holds nothing. A card verb is sent to the home's board owner (`workflow serve`,
|
|
13
|
+
* @volter/teams/board-owner), which answers it in its own process with the home it holds; the answer is rendered here.
|
|
14
|
+
* The run-on-the-machine verbs (BOARD_OPERATION_VERBS: the owner itself, its init, repair and housekeeping, the
|
|
15
|
+
* dispatching switch and the verbs that hold the call open) and help are acts of their own, run here.
|
|
16
|
+
*/
|
|
17
|
+
export async function runWorkflow(argv) {
|
|
18
|
+
const args = parse(argv);
|
|
19
|
+
const [verb] = args._;
|
|
20
|
+
const help = !verb || verb === 'help' || args.help;
|
|
21
|
+
const root = help ? null : homeOf(args);
|
|
22
|
+
if (help || BOARD_OPERATION_VERBS.has(verb)) {
|
|
23
|
+
// The board has one writer. An operation that writes the boards (exclusive, below) takes the home's lock itself
|
|
24
|
+
// and so is that writer, refusing while an owner holds it; the dispatching switch is the owner's to write while one
|
|
25
|
+
// runs. The rest only read, or are the owner itself (serve), and run here as their own act.
|
|
26
|
+
const writes = !help && exclusive(verb, args);
|
|
27
|
+
let lock = null;
|
|
28
|
+
if (writes) {
|
|
29
|
+
try { lock = await takeHome(root); }
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (verb === 'dispatching') return sendToOwner(root, argv, args);
|
|
32
|
+
process.stderr.write(`supercode workflow: ${verb} writes the boards of ${root}, which have a running owner (${error.message}); stop \`supercode workflow serve\` to run it\n`);
|
|
33
|
+
return 1;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const [{ runVerb }, { closeWorld }] = await Promise.all([import('./cli.mjs'), import('../orchestration.mjs')]);
|
|
37
|
+
try { return await runVerb(argv); } finally { lock?.release(); await closeWorld(); }
|
|
38
|
+
}
|
|
39
|
+
return sendToOwner(root, argv, args);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The operations that write the boards: run only as the home's sole writer, holding its lock and a fencing epoch.
|
|
43
|
+
const EXCLUSIVE = new Set(['init', 'migrate', 'gc', 'repair']);
|
|
44
|
+
const BOARDS_WRITES = new Set(['create', 'new', 'rm', 'remove', 'delete', 'rename', 'set-default-workdir', 'import']);
|
|
45
|
+
function exclusive(verb, args) {
|
|
46
|
+
if (EXCLUSIVE.has(verb)) return true;
|
|
47
|
+
if (verb === 'dispatch') return !args['dry-run'];
|
|
48
|
+
if (verb === 'dispatching') return ['on', 'off'].includes(args._[1]);
|
|
49
|
+
if (verb === 'boards') return BOARDS_WRITES.has(args._[1] ?? 'list');
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Hold the home's lock and take a fencing epoch, as its owner does; rejects when an owner holds the lock. */
|
|
54
|
+
async function takeHome(root) {
|
|
55
|
+
mkdirSync(root, { recursive: true });
|
|
56
|
+
const [{ holdLock }, { takeEpoch }, { listBoards, openBoard }] = await Promise.all([import('./lock-holder.mjs'), import('./fence.mjs'), import('./store.mjs')]);
|
|
57
|
+
const lock = await holdLock(join(boardHome(root), 'board.lock'), { onLost: (error) => { process.stderr.write(`supercode workflow: ${error.message}; stopping\n`); process.exit(1); } });
|
|
58
|
+
takeEpoch(root, () => listBoards(root).map((slug) => openBoard(root, slug)));
|
|
59
|
+
return lock;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function sendToOwner(root, argv, args) {
|
|
63
|
+
const sid = processSession();
|
|
64
|
+
const harness = sid ? (sid === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code') : null;
|
|
65
|
+
// a file named `-` is the caller's standard input, read here and sent with the verb
|
|
66
|
+
const stdin = [args.file, args['body-file']].includes('-') ? readFileSync(0, 'utf8') : null;
|
|
67
|
+
// The caller's own selections travel with the verb, so the owner never answers it with its own: its board (the
|
|
68
|
+
// board it names, else the one its environment names) and its Teams context (named, else the one selected here).
|
|
69
|
+
const board = args.board ?? process.env.HERMES_KANBAN_BOARD ?? null;
|
|
70
|
+
const facts = { ...callerFacts(), SUPERCODE_CONTEXT: process.env.SUPERCODE_CONTEXT || new WorkspaceContexts(supercodeHome()).read().current || 'local' };
|
|
71
|
+
const answer = await askBoardOwner(root, { argv: withoutHome(argv), board, caller: null, session: sid ? { id: sid, harness } : null, facts, cwd: process.cwd(), stdin });
|
|
72
|
+
if (answer.stdout) process.stdout.write(answer.stdout);
|
|
73
|
+
if (answer.stderr) process.stderr.write(answer.stderr);
|
|
74
|
+
return answer.code ?? 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `argv` without the home and board it names: the owner answers for its own home, and the board travels apart. */
|
|
78
|
+
export function withoutHome(argv) {
|
|
79
|
+
const out = [];
|
|
80
|
+
for (let i = 0; i < argv.length; i++) {
|
|
81
|
+
const arg = argv[i];
|
|
82
|
+
if (/^--(root|board)=/.test(arg)) continue;
|
|
83
|
+
if (arg === '--root' || arg === '--board') { i++; continue; }
|
|
84
|
+
out.push(arg);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
package/board/dispatch.mjs
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
// `workflow dispatch`) is harmless: every act of a round is a compare-and-set claim, a transaction that re-reads what it
|
|
17
17
|
// acts on and does nothing when another round already acted (see `tick`). Every side effect follows a committed claim, so
|
|
18
18
|
// a crash between the two steps is answered by the next round.
|
|
19
|
+
import { machineCall } from './doors.mjs';
|
|
20
|
+
import { processSession } from './caller-session.mjs';
|
|
21
|
+
import { callerOf, current, isCallersCall, ownCall } from './call.mjs';
|
|
19
22
|
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
20
23
|
import { monitorsRuns, failedObservation, surfaceIncident, auditEndedRuns, originOf } from './incidents.mjs';
|
|
21
24
|
import { readMachineFleet } from './machine-fleet.mjs';
|
|
@@ -57,13 +60,17 @@ export function normalMachine(name) {
|
|
|
57
60
|
|
|
58
61
|
/** This machine's name as supercode's addresses spell it: its Teams enrollment, else its host name. */
|
|
59
62
|
let localMachineName;
|
|
60
|
-
|
|
63
|
+
/** Learn it once per process, from the machine's own daemon (in-process: doors.mjs); a host with no daemon is its OS name. */
|
|
64
|
+
export async function knowLocalMachine() {
|
|
61
65
|
if (localMachineName) return localMachineName;
|
|
62
|
-
try {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
try { const machine = await machineCall(null, 'harness.v1.machine.describe', {}, { timeoutMs: 15_000 }); localMachineName = normalMachine(machine.name ?? machine.hostname); }
|
|
67
|
+
catch { localMachineName = normalMachine(hostname()); }
|
|
68
|
+
return localMachineName;
|
|
69
|
+
}
|
|
70
|
+
/** This machine's name, as the process learned it at its start (knowLocalMachine). */
|
|
71
|
+
export function localMachine() {
|
|
72
|
+
if (!localMachineName) throw new Error('this machine\'s name is read at the start of a verb or the owner (knowLocalMachine), not on demand');
|
|
73
|
+
return localMachineName;
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
/** The supercode command the dispatcher drives. */
|
|
@@ -96,24 +103,10 @@ const lastJson = (text) => {
|
|
|
96
103
|
* every session that can be messaged on every machine; `online` every enrolled machine connected to the team.
|
|
97
104
|
*/
|
|
98
105
|
/**
|
|
99
|
-
* The
|
|
100
|
-
*
|
|
101
|
-
* from the calling process itself and writes `{"caller": …}` on the verb's stdin, which this process reads to its end,
|
|
102
|
-
* so nothing it starts inherits it. `SUPERCODE_BOARD_CALL` only says to read it. It is trusted as the machine's user is:
|
|
103
|
-
* any process of that user can start a verb so, as it can name a session in CLAUDE_CODE_SESSION_ID.
|
|
106
|
+
* The session a verb answers for when another session called it (the board's owner answering the caller the CLI or the
|
|
107
|
+
* machine daemon's board door named, call.mjs), or null for the process's own call.
|
|
104
108
|
*/
|
|
105
|
-
|
|
106
|
-
export function boardCaller() {
|
|
107
|
-
if (readBoardCaller !== undefined) return readBoardCaller;
|
|
108
|
-
readBoardCaller = null;
|
|
109
|
-
if (process.env.SUPERCODE_BOARD_CALL !== '1') return null;
|
|
110
|
-
let said = null;
|
|
111
|
-
try { said = JSON.parse(readFileSync(0, 'utf8')); } catch { /* nothing said */ }
|
|
112
|
-
const m = /^sc:([^:\s]+):(claude-code|codex):([^:\s]+)$/.exec(said?.caller ?? '');
|
|
113
|
-
if (!m) throw new Error('this verb was started by the board door, which named no caller for it; nothing was done');
|
|
114
|
-
readBoardCaller = { address: m[0], machine: m[1], harness: m[2], session: m[3] };
|
|
115
|
-
return readBoardCaller;
|
|
116
|
-
}
|
|
109
|
+
export const boardCaller = () => callerOf();
|
|
117
110
|
|
|
118
111
|
/**
|
|
119
112
|
* The session a command runs in. Each harness names its own in a variable (Claude Code CLAUDE_CODE_SESSION_ID, Codex
|
|
@@ -121,27 +114,10 @@ export function boardCaller() {
|
|
|
121
114
|
* pane can carry a Claude session's id too: the nearest harness among the command's ancestors says which is its own.
|
|
122
115
|
*/
|
|
123
116
|
export function callerSession() {
|
|
124
|
-
// A verb
|
|
125
|
-
// calling process
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const claude = process.env.CLAUDE_CODE_SESSION_ID || null;
|
|
129
|
-
const codex = process.env.CODEX_THREAD_ID || null;
|
|
130
|
-
if (!claude || !codex) return claude ?? codex;
|
|
131
|
-
const table = spawnSync('ps', ['-A', '-o', 'pid=,ppid=,comm='], { encoding: 'utf8' });
|
|
132
|
-
const processes = new Map();
|
|
133
|
-
for (const row of String(table.stdout ?? '').split('\n')) {
|
|
134
|
-
const m = row.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
|
135
|
-
if (m) processes.set(Number(m[1]), { parent: Number(m[2]), name: basename(m[3]) });
|
|
136
|
-
}
|
|
137
|
-
for (let pid = process.ppid, depth = 0; pid > 1 && depth < 64; depth++) {
|
|
138
|
-
const p = processes.get(pid);
|
|
139
|
-
if (!p) break;
|
|
140
|
-
if (p.name === 'codex') return codex;
|
|
141
|
-
if (/^claude(\.exe)?$/.test(p.name)) return claude;
|
|
142
|
-
pid = p.parent;
|
|
143
|
-
}
|
|
144
|
-
return claude;
|
|
117
|
+
// A caller's verb in the board's owner: its own side named it (the CLI from its process, the machine daemon from the
|
|
118
|
+
// calling process on its machine). Only the process's own call reads the harness variables.
|
|
119
|
+
if (isCallersCall()) return boardCaller()?.session ?? null;
|
|
120
|
+
return processSession();
|
|
145
121
|
}
|
|
146
122
|
|
|
147
123
|
/** The fleet as the home's machines answer for themselves (machine-fleet.mjs): this machine, and every machine an open
|
|
@@ -771,7 +747,7 @@ export function launchRun(board, entry, id, lane, fleet, settings) {
|
|
|
771
747
|
try {
|
|
772
748
|
// Teams is never on a launch's path: the card's agent is its identity minted on this host under its retained intent,
|
|
773
749
|
// and its sessions are joined to it off this path, after the launch. No Teams answer starts, stops or holds a card.
|
|
774
|
-
const context=
|
|
750
|
+
const context=current().facts.SUPERCODE_CONTEXT,team=agentScope(context);
|
|
775
751
|
const agent=localCardAgent({root:board.root,org:team.org,server:team.server,profiles:entry.profiles,
|
|
776
752
|
profile:lane.name,card:id,role:spec.role??slot,sponsor:task.sponsor??null});
|
|
777
753
|
// G3: a run of an agent over its budget does not start; the reason waits on the card until the window passes
|
|
@@ -1368,7 +1344,7 @@ export function reconcileCallerLaunch(board, sid) {
|
|
|
1368
1344
|
JOIN tasks t ON t.current_run_id=r.id WHERE r.ended_at IS NULL
|
|
1369
1345
|
AND json_extract(r.metadata, '$.supercode.launch_pending') = 1`).all());
|
|
1370
1346
|
const machine = localMachine();
|
|
1371
|
-
const harness = sid === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code';
|
|
1347
|
+
const harness = boardCaller()?.harness ?? (sid === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code');
|
|
1372
1348
|
const candidates = rows.filter((row) => {
|
|
1373
1349
|
const s = json(row.metadata, {})?.supercode;
|
|
1374
1350
|
return s?.machine === machine && s.harness === harness && (!s.session_id || s.session_id === sid);
|
|
@@ -1702,20 +1678,22 @@ function launchHeadless(board, task, runId, lane, settings, { prompt = null, ski
|
|
|
1702
1678
|
rotateLog(logFile, settings.logRotateBytes, settings.logBackups);
|
|
1703
1679
|
const out = openSync(logFile, 'a');
|
|
1704
1680
|
const exitFile = join(logDir, `${task.id}.r${runId}.exit`);
|
|
1681
|
+
// the launch's secret, given to this worker alone: the run keeps its hash, and the worker's session record must name it
|
|
1682
|
+
const launchSecret = randomUUID() + randomUUID();
|
|
1705
1683
|
// the worker runs under a shell that records its exit code, so a later tick can tell a clean exit from a crash
|
|
1706
1684
|
const child = spawnChild('/bin/sh', ['-c', '"$0" "$@"; echo $? > "$SUPERCODE_BOARD_EXIT_FILE"', process.execPath, WORKER, ...args], {
|
|
1707
1685
|
cwd: workspace, detached: true, stdio: ['ignore', out, out],
|
|
1708
1686
|
env: {
|
|
1709
1687
|
SUPERCODE_BOARD_EXIT_FILE: exitFile,
|
|
1710
1688
|
...(task.goal_mode ? { HERMES_KANBAN_GOAL_MODE: '1', ...(task.goal_max_turns ? { HERMES_KANBAN_GOAL_MAX_TURNS: String(task.goal_max_turns) } : {}) } : {}),
|
|
1711
|
-
...process.env, ...meta.agent_env, HERMES_HOME: meta.agent_env?.HERMES_HOME??lane.dir, HERMES_PROFILE: lane.name, HERMES_KANBAN_TASK: task.id, HERMES_KANBAN_WORKSPACE: workspace,
|
|
1712
|
-
HERMES_KANBAN_RUN_ID: String(runId), HERMES_KANBAN_DB: board.path, HERMES_KANBAN_BOARD: board.slug, HERMES_SESSION_SOURCE: 'kanban',
|
|
1689
|
+
...process.env, ...meta.agent_env, SUPERCODE_ORCHESTRATOR_HOME: board.root, HERMES_HOME: meta.agent_env?.HERMES_HOME??lane.dir, HERMES_PROFILE: lane.name, HERMES_KANBAN_TASK: task.id, HERMES_KANBAN_WORKSPACE: workspace,
|
|
1690
|
+
HERMES_KANBAN_RUN_ID: String(runId), SUPERCODE_RUN_LAUNCH: launchSecret, HERMES_KANBAN_DB: board.path, HERMES_KANBAN_BOARD: board.slug, HERMES_SESSION_SOURCE: 'kanban',
|
|
1713
1691
|
HERMES_KANBAN_CLAIM_LOCK: claimer(), ...(prompt ? { SUPERCODE_BOARD_PROMPT: prompt } : {}), ...(task.tenant ? { HERMES_TENANT: task.tenant } : {}),
|
|
1714
1692
|
...(task.branch_name ? { HERMES_KANBAN_BRANCH: task.branch_name } : {}),
|
|
1715
1693
|
},
|
|
1716
1694
|
});
|
|
1717
1695
|
child.unref();
|
|
1718
|
-
const session = { surface: 'headless', pid: child.pid, machine: localMachine(), mode: 'fresh', launched_at: now(), exit_file: exitFile, ...meta };
|
|
1696
|
+
const session = { surface: 'headless', pid: child.pid, machine: localMachine(), mode: 'fresh', launched_at: now(), exit_file: exitFile, launch_secret: createHash('sha256').update(launchSecret).digest('hex'), ...meta };
|
|
1719
1697
|
tx(board, (db) => {
|
|
1720
1698
|
db.prepare('UPDATE tasks SET worker_pid = ? WHERE id = ?').run(child.pid, task.id);
|
|
1721
1699
|
db.prepare('UPDATE task_runs SET worker_pid = ? WHERE id = ?').run(child.pid, runId);
|
|
@@ -1881,7 +1859,7 @@ function adoptSession(board, id, who, { request = null, open = request, progress
|
|
|
1881
1859
|
const entry0 = workflowFor(board.root), card0 = view(board, (db) => getTask(db, id));
|
|
1882
1860
|
const agentRole = view(board, (db) => adoptedRole(db, board, entry0, card0, beforeRun)) ?? 'implementer';
|
|
1883
1861
|
const laneName = view(board, (db) => evaluate(entry0.workflow.roles?.[agentRole] ?? 'card.assignee', scopeOf(db, board, card0, { params: entry0.params }))) ?? assignee ?? card0?.assignee;
|
|
1884
|
-
const lane = laneName ? lanes?.[laneName] : null, context =
|
|
1862
|
+
const lane = laneName ? lanes?.[laneName] : null, context = current().facts.SUPERCODE_CONTEXT, team = agentScope(context);
|
|
1885
1863
|
// The request is claimed before this adoption's first write outside the board (the agent store, below), in
|
|
1886
1864
|
// a transaction that finds it still open and the card's run the one read: a request answered or claimed meanwhile, or
|
|
1887
1865
|
// a run that changed, ends it here with nothing written anywhere, and no other round acts on a request claimed here.
|
|
@@ -2024,7 +2002,7 @@ function fileMail(board, to, body, key, { ask = false, wake = true, subject } =
|
|
|
2024
2002
|
*/
|
|
2025
2003
|
export async function alertManagers(root, slug, text, key) {
|
|
2026
2004
|
let managers = [];
|
|
2027
|
-
try { const entry = await loadWorkflow(root
|
|
2005
|
+
try { const entry = await loadWorkflow(root); managers = Array.isArray(entry.params.managers) ? entry.params.managers.map(String) : []; } catch { /* a workflow that cannot load names nobody */ }
|
|
2028
2006
|
const label = boardLabel(root, slug);
|
|
2029
2007
|
let told = 0;
|
|
2030
2008
|
for (const to of managers) {
|
|
@@ -2243,7 +2221,7 @@ function notify(board, fleet, report, entry) {
|
|
|
2243
2221
|
export async function notifyChats(root, adapters, log = () => {}, wake = null) {
|
|
2244
2222
|
const sent = [];
|
|
2245
2223
|
// what a chat is told is the workflow's (notify.chat)
|
|
2246
|
-
const entry = await loadWorkflow(root
|
|
2224
|
+
const entry = await loadWorkflow(root);
|
|
2247
2225
|
const kinds = heardBy(entry, 'chat');
|
|
2248
2226
|
if (!kinds.length) return sent;
|
|
2249
2227
|
for (const slug of listBoards(root)) {
|
|
@@ -2422,7 +2400,7 @@ const CORRUPT = /malformed|not a database|corrupt|disk image/i;
|
|
|
2422
2400
|
/** What a tick would start on each board of the home, planned through the workflow's `dispatch`, writing nothing. */
|
|
2423
2401
|
export async function plan({ root, lanes, settings, slugs = null, params = {} }) {
|
|
2424
2402
|
fleetRoot = root;
|
|
2425
|
-
const loaded = await loadWorkflow(root
|
|
2403
|
+
const loaded = await loadWorkflow(root);
|
|
2426
2404
|
const entry = { ...loaded, params: { ...loaded.params, ...params } };
|
|
2427
2405
|
const fleet = Object.values(lanes).some((l) => l.surface === 'pane') ? observeFleet() : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
|
|
2428
2406
|
const out = [];
|
|
@@ -2536,31 +2514,24 @@ function capMax(cap, group, entry, lanes) {
|
|
|
2536
2514
|
catch { return null; }
|
|
2537
2515
|
}
|
|
2538
2516
|
|
|
2539
|
-
const ORCHESTRATOR_ENTRY = fileURLToPath(new URL('../bin/orchestrator.mjs', import.meta.url));
|
|
2540
2517
|
const deliveries = new Map();
|
|
2541
2518
|
/**
|
|
2542
2519
|
* Start the home's delivery pass (`workflow deliver`) in a process of its own, unless one is already running: the
|
|
2543
2520
|
* dispatcher's loop calls this after each round and never waits for it. `log(level, line)` hears what it failed at.
|
|
2544
2521
|
* Answers whether one was started.
|
|
2545
2522
|
*/
|
|
2546
|
-
export function startDelivery(root, { log = () => {} } = {}) {
|
|
2523
|
+
export function startDelivery(root, { slugs = null, log = () => {} } = {}) {
|
|
2524
|
+
// the controller's delivery pass, run here by the owner (no process of its own), never two at once for a home; the
|
|
2525
|
+
// round never waits for it
|
|
2547
2526
|
if (deliveries.has(root)) return false;
|
|
2548
|
-
const
|
|
2549
|
-
deliveries.set(root, child);
|
|
2550
|
-
let out = '', err = '';
|
|
2551
|
-
child.stdout.setEncoding('utf8').on('data', (c) => { out += c; });
|
|
2552
|
-
child.stderr.setEncoding('utf8').on('data', (c) => { err = (err + c).slice(-2000); });
|
|
2553
|
-
const done = () => {
|
|
2554
|
-
deliveries.delete(root);
|
|
2555
|
-
let reports = null; try { reports = JSON.parse(out); } catch { log('warn', `delivery pass failed: ${(err || out).trim().slice(-300) || 'no answer'}`); return; }
|
|
2527
|
+
const pass = ownCall(() => deliver({ root, slugs, controller: true })).then((reports) => {
|
|
2556
2528
|
for (const r of reports) {
|
|
2557
2529
|
if (r.error) log('warn', `delivery on board ${r.board} failed: ${r.error}`);
|
|
2558
2530
|
for (const [task, to, why] of r.undelivered ?? []) log('warn', `board ${r.board}: a delivery for ${task} to ${to} waits: ${why}`);
|
|
2559
2531
|
log('info', `delivery on board ${r.board}: ${r.effects_waiting ?? 0} effect(s) waiting, ${r.effects_failed ?? 0} failed for good; took ${Object.entries(r.ms ?? {}).map(([k, v]) => `${k} ${(v / 1000).toFixed(1)}s`).join(', ')}`);
|
|
2560
2532
|
}
|
|
2561
|
-
};
|
|
2562
|
-
|
|
2563
|
-
child.on('close', done);
|
|
2533
|
+
}, (error) => log('warn', `delivery pass failed: ${error?.message ?? error}`)).finally(() => deliveries.delete(root));
|
|
2534
|
+
deliveries.set(root, pass);
|
|
2564
2535
|
return true;
|
|
2565
2536
|
}
|
|
2566
2537
|
|
|
@@ -2570,12 +2541,31 @@ export function startDelivery(root, { log = () => {} } = {}) {
|
|
|
2570
2541
|
* its own process (`workflow deliver`, started by serve after a round, one at a time), never inside a dispatch round,
|
|
2571
2542
|
* so no recipient, door or Teams answer holds a round. Answers one report per board.
|
|
2572
2543
|
*/
|
|
2573
|
-
|
|
2544
|
+
/**
|
|
2545
|
+
* Whether the board dispatches: board state, kept in the board's own store (`supercode_board_state`, key `dispatching`)
|
|
2546
|
+
* with who set it, when and why. A board that never recorded one dispatches, as every board did before the switch.
|
|
2547
|
+
*/
|
|
2548
|
+
export function dispatchingOf(root, slug) {
|
|
2549
|
+
let board;
|
|
2550
|
+
try {
|
|
2551
|
+
board = openBoard(root, slug);
|
|
2552
|
+
const row = board.db.prepare("SELECT value FROM supercode_board_state WHERE key = 'dispatching'").get();
|
|
2553
|
+
const value = json(row?.value, null);
|
|
2554
|
+
return value && typeof value.on === 'boolean' ? value : { on: true };
|
|
2555
|
+
} catch { return { on: true }; } finally { board?.close(); }
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
export async function deliver({ root, slugs = null, controller = false }) {
|
|
2574
2559
|
fleetRoot = root;
|
|
2575
|
-
const entry = await loadWorkflow(root
|
|
2560
|
+
const entry = await loadWorkflow(root);
|
|
2561
|
+
// the owner's controller delivers only for boards whose dispatching is on, read as each board is reached (a pass
|
|
2562
|
+
// already under way when a board is switched off leaves it); `workflow deliver`, an operator's act, delivers as asked
|
|
2563
|
+
const boards = (slugs ?? listBoards(root)).filter((slug) => !controller || dispatchingOf(root, slug).on);
|
|
2564
|
+
if (!boards.length) return [];
|
|
2576
2565
|
const fleet = observeFleet();
|
|
2577
2566
|
const out = [];
|
|
2578
|
-
for (const slug of
|
|
2567
|
+
for (const slug of boards) {
|
|
2568
|
+
if (controller && !dispatchingOf(root, slug).on) continue;
|
|
2579
2569
|
const report = { board: slug, ms: {}, mailed: [], queued: [], failed: [], notified: [], undelivered: [], fleet: fleet.ok ? 'read' : fleet.error };
|
|
2580
2570
|
let board;
|
|
2581
2571
|
try {
|
|
@@ -2585,10 +2575,13 @@ export async function deliver({ root, slugs = null }) {
|
|
|
2585
2575
|
let mark = Date.now();
|
|
2586
2576
|
const phase = (name) => { const t = Date.now(); report.ms[name] = t - mark; mark = t; };
|
|
2587
2577
|
// board mail and notices are filed in their receivers' mailboxes (local writes); the mailbox delivers them
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2578
|
+
// a controller's pass reads the board's switch before each phase and each effect: switched off meanwhile, what had
|
|
2579
|
+
// not begun is left (an operator's `workflow deliver` delivers as asked)
|
|
2580
|
+
const on = () => !controller || dispatchingOf(root, slug).on;
|
|
2581
|
+
if (on()) { fileBoardMail(board, report); phase('mail'); }
|
|
2582
|
+
if (on()) { report.effects = await runEffects(board, [], { pending: true, eligible: controller ? on : null }); phase('effects'); }
|
|
2583
|
+
if (on()) { notify(board, fleet, report, entry); phase('notify'); }
|
|
2584
|
+
if (on()) { synchronizeAgents(board, report); phase('agents'); }
|
|
2592
2585
|
report.effects_failed = Number(board.db.prepare('SELECT COUNT(*) AS n FROM supercode_effects WHERE failed_at IS NOT NULL').get().n);
|
|
2593
2586
|
report.effects_waiting = Number(board.db.prepare('SELECT COUNT(*) AS n FROM supercode_effects WHERE failed_at IS NULL').get().n);
|
|
2594
2587
|
} catch (error) { report.error = error.message; }
|
|
@@ -2621,15 +2614,16 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
2621
2614
|
discoveries.clear();
|
|
2622
2615
|
// a verb's flags (dispatch --max, --failure-limit) are params of this tick, read by the workflow as its own
|
|
2623
2616
|
const deployment = deploymentReceipt ? workflowRevision(root) : null;
|
|
2624
|
-
const loaded = await loadWorkflow(root
|
|
2617
|
+
const loaded = await loadWorkflow(root);
|
|
2625
2618
|
const entry = { ...loaded, params: { ...loaded.params, ...params } };
|
|
2626
2619
|
const needFleet = Object.values(lanes).some((l) => l.surface === 'pane');
|
|
2627
2620
|
const fleetAt = Date.now();
|
|
2628
2621
|
const fleet = needFleet ? observeFleet() : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
|
|
2629
2622
|
const fleetMs = Date.now() - fleetAt;
|
|
2630
2623
|
const boards = slugs ?? listBoards(root);
|
|
2631
|
-
// what every board of the home holds, for caps whose scope is the whole home (kanban.max_in_progress counts so)
|
|
2632
|
-
|
|
2624
|
+
// what every board of the home holds, for caps whose scope is the whole home (kanban.max_in_progress counts so): every
|
|
2625
|
+
// board, including one whose dispatching is off, whose live workers still hold their places
|
|
2626
|
+
const held = dispatch ? homeHoldings(root, listBoards(root), lanes, fleet, entry) : [];
|
|
2633
2627
|
const out = [];
|
|
2634
2628
|
for (const slug of boards) {
|
|
2635
2629
|
if (stopping) break;
|
package/board/doors.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The board owner's doors to its machine and the others (ADR 0010: sessions, panes and workspaces are the machine's,
|
|
2
|
+
// asked through its own daemon). The owner is resident and async: it holds one admitted channel per machine for as
|
|
3
|
+
// long as the channel lives and calls each door on it in-process, with the SDK clients the doors speak. No helper
|
|
4
|
+
// process is started for a call. The local machine is this OS user's daemon on its socket; another machine is reached
|
|
5
|
+
// through the Teams context's channel to it (@volter/teams/machine-door).
|
|
6
|
+
import { openMachine } from '@volter/teams/machine-door';
|
|
7
|
+
import { StreamRpc, harnessTransport } from '@volter/teams/client';
|
|
8
|
+
import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
|
|
9
|
+
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
10
|
+
|
|
11
|
+
const held = new Map(); // machine name, '' for this one → Promise<{ channel, node, harness }>
|
|
12
|
+
|
|
13
|
+
async function opened(machine) {
|
|
14
|
+
const key = machine ?? '';
|
|
15
|
+
const known = held.get(key);
|
|
16
|
+
if (known) {
|
|
17
|
+
const door = await known.catch(() => null);
|
|
18
|
+
if (door && !door.channel.closed) return door;
|
|
19
|
+
held.delete(key);
|
|
20
|
+
}
|
|
21
|
+
const opening = (async () => {
|
|
22
|
+
const channel = await openMachine({ machine: machine || null });
|
|
23
|
+
const door = { channel, node: null, harness: null };
|
|
24
|
+
channel.on('close', () => { if (held.get(key) === opening) held.delete(key); });
|
|
25
|
+
return door;
|
|
26
|
+
})();
|
|
27
|
+
held.set(key, opening);
|
|
28
|
+
try { return await opening; } catch (error) { held.delete(key); throw error; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One `harness.v1.*` request to a machine's daemon: `machine` null for this machine. A session's records are the
|
|
33
|
+
* harness's own service, spoken by its SDK over the machine's harness stream; every other door is the daemon's node
|
|
34
|
+
* service. Throws the door's refusal (with its code).
|
|
35
|
+
*/
|
|
36
|
+
export async function machineCall(machine, method, params = {}, { timeoutMs = 60_000 } = {}) {
|
|
37
|
+
if (!method.startsWith('harness.v1.')) throw new Error('a machine door is a harness.v1 method');
|
|
38
|
+
const started = performance.now();
|
|
39
|
+
try {
|
|
40
|
+
const door = await opened(machine);
|
|
41
|
+
if (method.startsWith('harness.v1.sessions.')) {
|
|
42
|
+
door.harness ??= (async () => { const client = new SupercodeHarnessClient({ transport: harnessTransport(door.channel) }); await client.start?.(); return client; })();
|
|
43
|
+
return await (await door.harness).request(method, params, { timeoutMs });
|
|
44
|
+
}
|
|
45
|
+
door.node ??= door.channel.open('node').then((stream) => new StreamRpc(stream));
|
|
46
|
+
return await (await door.node).call(method, params, { timeoutMs });
|
|
47
|
+
} finally {
|
|
48
|
+
noteStep(`door ${method}${machine ? ' (another machine)' : ''}`, performance.now() - started);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Let go of every held channel (the owner stopping). */
|
|
53
|
+
export async function closeDoors() {
|
|
54
|
+
for (const [key, opening] of [...held]) {
|
|
55
|
+
held.delete(key);
|
|
56
|
+
const door = await opening.catch(() => null);
|
|
57
|
+
try { await (await door?.harness)?.close?.(); } catch { /* closing anyway */ }
|
|
58
|
+
door?.channel.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
package/board/engine.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Each function takes an open board (`store.mjs`) and runs in one write transaction. Nothing here launches,
|
|
9
9
|
// observes or sends: the dispatcher (`dispatch.mjs`) does, and records what it did through these.
|
|
10
|
+
import { current } from './call.mjs';
|
|
10
11
|
import { spawn, spawnSync } from 'node:child_process';
|
|
11
12
|
import { existsSync, rmSync } from 'node:fs';
|
|
12
13
|
import { join, resolve } from 'node:path';
|
|
@@ -274,7 +275,7 @@ export function createCard(board, spec) {
|
|
|
274
275
|
if(!arc) {
|
|
275
276
|
const creator=/^sc:[^:]+:(claude-code|codex|gemini|grok|pi|hermes|opencode|supercode):(.+)$/.exec(actorOf()??'');
|
|
276
277
|
const effect={kind:'declare_card_agent',id,role:'implementer',profile:spec.assignee??null,
|
|
277
|
-
context:
|
|
278
|
+
context:current().facts.SUPERCODE_CONTEXT??null,starter_session:creator?`${creator[1]}:${creator[2]}`:null};
|
|
278
279
|
const inserted=run(db,'INSERT INTO supercode_effects(effect) VALUES(?)',JSON.stringify(effect));
|
|
279
280
|
declaration={...effect,durableId:Number(inserted.lastInsertRowid)};
|
|
280
281
|
}
|
|
@@ -397,9 +398,9 @@ function cleanupWorkspace(board, id) {
|
|
|
397
398
|
* `platform` for the dispatcher. Answers `{ from, to, moved }`; throws the workflow's refusal.
|
|
398
399
|
*/
|
|
399
400
|
// who is sending, when a caller does not say: the CLI answers with the caller's role on the card (setCaller)
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
401
|
+
/** How the engine learns who a caller is on a card: `fn(db, cardId)` answers the caller's roles there (the call's own). */
|
|
402
|
+
export function setCaller(fn) { current().roles = fn; }
|
|
403
|
+
const callerRoles = (db, cardId) => current().roles(db, cardId);
|
|
403
404
|
|
|
404
405
|
export async function act(board, id, name, { roles = null, payload = {}, extra = {} } = {}) {
|
|
405
406
|
const entry = await loadWorkflow(board.root);
|
|
@@ -505,7 +506,7 @@ export function queueEffects(board, effects = []) {
|
|
|
505
506
|
}
|
|
506
507
|
|
|
507
508
|
/** The I/O a committed move asked for: the move's own `effects`, and with `pending` every recorded effect that is due. */
|
|
508
|
-
export async function runEffects(board, effects = [], { pending: due = false } = {}) {
|
|
509
|
+
export async function runEffects(board, effects = [], { pending: due = false, eligible = null } = {}) {
|
|
509
510
|
const requested = new Set(effects.map((e) => e.durableId).filter(Boolean));
|
|
510
511
|
const t = now();
|
|
511
512
|
const rows = board.db.prepare('SELECT id, effect, attempts, next_at FROM supercode_effects WHERE failed_at IS NULL ORDER BY id').all()
|
|
@@ -517,6 +518,9 @@ export async function runEffects(board, effects = [], { pending: due = false } =
|
|
|
517
518
|
// what this call did, for its caller's report: effect ids done, tried and kept, and skipped as another's claim
|
|
518
519
|
const did = { done: [], kept: [], claimed_elsewhere: [] };
|
|
519
520
|
for (const e of combined) {
|
|
521
|
+
// a controller's pass asks before each claim whether its board is still its to work (dispatching on): one switched
|
|
522
|
+
// off meanwhile has nothing more claimed by it
|
|
523
|
+
if (eligible && !eligible()) break;
|
|
520
524
|
// the claim: taken only while the row is due and not failed; another pass holding it answers no change
|
|
521
525
|
if (e.durableId && !tx(board, (db) => db.prepare('UPDATE supercode_effects SET next_at = ? WHERE id = ? AND failed_at IS NULL AND next_at <= ?').run(now() + EFFECT_CLAIM, e.durableId, now()).changes)) { did.claimed_elsewhere.push(e.durableId); continue; }
|
|
522
526
|
let done, error = null;
|
package/board/event-stream.mjs
CHANGED
|
@@ -4,7 +4,6 @@ import { watch, existsSync, readFileSync, realpathSync, statSync } from 'node:fs
|
|
|
4
4
|
import { dirname, basename, join, resolve } from 'node:path';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
import { listBoards, openBoard, backingPaths } from './store.mjs';
|
|
7
|
-
import { recordCardPublications } from './published.mjs';
|
|
8
7
|
|
|
9
8
|
const identity = root => createHash('sha256').update(realpathSync(root)).digest('hex');
|
|
10
9
|
const cursorOf = (home, boards) => Buffer.from(JSON.stringify({version:1,home,boards})).toString('base64url');
|
|
@@ -15,8 +14,8 @@ const RESETTLE_MS = 30_000;
|
|
|
15
14
|
export async function* followBoardPublications({root,after,signal}) {
|
|
16
15
|
const home=identity(root), positions={}, watchers=new Map();
|
|
17
16
|
let dirty=true, wake=null, failure=null, inventory=null;
|
|
18
|
-
// Each store file as this follower's own pass left it, stamped right after that board's close. Its pass
|
|
19
|
-
//
|
|
17
|
+
// Each store file as this follower's own pass left it, stamped right after that board's close. Its pass only reads,
|
|
18
|
+
// but closing the board checkpoints the WAL and raises a watch event of its own; an event
|
|
20
19
|
// for a store file that is still as its pass left it is that echo, not a change, and does not start another pass.
|
|
21
20
|
// Without this the follower woke itself every quarter second and burned a core with nothing to publish
|
|
22
21
|
// (t_0328d520). Stamped per board, a write to one board while later boards' passes run is never taken for an echo.
|
|
@@ -67,11 +66,12 @@ export async function* followBoardPublications({root,after,signal}) {
|
|
|
67
66
|
for(const slug of slugs){
|
|
68
67
|
const board=openBoard(root,slug);
|
|
69
68
|
try {
|
|
70
|
-
//
|
|
71
|
-
//
|
|
69
|
+
// The follower only reads the board's retained log: what a writer changed, it published in its own transaction,
|
|
70
|
+
// and what a native writer marked, the board's owner publishes on its wake (published.mjs). Read in one view,
|
|
71
|
+
// in the backing's own transaction (not the store's, which would publish), so the boundary and the heads are
|
|
72
|
+
// one state.
|
|
72
73
|
const boundary=board.tx(db=>{
|
|
73
|
-
|
|
74
|
-
const log=db.prepare('SELECT id,pruned_through FROM supercode_publication_log').get();
|
|
74
|
+
const log=db.prepare('SELECT id,pruned_through FROM supercode_publication_log').get()??{id:null,pruned_through:0};
|
|
75
75
|
const high=Number(db.prepare('SELECT MAX(sequence) AS n FROM supercode_publications').get()?.n??0);
|
|
76
76
|
const heads=initial?db.prepare('SELECT sequence FROM supercode_publication_heads ORDER BY resource_id').all().map(row=>row.sequence):[];
|
|
77
77
|
return {log:log.id,floor:Number(log.pruned_through),high,heads};
|
package/board/fence.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The board owner's fencing token. The OS lock (lock-holder.mjs) says who owns a home; the epoch makes a stale owner's
|
|
2
|
+
// write fail at the database whatever its stack is doing: an owner that lost its lock while blocked in synchronous work
|
|
3
|
+
// cannot commit after its successor started. Each owner start takes a new epoch, above every epoch any board of the
|
|
4
|
+
// home has stored and the home's own record (`<home>/board.epoch`, so a home whose boards are all new still counts up). Every
|
|
5
|
+
// write transaction in the owner process stores its epoch in the board (`supercode_board_state`, key `owner_epoch`)
|
|
6
|
+
// only when the stored one is not newer, in one conditional statement at the transaction's start; when it is newer this
|
|
7
|
+
// owner is stale, and it exits there, its transaction uncommitted. Every board is made by the owner or by an operation
|
|
8
|
+
// holding the home's lock, so its first (fenced) transaction stores its maker's epoch.
|
|
9
|
+
import { renameSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { boardHome } from '@volter/teams/board-owner';
|
|
12
|
+
|
|
13
|
+
let mine = null;
|
|
14
|
+
const STATE = 'CREATE TABLE IF NOT EXISTS supercode_board_state (key TEXT PRIMARY KEY, value TEXT)';
|
|
15
|
+
const epochFile = (root) => join(boardHome(root), 'board.epoch');
|
|
16
|
+
const recorded = (root) => { try { return Number(readFileSync(epochFile(root), 'utf8').trim()) || 0; } catch { return 0; } };
|
|
17
|
+
const storedIn = (db) => { db.exec(STATE); return Number(db.prepare("SELECT value FROM supercode_board_state WHERE key = 'owner_epoch'").get()?.value ?? 0) || 0; };
|
|
18
|
+
|
|
19
|
+
/** This process's epoch: null outside the owner. */
|
|
20
|
+
export const ownerEpoch = () => mine;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Take a new epoch for the home at `root` (the caller holds its lock): one above the home's record and every epoch the
|
|
24
|
+
* boards opened by `boards()` store; recorded, then fenced into each board.
|
|
25
|
+
*/
|
|
26
|
+
export function takeEpoch(root, boards) {
|
|
27
|
+
let high = recorded(root);
|
|
28
|
+
for (const board of boards()) { try { high = Math.max(high, storedIn(board.db)); } finally { board.close(); } }
|
|
29
|
+
mine = high + 1;
|
|
30
|
+
const file = epochFile(root);
|
|
31
|
+
writeFileSync(`${file}.tmp`, `${mine}\n`); renameSync(`${file}.tmp`, file);
|
|
32
|
+
for (const board of boards()) { try { board.tx(() => {}); } finally { board.close(); } }
|
|
33
|
+
return mine;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** At a write transaction's start: in the owner, store its epoch unless a newer one is stored, and exit when it is. */
|
|
37
|
+
export function fenced(db) {
|
|
38
|
+
if (mine === null) return;
|
|
39
|
+
db.exec(STATE);
|
|
40
|
+
const r = db.prepare(`INSERT INTO supercode_board_state (key, value) VALUES ('owner_epoch', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
41
|
+
WHERE CAST(supercode_board_state.value AS INTEGER) <= CAST(excluded.value AS INTEGER)`).run(String(mine));
|
|
42
|
+
if (r.changes > 0) return;
|
|
43
|
+
process.stderr.write(`${new Date().toISOString()} this owner (epoch ${mine}) was superseded by epoch ${storedIn(db)}: a newer owner holds the home; stopping before this write commits\n`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|