@volter/supercode-orchestrator 0.5.34 → 0.5.36

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
@@ -15,7 +15,7 @@ import { actorOf, view, backingPaths, layOut, backingOf, boardExists, boardPath,
15
15
  import { isLocalWorkspace } from './workspace.mjs';
16
16
  import { openZtrackBoard } from './ztrack.mjs';
17
17
  import * as engine from './engine.mjs';
18
- import { requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, supercodeBin, tick } from './dispatch.mjs';
18
+ import { requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, stopDispatching, supercodeBin, tick } from './dispatch.mjs';
19
19
  import { watchBoard } from './wake.mjs';
20
20
  import { streamBoardPublications } from './event-stream.mjs';
21
21
  import { completionOwnerOf, isLatestReviewer, loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
@@ -1358,7 +1358,7 @@ function closeSession(board, id) {
1358
1358
  export function summary(r) {
1359
1359
  const parts = [`board ${r.board}:`];
1360
1360
  if (r.error) parts.push(`error ${r.error}`);
1361
- for (const [k, label] of [['mailed', 'mailed updates'], ['started', 'started'], ['queued', 'waiting'], ['failed', 'failed'], ['lost', 'lost'], ['crashed', 'crashed'], ['timed_out', 'timed out'], ['reclaimed', 'reclaimed'], ['auto_blocked', 'gave up'], ['notified', 'notices'], ['closed', 'closed sessions'], ['undelivered', 'undelivered (retrying)']]) {
1361
+ for (const [k, label] of [['mailed', 'mailed updates'], ['started', 'started'], ['queued', 'waiting'], ['failed', 'failed'], ['lost', 'lost'], ['crashed', 'crashed'], ['timed_out', 'timed out'], ['reclaimed', 'reclaimed'], ['auto_blocked', 'gave up'], ['notified', 'notices'], ['closed', 'closed sessions'], ['undelivered', 'undelivered (retrying)'], ['subscriptions_ended', 'worker subscriptions to ended sessions ended']]) {
1362
1362
  if (r[k]?.length) parts.push(`${label} ${r[k].length}`);
1363
1363
  }
1364
1364
  if (r.promoted) parts.push(`promoted ${r.promoted}`);
@@ -1379,9 +1379,9 @@ async function serve(root, args = {}) {
1379
1379
  if (args.pidfile) writeFileSync(args.pidfile, `${process.pid}\n`);
1380
1380
  let stopped = false;
1381
1381
  let events;
1382
- const stop = () => { stopped = true; events?.close(); };
1383
- process.on('SIGTERM', stop);
1384
- process.on('SIGINT', stop);
1382
+ // a signal ends the round in progress with nothing new started (dispatch.mjs stopDispatching) and no round after it
1383
+ const stop = () => { stopped = true; stopDispatching(); events?.close(); };
1384
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, stop);
1385
1385
  // what the serve says is kept in the home too (logs/workflow-serve.log), so why it stopped outlives its pane
1386
1386
  const kept = join(root, 'logs', 'workflow-serve.log');
1387
1387
  const log = (text) => {
@@ -1488,7 +1488,7 @@ async function serve(root, args = {}) {
1488
1488
  interval = settings.interval;
1489
1489
  const reports = await tick({ root, lanes: state.lanes, settings, params, deploymentReceipt: true });
1490
1490
  round += 1;
1491
- await watchRounds(reports);
1491
+ if (!stopped) await watchRounds(reports);
1492
1492
  for (const r of reports) {
1493
1493
  nextMailAt = Math.min(nextMailAt, r.next_mail_at ?? Infinity);
1494
1494
  const said = summary(r);
@@ -1509,7 +1509,7 @@ async function serve(root, args = {}) {
1509
1509
  }
1510
1510
  } catch (error) {
1511
1511
  log(`tick failed: ${error.message}`);
1512
- await watchRounds([{ board: '*', error: `tick failed: ${error.message}` }]);
1512
+ if (!stopped) await watchRounds([{ board: '*', error: `tick failed: ${error.message}` }]);
1513
1513
  }
1514
1514
  // each round is a line of its own in the slow log, with the steps it spent its time on
1515
1515
  recordSlow({ kind: 'call', name: 'dispatch round', ms: Date.now() - began, slowest: null, extra: { steps: takeSteps() } });
@@ -1529,7 +1529,7 @@ async function serve(root, args = {}) {
1529
1529
  }
1530
1530
  } finally {
1531
1531
  events?.close();
1532
- process.off('SIGTERM', stop); process.off('SIGINT', stop);
1532
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.off(signal, stop);
1533
1533
  if (args.pidfile) { try { unlinkSync(args.pidfile); } catch { /* gone */ } }
1534
1534
  }
1535
1535
  log(stopped ? 'stopped (a signal)' : 'stopped');
@@ -910,9 +910,15 @@ export function closeRequested(board, report, { taskId = null, caller = null, tt
910
910
  const { close_error: _error, ...rest } = current;
911
911
  setRunSession(db, row.id, { ...rest, close_error: null, closed_at: at, close_confirmed_at: at });
912
912
  const address = addressOf(session);
913
- const subscribed = active.some((r) => addressOf(json(r.metadata, {})?.supercode) === address && r.id !== row.id);
914
- if (!subscribed) db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?").run(row.task_id, address);
915
- event(db, row.task_id, 'session_closed', { session_id: session.session_id, pane: session.pane, machine: session.machine }, row.id);
913
+ // The closed session stops hearing every card it heard as their worker: its own and each subtask (a launch
914
+ // subscribes it to all of them), unless another unended run still works in it, read in this transaction.
915
+ // Removing only the card's own left every subtask notifying a closed session, each attempt a failed delivery,
916
+ // every round. A later run that resumes it by id subscribes it again at its launch (and each round while it is
917
+ // the card's current run).
918
+ const subscribed = db.prepare('SELECT r.id, r.metadata FROM task_runs r WHERE r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)').all()
919
+ .some((r) => addressOf(json(r.metadata, {})?.supercode) === address && r.id !== row.id);
920
+ const unsubscribed = subscribed ? 0 : db.prepare("DELETE FROM kanban_notify_subs WHERE platform = 'supercode' AND chat_id = ? AND (task_id = ? OR json_extract(delivery_metadata, '$.role') = 'worker')").run(address, row.task_id).changes;
921
+ event(db, row.task_id, 'session_closed', { session_id: session.session_id, pane: session.pane, machine: session.machine, unsubscribed }, row.id);
916
922
  }
917
923
  });
918
924
  if (error) report.undelivered.push([row.task_id, addressOf(session), error, 'close']);
@@ -1966,7 +1972,12 @@ async function send(board, from, label, to, body, key, { queue = false, ask = fa
1966
1972
  // killed after it answered `code: 0` delivered)
1967
1973
  const r = await scAsync(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 180_000 });
1968
1974
  const answer = lastJson(r.stdout) ?? {};
1969
- if (answer.code !== 0) throw new Error((answer.text || r.stderr || `no answer (exit ${r.code})`).trim().slice(-300));
1975
+ if (answer.code !== 0) {
1976
+ const error = new Error((answer.text || r.stderr || `no answer (exit ${r.code})`).trim().slice(-300));
1977
+ // the delivery door's own answer that the recipient runs nowhere (mail_route / mail_send), never a local failure
1978
+ if (typeof answer.code === 'number' && / is no longer running\. Nothing was sent\./.test(String(answer.text ?? ''))) error.notRunning = true;
1979
+ throw error;
1980
+ }
1970
1981
  return answer.receipt ?? { delivered: false, reason: 'mail door returned no delivery receipt' };
1971
1982
  }
1972
1983
 
@@ -1998,6 +2009,7 @@ export async function sendBoardMessages(board, report, taskId = null) {
1998
2009
  const planned = tx(board, db => pendingRequestMail(db, taskId));
1999
2010
  if (Number.isFinite(planned.nextAt)) report.next_mail_at = Math.min(report.next_mail_at ?? Infinity, planned.nextAt);
2000
2011
  for (const mail of planned.ready) {
2012
+ if (stopping) break;
2001
2013
  const first = mail.members[0];
2002
2014
  try {
2003
2015
  const task = view(board, db => getTask(db, first.task_id));
@@ -2064,8 +2076,73 @@ function ensureSubscriptions(board, entry) {
2064
2076
  /** The event kinds an audience hears (the workflow's notify). */
2065
2077
  const heardBy = (entry, audience) => Object.keys(entry.workflow.notify?.[audience] ?? {});
2066
2078
 
2079
+ /**
2080
+ * The board's record of each session address: `held`, every address an unended or current run works in, and `latest`,
2081
+ * the latest run that names it. A Claude or Codex session resumed by id keeps its address, so a run that resumes it
2082
+ * holds it again.
2083
+ */
2084
+ function sessionRecord(db) {
2085
+ const held = new Set(), latest = new Map();
2086
+ for (const r of db.prepare('SELECT r.id, r.task_id, r.ended_at, r.metadata, t.id AS current FROM task_runs r LEFT JOIN tasks t ON t.current_run_id = r.id ORDER BY r.id').all()) {
2087
+ const session = json(r.metadata, {})?.supercode, address = addressOf(session);
2088
+ if (!address) continue;
2089
+ if (!r.ended_at || r.current) held.add(address);
2090
+ latest.set(address, { run: r.id, task_id: r.task_id, session });
2091
+ }
2092
+ return { held, latest };
2093
+ }
2094
+
2095
+ /**
2096
+ * Whether the recipient's machine says, through its native discovery, that the session is not running (`persisted`:
2097
+ * its transcript is on disk and nothing runs it). Asked only after the delivery door answered that the session is no
2098
+ * longer running, once per machine and harness a round; null when the machine cannot answer.
2099
+ */
2100
+ function nativeEnded(address, discovered) {
2101
+ const m = String(address).match(/^sc:([^:]+):([^:]+):(.+)$/);
2102
+ if (!m) return null;
2103
+ const [, machine, harness, session_id] = m, key = `${normalMachine(machine)}\u0000${harness}`;
2104
+ if (!discovered.has(key)) { try { discovered.set(key, nativeSessions(machine, harness)); } catch { discovered.set(key, null); } }
2105
+ const rows = discovered.get(key);
2106
+ if (!rows) return null;
2107
+ return nativeReading(rows, { session_id, harness }).alive === false ? { by: 'native_discovery', presence: 'persisted', machine } : null;
2108
+ }
2109
+
2110
+ /**
2111
+ * The worker subscriptions of sessions that have ended, removed in one transaction, with one notice_unsubscribed event
2112
+ * per session naming its cards. A session has ended when no unended or current run works in it, the fleet does not list
2113
+ * it running, its latest run is not a release (a released session is a person's own, left running), and either the
2114
+ * board closed it (that run's confirmed close) or its machine's native discovery read it `persisted` this round
2115
+ * (`natively`). Everything is read inside the transaction, so a run that took the session meanwhile keeps it. A run
2116
+ * that resumes the session subscribes it again at its launch and each round while it is the card's current run.
2117
+ * A creator's or manager's subscription is never ended here: its notices wait for the session to come back.
2118
+ */
2119
+ function endWorkerSubscriptions(board, fleet, report, natively = new Map()) {
2120
+ tx(board, (db) => {
2121
+ const { held, latest } = sessionRecord(db);
2122
+ const remove = db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?");
2123
+ const ended = new Map();
2124
+ for (const row of db.prepare("SELECT task_id, chat_id, thread_id FROM kanban_notify_subs WHERE platform = 'supercode' AND json_extract(delivery_metadata, '$.role') = 'worker'").all()) {
2125
+ const address = row.chat_id, record = latest.get(address);
2126
+ if (!record || record.session.released || held.has(address) || (fleet.ok && fleet.alive.has(address))) continue;
2127
+ const evidence = record.session.close_confirmed_at ? { by: 'board_close', run: record.run, closed_at: record.session.close_confirmed_at } : natively.get(address);
2128
+ if (!evidence || !remove.run(row.task_id, address, row.thread_id).changes) continue;
2129
+ if (!ended.has(address)) ended.set(address, { record, evidence, cards: [] });
2130
+ ended.get(address).cards.push(row.task_id);
2131
+ }
2132
+ for (const [address, { record, evidence, cards }] of ended) {
2133
+ event(db, record.task_id, 'notice_unsubscribed', { address, role: 'worker', reason: 'the session has ended', evidence, cards, count: cards.length }, record.run);
2134
+ for (const card of cards) (report.subscriptions_ended ??= []).push([card, address]);
2135
+ }
2136
+ });
2137
+ }
2138
+
2067
2139
  async function notify(board, fleet, report, entry) {
2068
2140
  const from = boardAddress(board.label);
2141
+ endWorkerSubscriptions(board, fleet, report);
2142
+ const { held, latest } = view(board, sessionRecord);
2143
+ // A recipient whose delivery failed this round is not asked again this round for its other cards: those are left for
2144
+ // the next round untried (a closed session subscribed to 21 subtasks was a delivery process for each, every round)
2145
+ const refused = new Map(), discovered = new Map(), natively = new Map();
2069
2146
  // each subscription's notices go in order; different subscribers are told side by side
2070
2147
  await pool(subscriptions(board.db).filter((sub) => sub.platform === 'supercode'), SEND_CONCURRENCY, async (sub) => {
2071
2148
  if (sub.delivery_metadata?.dependent_card && sub.delivery_metadata.waiting_recipient) return;
@@ -2085,7 +2162,7 @@ async function notify(board, fleet, report, entry) {
2085
2162
  // the one the next run gets): the notice is filed in its mailbox, read when it next works
2086
2163
  const working = role !== 'worker' || view(board, (db) => addressOf(currentRun(db, sub.task_id)?.metadata?.supercode) === sub.chat_id);
2087
2164
  let cursor = Number(sub.last_event_id);
2088
- let failed = null;
2165
+ let failed = null, untried = false;
2089
2166
  try {
2090
2167
  // an event older than the workflow's notice_max_age_seconds is history, not news: it is passed over, never mailed
2091
2168
  const maxAge = Number(entry.params.notice_max_age_seconds) || null;
@@ -2113,10 +2190,17 @@ async function notify(board, fleet, report, entry) {
2113
2190
  } else text = noticeOf(entry, role, ev.kind, task, p);
2114
2191
  if (text) {
2115
2192
  const body = ['worker', 'dependency'].includes(role) ? identifyNotice(ev, text) : text;
2116
- const receipt = await send(board, from, board.label, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { queue: String(sub.chat_id).includes(':claude-code:') ? false : !working || (!entry.workflow.run && sub.delivery_mode === 'notify'), subject: `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}` });
2117
- // A notice the recipient's mailbox took is filed: the machine delivers it at the session's next turn, so the
2118
- // board does not send it again.
2119
- if (!receipt.delivered && !receipt.queued) throw new Error(receipt.reason ?? 'notice was not filed');
2193
+ if (refused.has(sub.chat_id) || stopping) { untried = true; break; }
2194
+ let receipt;
2195
+ try {
2196
+ receipt = await send(board, from, board.label, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { queue: String(sub.chat_id).includes(':claude-code:') ? false : !working || (!entry.workflow.run && sub.delivery_mode === 'notify'), subject: `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}` });
2197
+ // A notice the recipient's mailbox took is filed: the machine delivers it at the session's next turn, so the
2198
+ // board does not send it again.
2199
+ if (!receipt.delivered && !receipt.queued) throw new Error(receipt.reason ?? 'notice was not filed');
2200
+ } catch (error) {
2201
+ if (!refused.has(sub.chat_id)) refused.set(sub.chat_id, error);
2202
+ throw error;
2203
+ }
2120
2204
  tx(board, (db) => event(db, sub.task_id, receipt.delivered ? 'notice_delivered' : 'notice_filed', { source_event: ev.id, kind: ev.kind, address: sub.chat_id, receipt }));
2121
2205
  report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
2122
2206
  // the cursor follows each notice sent, so a restart mid-backlog resumes after it
@@ -2125,6 +2209,14 @@ async function notify(board, fleet, report, entry) {
2125
2209
  cursor = ev.id;
2126
2210
  }
2127
2211
  } catch (error) {
2212
+ // One "no longer running" answer is not an ended session (a Claude session is resumed under the same id): for a
2213
+ // worker no run holds, and that is not a released session, the machine's native discovery is asked, and a
2214
+ // `persisted` reading ends its worker subscriptions after this round's deliveries.
2215
+ const record = latest.get(sub.chat_id);
2216
+ if (error.notRunning && role === 'worker' && record && !record.session.released && !held.has(sub.chat_id)) {
2217
+ if (!natively.has(sub.chat_id)) natively.set(sub.chat_id, nativeEnded(sub.chat_id, discovered));
2218
+ if (natively.get(sub.chat_id)) return;
2219
+ }
2128
2220
  failed = String(error.message).slice(-200);
2129
2221
  tx(board, (db) => {
2130
2222
  const kind = 'notice_failed';
@@ -2140,12 +2232,16 @@ async function notify(board, fleet, report, entry) {
2140
2232
  const where = "WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?";
2141
2233
  const identity = [sub.task_id, sub.chat_id, sub.thread_id ?? ''];
2142
2234
  db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND last_event_id < ?`).run(cursor, ...identity, cursor);
2235
+ // a round that left this subscription untried (a delivery to its recipient failed on another card) says nothing of it
2236
+ if (untried) return;
2143
2237
  const failures = failed ? Number(sub.delivery_metadata?.failures ?? 0) + 1 : 0;
2144
2238
  if (failures >= MAX_SEND_FAILURES) { db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity); return; }
2145
2239
  if (failures !== Number(sub.delivery_metadata?.failures ?? 0)) db.prepare(`UPDATE kanban_notify_subs SET delivery_metadata = ? ${where}`).run(JSON.stringify({ ...sub.delivery_metadata, failures }), ...identity);
2146
2240
  if (task?.status === 'archived' && cursor >= events.at(-1).id) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity);
2147
2241
  });
2148
2242
  });
2243
+ const persisted = new Map([...natively].filter(([, reading]) => reading));
2244
+ if (persisted.size) endWorkerSubscriptions(board, fleet, report, persisted);
2149
2245
  }
2150
2246
 
2151
2247
  // Hermes's terminal kinds for a chat subscriber, in its order of silence: archived and unblocked move the cursor and say nothing
@@ -2359,6 +2455,11 @@ function scAsync(args, { input = null, timeout = 120_000 } = {}) {
2359
2455
  });
2360
2456
  }
2361
2457
 
2458
+ // A dispatcher told to stop (its serve's SIGTERM, SIGINT or SIGHUP) starts nothing more in the round it is in: no further
2459
+ // board, card start, board message or notice. What it leaves untried is not a failure; the next dispatcher sends it.
2460
+ let stopping = false;
2461
+ export function stopDispatching() { stopping = true; }
2462
+
2362
2463
  // how many deliveries a tick has in flight at once (each is a `supercode message` process of a few seconds)
2363
2464
  const SEND_CONCURRENCY = 8;
2364
2465
 
@@ -2366,7 +2467,7 @@ const SEND_CONCURRENCY = 8;
2366
2467
  async function pool(items, n, fn) {
2367
2468
  let next = 0;
2368
2469
  await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
2369
- while (next < items.length) { const i = next; next += 1; await fn(items[i]); }
2470
+ while (next < items.length && !stopping) { const i = next; next += 1; await fn(items[i]); }
2370
2471
  }));
2371
2472
  }
2372
2473
 
@@ -2503,6 +2604,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
2503
2604
  const held = dispatch ? homeHoldings(root, boards, lanes, fleet, entry) : [];
2504
2605
  const out = [];
2505
2606
  for (const slug of boards) {
2607
+ if (stopping) break;
2506
2608
  const report = { board: slug, ms: { fleet: fleetMs }, mailed: [], started: [], queued: [], failed: [], lost: [], crashed: [], timed_out: [], reclaimed: [], auto_blocked: [], promoted: 0, unassigned: [], nonspawnable: [], notified: [], undelivered: [], fleet: fleet.ok ? 'read' : fleet.error };
2507
2609
  // the mailbox's answers in flight (not part of the report the caller prints)
2508
2610
  Object.defineProperty(report, 'answers', { value: [], enumerable: false });
@@ -2533,7 +2635,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
2533
2635
  tx(board,db=>auditEndedRuns(db,board,entry));
2534
2636
  if (dispatch) await activatePullRequestReviews(board, entry, report, effects, { only });
2535
2637
  await sendBoardMessages(board, report);
2536
- if (dispatch) startCards(board, fleet, lanes, settings, report, entry, effects, { only, held });
2638
+ if (dispatch && !stopping) startCards(board, fleet, lanes, settings, report, entry, effects, { only, held });
2537
2639
  phase('start');
2538
2640
  if (dispatch && !only && !report.paused) observeIdle(board, fleet, settings, report, entry, effects);
2539
2641
  // what the moves asked for, once they are committed (messages to actors, closed panes, cleaned workspaces)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.34",
3
+ "version": "0.5.36",
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": {