@volter/supercode-orchestrator 0.4.124 → 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 +21 -9
- 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
|
|
|
@@ -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/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": {
|