@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 +9 -5
- package/board/cli.mjs +5 -1
- package/board/dispatch.mjs +104 -9
- package/package.json +1 -1
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})`);
|
package/board/dispatch.mjs
CHANGED
|
@@ -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
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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)
|
|
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
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
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.
|
|
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": {
|