@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 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
- 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');
@@ -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
- // the workflow's adopt event: the card is claimed for its implementer, whose session this now is
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) => 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.123",
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": {