@volter/supercode-orchestrator 0.5.33 → 0.5.35

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/bin/runtime.mjs CHANGED
@@ -674,6 +674,15 @@ async function main() {
674
674
  return;
675
675
  }
676
676
  const root = resolve(args.root ?? defaultRoot());
677
+ // `SUPERCODE_BOARD_DISPATCHER=serve`: whoever started this daemon also runs `workflow serve` on the home, the board's
678
+ // one dispatcher, so this daemon runs no dispatch round (board.mjs). It is this process's alone: the sessions it
679
+ // launches do not inherit it. Any other value is refused before this daemon takes its lease or a lock.
680
+ const boardDispatcher = process.env.SUPERCODE_BOARD_DISPATCHER || null;
681
+ delete process.env.SUPERCODE_BOARD_DISPATCHER;
682
+ if (boardDispatcher !== null && boardDispatcher !== 'serve') {
683
+ say('error', { root, message: `SUPERCODE_BOARD_DISPATCHER is \`${boardDispatcher}\`: the one value it takes is \`serve\` (a \`workflow serve\` beside this daemon dispatches the board); unset, the board workflow's dispatch.serve decides` });
684
+ process.exit(2);
685
+ }
677
686
  let crashed = false;
678
687
  let gatewayLock = null;
679
688
  if (!args.once) {
@@ -746,11 +755,6 @@ async function main() {
746
755
  // binary that spawned this daemon, so the worker doors it opens are that
747
756
  // build's, not whatever `supercode` a PATH lookup happens to find.
748
757
  const client = new SupercodeHarnessClient(process.env.SUPERCODE_BIN ? { command: process.env.SUPERCODE_BIN } : {});
749
- // `SUPERCODE_BOARD_DISPATCHER=serve`: whoever started this daemon also runs `workflow serve` on the home, the board's
750
- // one dispatcher, so this daemon runs no dispatch round (board.mjs). It is this process's alone: the sessions it
751
- // launches do not inherit it.
752
- const boardDispatcher = process.env.SUPERCODE_BOARD_DISPATCHER || null;
753
- delete process.env.SUPERCODE_BOARD_DISPATCHER;
754
758
  const orchestrator = new Orchestrator({
755
759
  boardDispatcher,
756
760
  executeJob: args.executorUrl ? httpJobExecutor({ url: args.executorUrl, tokenFile: args.executorTokenFile, root }) : null,
package/board/cli.mjs CHANGED
@@ -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}`);
@@ -1444,6 +1444,8 @@ async function serve(root, args = {}) {
1444
1444
  const stuck = {};
1445
1445
  const warned = {};
1446
1446
  let pausedSaid = false;
1447
+ // a board whose cards name an assignee that is no profile of this home is said once, until it has none
1448
+ const laneSaid = new Set();
1447
1449
  let wakeReason = null;
1448
1450
  try {
1449
1451
  while (!stopped) {
@@ -1498,6 +1500,8 @@ async function serve(root, args = {}) {
1498
1500
  stuck[r.board] = r.ready_waiting && !r.started.length && !r.paused ? (stuck[r.board] || Date.now()) : 0;
1499
1501
  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(); }
1500
1502
  for (const [t, why] of r.queued) log(` ${t} waits: ${why}`);
1503
+ if (r.nonspawnable?.length && !laneSaid.has(r.board)) { laneSaid.add(r.board); log(`board ${r.board}: ${r.nonspawnable.length} card(s) name an assignee that is no profile of this home, and wait`); }
1504
+ if (!r.nonspawnable?.length) laneSaid.delete(r.board);
1501
1505
  for (const [t, why] of r.failed) log(` ${t} did not start: ${why}`);
1502
1506
  for (const [t, why] of r.unsettled ?? []) log(` ${t} could not be read: ${why}`);
1503
1507
  for (const [t, s, m] of r.started) log(` ${t} started ${s} (${m})`);
@@ -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
 
@@ -2064,8 +2075,73 @@ function ensureSubscriptions(board, entry) {
2064
2075
  /** The event kinds an audience hears (the workflow's notify). */
2065
2076
  const heardBy = (entry, audience) => Object.keys(entry.workflow.notify?.[audience] ?? {});
2066
2077
 
2078
+ /**
2079
+ * The board's record of each session address: `held`, every address an unended or current run works in, and `latest`,
2080
+ * the latest run that names it. A Claude or Codex session resumed by id keeps its address, so a run that resumes it
2081
+ * holds it again.
2082
+ */
2083
+ function sessionRecord(db) {
2084
+ const held = new Set(), latest = new Map();
2085
+ 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()) {
2086
+ const session = json(r.metadata, {})?.supercode, address = addressOf(session);
2087
+ if (!address) continue;
2088
+ if (!r.ended_at || r.current) held.add(address);
2089
+ latest.set(address, { run: r.id, task_id: r.task_id, session });
2090
+ }
2091
+ return { held, latest };
2092
+ }
2093
+
2094
+ /**
2095
+ * Whether the recipient's machine says, through its native discovery, that the session is not running (`persisted`:
2096
+ * its transcript is on disk and nothing runs it). Asked only after the delivery door answered that the session is no
2097
+ * longer running, once per machine and harness a round; null when the machine cannot answer.
2098
+ */
2099
+ function nativeEnded(address, discovered) {
2100
+ const m = String(address).match(/^sc:([^:]+):([^:]+):(.+)$/);
2101
+ if (!m) return null;
2102
+ const [, machine, harness, session_id] = m, key = `${normalMachine(machine)}\u0000${harness}`;
2103
+ if (!discovered.has(key)) { try { discovered.set(key, nativeSessions(machine, harness)); } catch { discovered.set(key, null); } }
2104
+ const rows = discovered.get(key);
2105
+ if (!rows) return null;
2106
+ return nativeReading(rows, { session_id, harness }).alive === false ? { by: 'native_discovery', presence: 'persisted', machine } : null;
2107
+ }
2108
+
2109
+ /**
2110
+ * The worker subscriptions of sessions that have ended, removed in one transaction, with one notice_unsubscribed event
2111
+ * per session naming its cards. A session has ended when no unended or current run works in it, the fleet does not list
2112
+ * it running, its latest run is not a release (a released session is a person's own, left running), and either the
2113
+ * board closed it (that run's confirmed close) or its machine's native discovery read it `persisted` this round
2114
+ * (`natively`). Everything is read inside the transaction, so a run that took the session meanwhile keeps it. A run
2115
+ * that resumes the session subscribes it again at its launch and each round while it is the card's current run.
2116
+ * A creator's or manager's subscription is never ended here: its notices wait for the session to come back.
2117
+ */
2118
+ function endWorkerSubscriptions(board, fleet, report, natively = new Map()) {
2119
+ tx(board, (db) => {
2120
+ const { held, latest } = sessionRecord(db);
2121
+ const remove = db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?");
2122
+ const ended = new Map();
2123
+ 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()) {
2124
+ const address = row.chat_id, record = latest.get(address);
2125
+ if (!record || record.session.released || held.has(address) || (fleet.ok && fleet.alive.has(address))) continue;
2126
+ const evidence = record.session.close_confirmed_at ? { by: 'board_close', run: record.run, closed_at: record.session.close_confirmed_at } : natively.get(address);
2127
+ if (!evidence || !remove.run(row.task_id, address, row.thread_id).changes) continue;
2128
+ if (!ended.has(address)) ended.set(address, { record, evidence, cards: [] });
2129
+ ended.get(address).cards.push(row.task_id);
2130
+ }
2131
+ for (const [address, { record, evidence, cards }] of ended) {
2132
+ event(db, record.task_id, 'notice_unsubscribed', { address, role: 'worker', reason: 'the session has ended', evidence, cards, count: cards.length }, record.run);
2133
+ for (const card of cards) (report.subscriptions_ended ??= []).push([card, address]);
2134
+ }
2135
+ });
2136
+ }
2137
+
2067
2138
  async function notify(board, fleet, report, entry) {
2068
2139
  const from = boardAddress(board.label);
2140
+ endWorkerSubscriptions(board, fleet, report);
2141
+ const { held, latest } = view(board, sessionRecord);
2142
+ // A recipient whose delivery failed this round is not asked again this round for its other cards: those are left for
2143
+ // the next round untried (a closed session subscribed to 21 subtasks was a delivery process for each, every round)
2144
+ const refused = new Map(), discovered = new Map(), natively = new Map();
2069
2145
  // each subscription's notices go in order; different subscribers are told side by side
2070
2146
  await pool(subscriptions(board.db).filter((sub) => sub.platform === 'supercode'), SEND_CONCURRENCY, async (sub) => {
2071
2147
  if (sub.delivery_metadata?.dependent_card && sub.delivery_metadata.waiting_recipient) return;
@@ -2085,7 +2161,7 @@ async function notify(board, fleet, report, entry) {
2085
2161
  // the one the next run gets): the notice is filed in its mailbox, read when it next works
2086
2162
  const working = role !== 'worker' || view(board, (db) => addressOf(currentRun(db, sub.task_id)?.metadata?.supercode) === sub.chat_id);
2087
2163
  let cursor = Number(sub.last_event_id);
2088
- let failed = null;
2164
+ let failed = null, untried = false;
2089
2165
  try {
2090
2166
  // an event older than the workflow's notice_max_age_seconds is history, not news: it is passed over, never mailed
2091
2167
  const maxAge = Number(entry.params.notice_max_age_seconds) || null;
@@ -2113,10 +2189,17 @@ async function notify(board, fleet, report, entry) {
2113
2189
  } else text = noticeOf(entry, role, ev.kind, task, p);
2114
2190
  if (text) {
2115
2191
  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');
2192
+ if (refused.has(sub.chat_id)) { untried = true; break; }
2193
+ let receipt;
2194
+ try {
2195
+ 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}` });
2196
+ // A notice the recipient's mailbox took is filed: the machine delivers it at the session's next turn, so the
2197
+ // board does not send it again.
2198
+ if (!receipt.delivered && !receipt.queued) throw new Error(receipt.reason ?? 'notice was not filed');
2199
+ } catch (error) {
2200
+ if (!refused.has(sub.chat_id)) refused.set(sub.chat_id, error);
2201
+ throw error;
2202
+ }
2120
2203
  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
2204
  report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
2122
2205
  // the cursor follows each notice sent, so a restart mid-backlog resumes after it
@@ -2125,6 +2208,14 @@ async function notify(board, fleet, report, entry) {
2125
2208
  cursor = ev.id;
2126
2209
  }
2127
2210
  } catch (error) {
2211
+ // One "no longer running" answer is not an ended session (a Claude session is resumed under the same id): for a
2212
+ // worker no run holds, and that is not a released session, the machine's native discovery is asked, and a
2213
+ // `persisted` reading ends its worker subscriptions after this round's deliveries.
2214
+ const record = latest.get(sub.chat_id);
2215
+ if (error.notRunning && role === 'worker' && record && !record.session.released && !held.has(sub.chat_id)) {
2216
+ if (!natively.has(sub.chat_id)) natively.set(sub.chat_id, nativeEnded(sub.chat_id, discovered));
2217
+ if (natively.get(sub.chat_id)) return;
2218
+ }
2128
2219
  failed = String(error.message).slice(-200);
2129
2220
  tx(board, (db) => {
2130
2221
  const kind = 'notice_failed';
@@ -2140,12 +2231,16 @@ async function notify(board, fleet, report, entry) {
2140
2231
  const where = "WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?";
2141
2232
  const identity = [sub.task_id, sub.chat_id, sub.thread_id ?? ''];
2142
2233
  db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND last_event_id < ?`).run(cursor, ...identity, cursor);
2234
+ // a round that left this subscription untried (a delivery to its recipient failed on another card) says nothing of it
2235
+ if (untried) return;
2143
2236
  const failures = failed ? Number(sub.delivery_metadata?.failures ?? 0) + 1 : 0;
2144
2237
  if (failures >= MAX_SEND_FAILURES) { db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity); return; }
2145
2238
  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
2239
  if (task?.status === 'archived' && cursor >= events.at(-1).id) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity);
2147
2240
  });
2148
2241
  });
2242
+ const persisted = new Map([...natively].filter(([, reading]) => reading));
2243
+ if (persisted.size) endWorkerSubscriptions(board, fleet, report, persisted);
2149
2244
  }
2150
2245
 
2151
2246
  // Hermes's terminal kinds for a chat subscriber, in its order of silence: archived and unblocked move the cursor and say nothing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.33",
3
+ "version": "0.5.35",
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": {