@volter/supercode-orchestrator 0.4.123 → 0.4.125
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/board/cli.mjs +23 -11
- package/board/dispatch.mjs +10 -4
- package/board/store.mjs +6 -1
- package/board/wake.mjs +134 -0
- package/board.mjs +22 -11
- package/package.json +1 -1
package/board/cli.mjs
CHANGED
|
@@ -13,7 +13,8 @@ import { view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_
|
|
|
13
13
|
import { isLocalWorkspace } from './workspace.mjs';
|
|
14
14
|
import { openZtrackBoard } from './ztrack.mjs';
|
|
15
15
|
import * as engine from './engine.mjs';
|
|
16
|
-
import { adoptSession, boardAddress, boardLabel, boardSettings, callerSession, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, tick } from './dispatch.mjs';
|
|
16
|
+
import { adoptSession, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, supercodeBin, tick } from './dispatch.mjs';
|
|
17
|
+
import { watchBoard } from './wake.mjs';
|
|
17
18
|
import { loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
|
|
18
19
|
import { evaluate } from './expr.mjs';
|
|
19
20
|
|
|
@@ -48,7 +49,7 @@ supercode's own:
|
|
|
48
49
|
create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start
|
|
49
50
|
With a pane lane and room, a card starts at once and answers "started" with its session; else it answers why it
|
|
50
51
|
waits, and its creator is told when it starts. A subtask is worked by its arc's session and never starts alone.
|
|
51
|
-
move <id> <machine|none> adopt <id> <session> [--assignee P] close <id> serve (= daemon) list --machine M
|
|
52
|
+
move <id> <machine|none> adopt <id> <session> [--assignee P] [--role ROLE] close <id> serve (= daemon) list --machine M
|
|
52
53
|
notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency]
|
|
53
54
|
Any verb … --at sc:<machine>:board:<home>: sent as mail to a board kept on another machine.
|
|
54
55
|
<event> <id>... [reason] [--<field> value]: any other event the board's workflow declares, its flags the event's fields.
|
|
@@ -814,7 +815,7 @@ export async function runWorkflow(argv) {
|
|
|
814
815
|
case 'adopt': {
|
|
815
816
|
need(2, 'a card id and a session');
|
|
816
817
|
const { settings } = await laneState(root);
|
|
817
|
-
const got = withBoard((b) => adoptSession(b, id, rest[1], { workspace: args.workspace ?? null, ttl: settings.ttl, assignee: args.assignee ?? null }));
|
|
818
|
+
const got = withBoard((b) => adoptSession(b, id, rest[1], { workspace: args.workspace ?? null, ttl: settings.ttl, assignee: args.assignee ?? null, role: args.role ?? null }));
|
|
818
819
|
say(args, `${id} is worked by ${got.address} (pane ${got.pane ?? '?'} in ${got.workspace ?? 'its card\'s directory'})`, { ok: true, ...got });
|
|
819
820
|
return 0;
|
|
820
821
|
}
|
|
@@ -1139,7 +1140,8 @@ async function serve(root, args = {}) {
|
|
|
1139
1140
|
const params = { ...(Number(args.max) > 0 ? { max_spawn: Number(args.max) } : {}), ...(Number(args['failure-limit']) > 0 ? { failure_limit: Number(args['failure-limit']) } : {}) };
|
|
1140
1141
|
if (args.pidfile) writeFileSync(args.pidfile, `${process.pid}\n`);
|
|
1141
1142
|
let stopped = false;
|
|
1142
|
-
|
|
1143
|
+
let events;
|
|
1144
|
+
const stop = () => { stopped = true; events?.close(); };
|
|
1143
1145
|
process.on('SIGTERM', stop);
|
|
1144
1146
|
process.on('SIGINT', stop);
|
|
1145
1147
|
// what the serve says is kept in the home too (logs/workflow-serve.log), so why it stopped outlives its pane
|
|
@@ -1154,9 +1156,12 @@ async function serve(root, args = {}) {
|
|
|
1154
1156
|
const installed = () => { try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version ?? null; } catch { return null; } };
|
|
1155
1157
|
const running = installed();
|
|
1156
1158
|
log(`dispatching the boards of ${root} from ${localMachine()} (orchestrator ${running ?? 'unknown'})`);
|
|
1159
|
+
events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log });
|
|
1160
|
+
log('dispatch wakes on board writes, document edits, board mail and session catalogue events; interval is a safety sweep');
|
|
1157
1161
|
const stuck = {};
|
|
1158
1162
|
const warned = {};
|
|
1159
1163
|
let pausedSaid = false;
|
|
1164
|
+
let wakeReason = null;
|
|
1160
1165
|
try {
|
|
1161
1166
|
while (!stopped) {
|
|
1162
1167
|
// An installer owns this operational window, not a card pause. Finish the
|
|
@@ -1177,6 +1182,8 @@ async function serve(root, args = {}) {
|
|
|
1177
1182
|
pausedSaid = Boolean(paused(root)) && pausedSaid;
|
|
1178
1183
|
let interval = 60;
|
|
1179
1184
|
const began = Date.now();
|
|
1185
|
+
if (wakeReason) log(`dispatch wake ${wakeReason.sources.join(', ')}; event age ${began - wakeReason.at}ms`);
|
|
1186
|
+
wakeReason = null;
|
|
1180
1187
|
try {
|
|
1181
1188
|
const state = await laneState(root);
|
|
1182
1189
|
const settings = { ...state.settings, ...override };
|
|
@@ -1186,9 +1193,9 @@ async function serve(root, args = {}) {
|
|
|
1186
1193
|
if (!said.endsWith('nothing to do') || args.verbose) log(said);
|
|
1187
1194
|
if (args.verbose && r.ms) log(` took ${Object.entries(r.ms).map(([k, v]) => `${k} ${(v / 1000).toFixed(1)}s`).join(', ')}`);
|
|
1188
1195
|
if (r.paused && !pausedSaid) { log(`new starts paused by ${r.paused} (\`supercode workflow resume\` lifts it)`); pausedSaid = true; }
|
|
1189
|
-
//
|
|
1190
|
-
stuck[r.board] = r.ready_waiting && !r.started.length && !r.paused ? (stuck[r.board]
|
|
1191
|
-
if (stuck[r.board] >=
|
|
1196
|
+
// Event bursts do not shorten the six-sweep stuck warning.
|
|
1197
|
+
stuck[r.board] = r.ready_waiting && !r.started.length && !r.paused ? (stuck[r.board] || Date.now()) : 0;
|
|
1198
|
+
if (stuck[r.board] && Date.now() - stuck[r.board] >= interval * 6000 && Date.now() - (warned[r.board] ?? 0) >= 300_000) { log(`board ${r.board} stuck: ${r.ready_waiting} ready card(s) and nothing started for ${Math.floor((Date.now() - stuck[r.board]) / 1000)}s; \`supercode workflow diagnostics\``); warned[r.board] = Date.now(); }
|
|
1192
1199
|
for (const [t, why] of r.failed) log(` ${t} did not start: ${why}`);
|
|
1193
1200
|
for (const [t, why] of r.unsettled ?? []) log(` ${t} could not be read: ${why}`);
|
|
1194
1201
|
for (const [t, s, m] of r.started) log(` ${t} started ${s} (${m})`);
|
|
@@ -1201,14 +1208,19 @@ async function serve(root, args = {}) {
|
|
|
1201
1208
|
const newer = () => { const now = installed(); return running && now && now !== running ? now : null; };
|
|
1202
1209
|
let moved = newer();
|
|
1203
1210
|
// the next tick is due an interval after this one began (a long tick is not followed by a full interval's wait)
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
await
|
|
1207
|
-
|
|
1211
|
+
events.refresh();
|
|
1212
|
+
if (!stopped && !moved) {
|
|
1213
|
+
wakeReason = await events.wait(Math.max(0, interval * 1000 - (Date.now() - began)));
|
|
1214
|
+
// A busy event stream is coalesced to at most one tick per second.
|
|
1215
|
+
const remaining = 1000 - (Date.now() - began);
|
|
1216
|
+
if (remaining > 0 && !stopped) await new Promise(done => setTimeout(done, remaining));
|
|
1217
|
+
moved = newer();
|
|
1208
1218
|
}
|
|
1209
1219
|
if (moved) { log(`orchestrator ${moved} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
|
|
1210
1220
|
}
|
|
1211
1221
|
} finally {
|
|
1222
|
+
events?.close();
|
|
1223
|
+
process.off('SIGTERM', stop); process.off('SIGINT', stop);
|
|
1212
1224
|
if (args.pidfile) { try { unlinkSync(args.pidfile); } catch { /* gone */ } }
|
|
1213
1225
|
}
|
|
1214
1226
|
log(stopped ? 'stopped (a signal)' : 'stopped');
|
package/board/dispatch.mjs
CHANGED
|
@@ -1158,7 +1158,7 @@ function resolveLocalWorkspace(board, task) {
|
|
|
1158
1158
|
* never as the session describes itself. From then on the board watches it, resumes it when lost, and tells it of
|
|
1159
1159
|
* its card. `who` is an address, a session id, or a `name@machine` the fleet lists.
|
|
1160
1160
|
*/
|
|
1161
|
-
export function adoptSession(board, id, who, { workspace = null, ttl, assignee = null } = {}) {
|
|
1161
|
+
export function adoptSession(board, id, who, { workspace = null, ttl, assignee = null, role = null } = {}) {
|
|
1162
1162
|
const fleet = observeFleet();
|
|
1163
1163
|
if (!fleet.ok) throw new Error(`supercode could not read the fleet: ${fleet.error}`);
|
|
1164
1164
|
const found = fleet.sessions.find((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who);
|
|
@@ -1195,14 +1195,20 @@ export function adoptSession(board, id, who, { workspace = null, ttl, assignee =
|
|
|
1195
1195
|
if (dir && !task.workspace_anchor && !(task.workspace_kind === 'dir' && task.workspace_path)) {
|
|
1196
1196
|
db.prepare("UPDATE tasks SET workspace_kind = 'dir', workspace_path = ? WHERE id = ?").run(dir.replace(/^dir:/, ''), id);
|
|
1197
1197
|
}
|
|
1198
|
-
//
|
|
1198
|
+
// Adoption takes the role and slot declared for this card's current stage.
|
|
1199
1199
|
const entry = workflowFor(board.root);
|
|
1200
|
+
const expected = runSpec(entry, statusOf(db, task), task);
|
|
1201
|
+
if (role && role !== expected?.role) throw new Error(`${id} needs its ${expected?.role ?? 'declared'} role, not ${role}`);
|
|
1202
|
+
if (expected?.role === 'reviewer') {
|
|
1203
|
+
const implementer = lastSession(db, id, 'worker');
|
|
1204
|
+
if (implementer?.session_id === sessionId) throw new Error('a reviewer must be independent of the implementer session');
|
|
1205
|
+
}
|
|
1200
1206
|
if (!beforeRun) fire(db, board, entry, id, entry.workflow.run ? entry.workflow.dispatch.start : 'adopt', { roles: ['manager'], payload: { lock: claimer() } });
|
|
1201
1207
|
const runId = getTask(db, id).current_run_id;
|
|
1202
1208
|
if (!runId) throw new Error(`${id} waits on parents that are not done`);
|
|
1203
1209
|
const session = { surface: 'pane', session_id: sessionId, harness, machine: host, pane: found.pane ?? null, address: found.address, mode: 'adopt', launched_at: now(), seen_at: now() };
|
|
1204
|
-
setRunSession(db, runId, { ...(currentRun(db, id)?.metadata?.supercode ?? {}), ...session, slot: beforeSession?.slot ?? 'worker', close_requested: null, closed_at: null, close_confirmed_at: null, close_error: null, missing_since: null });
|
|
1205
|
-
event(db, id, 'session_adopted', { session_id: sessionId, machine: host, pane: session.pane, address: found.address, mode: 'adopt' }, runId);
|
|
1210
|
+
setRunSession(db, runId, { ...(currentRun(db, id)?.metadata?.supercode ?? {}), ...session, slot: currentRun(db, id)?.metadata?.supercode?.slot ?? beforeSession?.slot ?? expected?.session ?? 'worker', close_requested: null, closed_at: null, close_confirmed_at: null, close_error: null, missing_since: null });
|
|
1211
|
+
event(db, id, 'session_adopted', { session_id: sessionId, machine: host, pane: session.pane, address: found.address, mode: 'adopt', role: currentRun(db, id)?.metadata?.supercode?.role }, runId);
|
|
1206
1212
|
subscribeIn(db, id, found.address, 'worker');
|
|
1207
1213
|
for (const p of db.prepare('SELECT parent_id FROM task_links WHERE child_id = ?').all(id)) subscribeIn(db, p.parent_id, found.address, 'dependency');
|
|
1208
1214
|
return { ...session, workspace: dir };
|
package/board/store.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { dirname, join } from 'node:path';
|
|
|
16
16
|
import { DatabaseSync } from 'node:sqlite';
|
|
17
17
|
import { openZtrackBoard } from './ztrack.mjs';
|
|
18
18
|
import { isFilesBoard, openFilesBoard } from './files.mjs';
|
|
19
|
+
import { signalBoardWrite } from './wake.mjs';
|
|
19
20
|
|
|
20
21
|
export const DEFAULT_BOARD = 'default';
|
|
21
22
|
|
|
@@ -163,7 +164,11 @@ export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing
|
|
|
163
164
|
* Run `fn(db)` in one write transaction of the board's backing: answers what `fn` answers; rolls back on a throw.
|
|
164
165
|
* A read inside the transaction is the state it writes over.
|
|
165
166
|
*/
|
|
166
|
-
export const tx = (board, fn) =>
|
|
167
|
+
export const tx = (board, fn) => {
|
|
168
|
+
const result = board.tx(fn);
|
|
169
|
+
signalBoardWrite(board.root, actor);
|
|
170
|
+
return result;
|
|
171
|
+
};
|
|
167
172
|
export const view = (board, fn) => fn(board.db);
|
|
168
173
|
|
|
169
174
|
// A deferred native transaction, with the whole operation retried on an optimistic conflict.
|
package/board/wake.mjs
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Wake hints never own board state. A missed hint is repaired by the safety
|
|
2
|
+
// sweep; a writer never waits for a dispatcher or for another writer.
|
|
3
|
+
import { watch, existsSync, readFileSync, readdirSync, writeFileSync, renameSync, unlinkSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
export function signalBoardWrite(root, actor) {
|
|
11
|
+
if (!root || actor?.includes(':board:')) return;
|
|
12
|
+
const path = join(root, '.dispatch-wake.json');
|
|
13
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
14
|
+
try {
|
|
15
|
+
writeFileSync(temporary, JSON.stringify({ at: Date.now(), actor }) + '\n', { mode: 0o600 });
|
|
16
|
+
renameSync(temporary, path);
|
|
17
|
+
} catch { try { unlinkSync(temporary); } catch { /* hints cannot fail a committed verb */ } }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One coalescing queue shared by standalone serve and the hosted dispatcher. */
|
|
21
|
+
export function watchBoard({ root, binary, env, log = () => {} }) {
|
|
22
|
+
const watchers = new Map();
|
|
23
|
+
let pending = null, waiter = null, closed = false, child = null, retry = null, lastError = null;
|
|
24
|
+
const wake = (source, at = Date.now()) => {
|
|
25
|
+
if (closed) return;
|
|
26
|
+
if (!pending) pending = { at, sources: new Set() };
|
|
27
|
+
pending.at = Math.min(pending.at, at);
|
|
28
|
+
pending.sources.add(source);
|
|
29
|
+
waiter?.();
|
|
30
|
+
};
|
|
31
|
+
const directory = (path, key, accept) => {
|
|
32
|
+
const id = `${path}\0${key}`;
|
|
33
|
+
if (watchers.has(id) || !existsSync(path)) return;
|
|
34
|
+
try {
|
|
35
|
+
const handle = watch(path, (_event, filename) => {
|
|
36
|
+
if (filename == null || accept(String(filename))) wake(key);
|
|
37
|
+
});
|
|
38
|
+
handle.on('error', error => {
|
|
39
|
+
handle.close(); watchers.delete(id);
|
|
40
|
+
log(`dispatch watch ${key} failed: ${error.message}; safety sweep remains active`);
|
|
41
|
+
});
|
|
42
|
+
watchers.set(id, handle);
|
|
43
|
+
} catch (error) { log(`dispatch watch ${key} unavailable: ${error.message}; safety sweep remains active`); }
|
|
44
|
+
};
|
|
45
|
+
const file = (path, key) => {
|
|
46
|
+
directory(dirname(path), key, name => name === basename(path));
|
|
47
|
+
try {
|
|
48
|
+
const target = realpathSync(path);
|
|
49
|
+
if (target !== path) directory(dirname(target), key, name => name === basename(target));
|
|
50
|
+
} catch { /* the parent watcher sees a newly created file */ }
|
|
51
|
+
};
|
|
52
|
+
const refresh = () => {
|
|
53
|
+
directory(root, 'board-write', name => {
|
|
54
|
+
if (name !== '.dispatch-wake.json') return false;
|
|
55
|
+
try { const receipt = JSON.parse(readFileSync(join(root, name), 'utf8')); wake('board-write', receipt.at); } catch { wake('board-write'); }
|
|
56
|
+
return false;
|
|
57
|
+
});
|
|
58
|
+
for (const name of ['workflow.yaml', 'config.yaml', 'installation-window.json', 'ESTOP']) file(join(root, name), name);
|
|
59
|
+
file(fileURLToPath(new URL('../package.json', import.meta.url)), 'installation');
|
|
60
|
+
const boards = join(root, 'kanban', 'boards');
|
|
61
|
+
directory(join(root, 'kanban'), 'board-registry', name => ['boards', 'ztrack.json', 'current'].includes(name));
|
|
62
|
+
directory(boards, 'board-registry', () => true);
|
|
63
|
+
const dirs = [join(root, 'kanban'), ...(existsSync(boards) ? readdirSync(boards).map(name => join(boards, name)) : [])];
|
|
64
|
+
for (const dir of dirs) {
|
|
65
|
+
file(join(dir, 'ztrack.json'), 'board-config');
|
|
66
|
+
try {
|
|
67
|
+
const config = JSON.parse(readFileSync(join(dir, 'ztrack.json'), 'utf8'));
|
|
68
|
+
const document = resolve(dir, config.document);
|
|
69
|
+
file(document, `document:${document}`);
|
|
70
|
+
file(`${document}.archive.md`, `document:${document}.archive.md`);
|
|
71
|
+
} catch { /* other backing or not yet created */ }
|
|
72
|
+
}
|
|
73
|
+
const config = env.SUPERCODE_HOME || (env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME, 'supercode') : join(env.HOME ?? '', '.config', 'supercode'));
|
|
74
|
+
const mail = join(config, 'mail');
|
|
75
|
+
directory(mail, 'mailbox-registry', name => name.startsWith('board-'));
|
|
76
|
+
if (existsSync(mail)) for (const name of readdirSync(mail)) {
|
|
77
|
+
if (name.startsWith('board-')) directory(join(mail, name, 'new'), 'board-mail', name => name.endsWith('.json'));
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
// The retained Teams catalogue includes local and remote session changes.
|
|
81
|
+
// This is a read-only stream, with no model or new native harness session.
|
|
82
|
+
const follow = () => {
|
|
83
|
+
if (closed) return;
|
|
84
|
+
let buffer = '', error = '';
|
|
85
|
+
const command = env.SUPERCODE_TEAMS_ENTRY ? process.execPath : binary;
|
|
86
|
+
const args = [...(env.SUPERCODE_TEAMS_ENTRY ? [env.SUPERCODE_TEAMS_ENTRY] : []), 'sessions', 'list', '--watch', '--json'];
|
|
87
|
+
child = spawn(command, args, { cwd: tmpdir(), env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
88
|
+
child.stdout.setEncoding('utf8').on('data', chunk => {
|
|
89
|
+
buffer += chunk;
|
|
90
|
+
if (buffer.length > 8 * 1024 * 1024) { error = 'catalogue frame exceeds 8 MiB'; child.kill(); return; }
|
|
91
|
+
let end;
|
|
92
|
+
while ((end = buffer.indexOf('\n')) >= 0) {
|
|
93
|
+
const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
|
|
94
|
+
try {
|
|
95
|
+
const event = JSON.parse(line);
|
|
96
|
+
lastError = null;
|
|
97
|
+
if (!['heartbeat', 'ping', 'keepalive'].includes(event.type)) wake('session-catalogue');
|
|
98
|
+
} catch { /* CLI diagnostics are not events */ }
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
child.stderr.setEncoding('utf8').on('data', chunk => { error = (error + chunk).slice(-500); });
|
|
102
|
+
child.on('error', failure => { error = failure.message; });
|
|
103
|
+
child.on('close', () => {
|
|
104
|
+
child = null;
|
|
105
|
+
if (!closed) {
|
|
106
|
+
const reason = error.trim() || 'connection closed';
|
|
107
|
+
if (reason !== lastError) log(`dispatch session stream ended: ${reason}; reconnecting, safety sweep remains active`);
|
|
108
|
+
lastError = reason;
|
|
109
|
+
retry = setTimeout(follow, 5000);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
refresh(); follow();
|
|
114
|
+
return {
|
|
115
|
+
refresh,
|
|
116
|
+
wake,
|
|
117
|
+
async wait(milliseconds) {
|
|
118
|
+
if (!pending && !closed) await new Promise(done => {
|
|
119
|
+
const timer = setTimeout(finish, Math.max(0, milliseconds));
|
|
120
|
+
function finish() { clearTimeout(timer); waiter = null; done(); }
|
|
121
|
+
waiter = finish;
|
|
122
|
+
});
|
|
123
|
+
// Bound coalescing: a sustained event stream cannot postpone dispatch.
|
|
124
|
+
if (pending && !closed) await new Promise(done => setTimeout(done, 100));
|
|
125
|
+
const result = pending; pending = null;
|
|
126
|
+
return result ? { at: result.at, sources: [...result.sources] } : null;
|
|
127
|
+
},
|
|
128
|
+
close() {
|
|
129
|
+
closed = true; waiter?.(); clearTimeout(retry);
|
|
130
|
+
for (const handle of watchers.values()) handle.close();
|
|
131
|
+
watchers.clear(); child?.kill('SIGTERM');
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
package/board.mjs
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
// docs/plans/hermes-compat.md row 14). The board and its dispatcher are supercode's own (`board/`): Hermes Kanban's
|
|
3
3
|
// format and lifecycle, each profile's lane decided by its `worker` in the IR. Where a Hermes gateway would run
|
|
4
4
|
// its dispatcher, the daemon runs this one the same way: when the board's workflow says so (its `dispatch.serve`),
|
|
5
|
-
// taking no file locks,
|
|
6
|
-
//
|
|
5
|
+
// taking no file locks, waking on events with `dispatch.tick` as a safety sweep.
|
|
6
|
+
// Each tick runs as
|
|
7
7
|
// `workflow dispatch` in a child process: a tick waits on supercode (the fleet, launches, mail), and the daemon's
|
|
8
8
|
// own loop never does.
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { summary } from './board/cli.mjs';
|
|
12
|
-
import { notifyChats } from './board/dispatch.mjs';
|
|
12
|
+
import { commandEnv, notifyChats, supercodeBin } from './board/dispatch.mjs';
|
|
13
|
+
import { watchBoard } from './board/wake.mjs';
|
|
13
14
|
import { workflowEntry } from './board/workflow.mjs';
|
|
14
15
|
import { evaluate, holds } from './board/expr.mjs';
|
|
15
16
|
|
|
@@ -52,8 +53,8 @@ export async function startBoard({ root, orchestration = null, log = () => {}, a
|
|
|
52
53
|
try { await notifyChats(root, adapters, log, wake); } catch (error) { log('warn', `board: chat notices failed: ${error.message}`); }
|
|
53
54
|
for (const r of reports) {
|
|
54
55
|
const line = summary(r);
|
|
55
|
-
stuck[r.board] = r.ready_waiting && !r.started?.length && !r.paused ? (stuck[r.board]
|
|
56
|
-
if (stuck[r.board] >=
|
|
56
|
+
stuck[r.board] = r.ready_waiting && !r.started?.length && !r.paused ? (stuck[r.board] || Date.now()) : 0;
|
|
57
|
+
if (stuck[r.board] && Date.now() - stuck[r.board] >= seconds * 6000 && Date.now() - (warned[r.board] ?? 0) >= 300_000) { log('warn', `board ${r.board} stuck: ${r.ready_waiting} ready card(s) and nothing started for ${Math.floor((Date.now() - stuck[r.board]) / 1000)}s`); warned[r.board] = Date.now(); }
|
|
57
58
|
if (r.paused && !said.has(`paused:${r.board}`)) { said.add(`paused:${r.board}`); log('info', `board ${r.board}: new starts paused by ${r.paused}`); }
|
|
58
59
|
if (!r.paused) said.delete(`paused:${r.board}`);
|
|
59
60
|
if (!line.endsWith('nothing to do')) log(r.error ? 'warn' : 'info', line);
|
|
@@ -67,12 +68,22 @@ export async function startBoard({ root, orchestration = null, log = () => {}, a
|
|
|
67
68
|
running = false;
|
|
68
69
|
}
|
|
69
70
|
};
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
const events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log: message => log('warn', message) });
|
|
72
|
+
void (async () => {
|
|
73
|
+
let wakeReason = null;
|
|
74
|
+
while (!stopped) {
|
|
75
|
+
const began = Date.now();
|
|
76
|
+
if (wakeReason) log('info', `board: dispatch wake ${wakeReason.sources.join(', ')}; event age ${began - wakeReason.at}ms`);
|
|
77
|
+
await tick();
|
|
78
|
+
events.refresh();
|
|
79
|
+
wakeReason = await events.wait(Math.max(0, seconds * 1000 - (Date.now() - began)));
|
|
80
|
+
const remaining = 1000 - (Date.now() - began);
|
|
81
|
+
if (remaining > 0 && !stopped) await new Promise(done => setTimeout(done, remaining));
|
|
82
|
+
}
|
|
83
|
+
})().catch(error => log('warn', `board: dispatch loop failed: ${error.message}`));
|
|
84
|
+
log('info', `board: dispatching on events, safety sweep every ${seconds}s, each card as its profile's worker says`);
|
|
74
85
|
return {
|
|
75
|
-
tick,
|
|
76
|
-
stop() { stopped = true;
|
|
86
|
+
tick() { events.wake('requested'); },
|
|
87
|
+
stop() { stopped = true; events.close(); },
|
|
77
88
|
};
|
|
78
89
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.125",
|
|
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": {
|