@volter/supercode-orchestrator 0.5.68 → 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.
@@ -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
+ }
@@ -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
- export function localMachine() {
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
- const reply=runtimeDoor(null,'harness.v1.machine.describe',{}, {timeout:15_000});
64
- if(reply.code===0){const machine=JSON.parse(reply.stdout);return localMachineName=normalMachine(machine.name??machine.hostname);}
65
- } catch { /* no local daemon: a plain host still has its OS machine name */ }
66
- return localMachineName=normalMachine(hostname());
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 caller of a verb the machine daemon's board door runs here (another machine's session, `sc:<machine>:<harness>:
100
- * <id>`), or null when the verb was not called through the door. The door is this verb's parent: it named the caller
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
- let readBoardCaller;
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 another session called on this board (the machine daemon's board door): the daemon named its caller from the
125
- // calling process itself, on the caller's own machine, and passes that address in place of the harness variables.
126
- const called = boardCaller();
127
- if (called) return called.session;
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=process.env.SUPERCODE_CONTEXT,team=agentScope(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 = process.env.SUPERCODE_CONTEXT, team = agentScope(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, { fresh: true }); managers = Array.isArray(entry.params.managers) ? entry.params.managers.map(String) : []; } catch { /* a workflow that cannot load names nobody */ }
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, { fresh: true });
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, { fresh: true });
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 child = spawnChild(process.execPath, [ORCHESTRATOR_ENTRY, 'workflow', 'deliver', '--root', root, '--json'], { stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
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
- child.on('error', (error) => { log('warn', `delivery pass did not start: ${error.message}`); deliveries.delete(root); });
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
- export async function deliver({ root, slugs = null }) {
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, { fresh: true });
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 slugs ?? listBoards(root)) {
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
- fileBoardMail(board, report); phase('mail');
2589
- report.effects = await runEffects(board, [], { pending: true }); phase('effects');
2590
- notify(board, fleet, report, entry); phase('notify');
2591
- synchronizeAgents(board, report); phase('agents');
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, { fresh: true });
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
- const held = dispatch ? homeHoldings(root, boards, lanes, fleet, entry) : [];
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;
@@ -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:process.env.SUPERCODE_CONTEXT??null,starter_session:creator?`${creator[1]}:${creator[2]}`:null};
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
- let callerRoles = () => ['manager'];
401
- /** How the engine learns who a caller is on a card: `fn(db, cardId)` answers the caller's roles there. */
402
- export function setCaller(fn) { callerRoles = fn; }
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;
@@ -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 writes (the
19
- // publication transaction) and closes the board, which rewrites the WAL and raises a watch event of its own; an event
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
- // External native writers do not share our transaction wrapper. Their
71
- // filesystem event reconciles once into the same retained source log.
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
- recordCardPublications(db,null);
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};
@@ -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
+ }