@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 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
- const stop = () => { stopped = true; };
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
- // Hermes's stuck warning: ready work for six ticks running and nothing started
1190
- stuck[r.board] = r.ready_waiting && !r.started.length && !r.paused ? (stuck[r.board] ?? 0) + 1 : 0;
1191
- if (stuck[r.board] >= 6 && Date.now() - (warned[r.board] ?? 0) >= 300_000) { log(`board ${r.board} stuck: ${r.ready_waiting} ready card(s) and nothing started for ${stuck[r.board]} ticks; \`supercode workflow diagnostics\``); warned[r.board] = Date.now(); }
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
- const rest = Math.max(1, interval - Math.floor((Date.now() - began) / 1000));
1205
- for (let waited = 0; waited < rest && !stopped && !moved; waited += 1) {
1206
- await new Promise((done) => setTimeout(done, 1000));
1207
- if (waited % 5 === 4) moved = newer();
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) => board.tx(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, one tick every `dispatch.tick` seconds
6
- // of the workflow (at least 1), the first after five seconds. Each tick runs as
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] ?? 0) + 1 : 0;
56
- if (stuck[r.board] >= 6 && Date.now() - (warned[r.board] ?? 0) >= 300_000) { log('warn', `board ${r.board} stuck: ${r.ready_waiting} ready card(s) and nothing started for ${stuck[r.board]} ticks`); warned[r.board] = Date.now(); }
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 first = setTimeout(() => { void tick(); }, 5_000);
71
- const timer = setInterval(() => { void tick(); }, seconds * 1000);
72
- first.unref?.(); timer.unref?.();
73
- log('info', `board: dispatching every ${seconds}s, each card as its profile's worker says`);
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; clearTimeout(first); clearInterval(timer); },
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.124",
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": {