@volter/supercode-orchestrator 0.4.27 → 0.4.29
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 +5 -1
- package/board/dispatch.mjs +76 -36
- package/package.json +1 -1
package/board/cli.mjs
CHANGED
|
@@ -1020,6 +1020,7 @@ async function serve(root, args = {}) {
|
|
|
1020
1020
|
while (!stopped) {
|
|
1021
1021
|
pausedSaid = Boolean(paused(root)) && pausedSaid;
|
|
1022
1022
|
let interval = 60;
|
|
1023
|
+
const began = Date.now();
|
|
1023
1024
|
try {
|
|
1024
1025
|
const state = await laneState(root);
|
|
1025
1026
|
const settings = { ...state.settings, ...override };
|
|
@@ -1027,6 +1028,7 @@ async function serve(root, args = {}) {
|
|
|
1027
1028
|
for (const r of await tick({ root, lanes: state.lanes, settings })) {
|
|
1028
1029
|
const said = summary(r);
|
|
1029
1030
|
if (!said.endsWith('nothing to do') || args.verbose) log(said);
|
|
1031
|
+
if (args.verbose && r.ms) log(` took ${Object.entries(r.ms).map(([k, v]) => `${k} ${(v / 1000).toFixed(1)}s`).join(', ')}`);
|
|
1030
1032
|
if (r.paused && !pausedSaid) { log(`new starts paused by ${r.paused} (\`supercode workflow resume\` lifts it)`); pausedSaid = true; }
|
|
1031
1033
|
// Hermes's stuck warning: ready work for six ticks running and nothing started
|
|
1032
1034
|
stuck[r.board] = r.ready_waiting && !r.started.length && !r.paused ? (stuck[r.board] ?? 0) + 1 : 0;
|
|
@@ -1038,7 +1040,9 @@ async function serve(root, args = {}) {
|
|
|
1038
1040
|
} catch (error) {
|
|
1039
1041
|
log(`tick failed: ${error.message}`);
|
|
1040
1042
|
}
|
|
1041
|
-
|
|
1043
|
+
// the next tick is due an interval after this one began (a long tick is not followed by a full interval's wait)
|
|
1044
|
+
const rest = Math.max(1, interval - Math.floor((Date.now() - began) / 1000));
|
|
1045
|
+
for (let waited = 0; waited < rest && !stopped; waited += 1) await new Promise((done) => setTimeout(done, 1000));
|
|
1042
1046
|
}
|
|
1043
1047
|
} finally {
|
|
1044
1048
|
lock.release();
|
package/board/dispatch.mjs
CHANGED
|
@@ -475,7 +475,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null } = {}) {
|
|
|
475
475
|
if (prev && prev.machine === machine) {
|
|
476
476
|
if (sessionAlive(fleet, prev)) {
|
|
477
477
|
// the session is kept; the heartbeat's count belongs to the run that ended, so the new run starts it again
|
|
478
|
-
const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n,
|
|
478
|
+
const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, ...kept } = prev;
|
|
479
479
|
session = { ...kept, mode: 'adopt', launched_at: now(), seen_at: now() };
|
|
480
480
|
} else if (prev.resumable !== false) {
|
|
481
481
|
const r = sc([...base, '--key', `board-${task.id}-r${runId}-resume`, '--input',
|
|
@@ -677,14 +677,16 @@ function notice(kind, task, p, role) {
|
|
|
677
677
|
}
|
|
678
678
|
}
|
|
679
679
|
|
|
680
|
-
function send(from, label, to, body, key, { queue = false } = {}) {
|
|
680
|
+
async function send(from, label, to, body, key, { queue = false } = {}) {
|
|
681
681
|
const request = {
|
|
682
682
|
op: 'send', from, from_name: `${label} board`, to, queue, id: key,
|
|
683
683
|
body: `[Board notice from the ${label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)`,
|
|
684
684
|
};
|
|
685
|
-
|
|
685
|
+
// a delivery to a busy session can take a minute on a loaded machine; what it answered is the verdict (a process
|
|
686
|
+
// killed after it answered `code: 0` delivered)
|
|
687
|
+
const r = await scAsync(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 180_000 });
|
|
686
688
|
const answer = lastJson(r.stdout) ?? {};
|
|
687
|
-
if (
|
|
689
|
+
if (answer.code !== 0) throw new Error((answer.text || r.stderr || `no answer (exit ${r.code})`).trim().slice(-300));
|
|
688
690
|
}
|
|
689
691
|
|
|
690
692
|
/** The board's own address: where its notices come from and where card updates are mailed. */
|
|
@@ -693,20 +695,20 @@ export const boardAddress = (label) => `sc:${localMachine()}:board:${label.repla
|
|
|
693
695
|
/** Consecutive failed sends after which a subscription is dropped (Hermes's notifier drops a dead chat after 12). */
|
|
694
696
|
const MAX_SEND_FAILURES = 12;
|
|
695
697
|
|
|
696
|
-
function notify(board, fleet, report) {
|
|
698
|
+
async function notify(board, fleet, report) {
|
|
697
699
|
const from = boardAddress(board.label);
|
|
698
|
-
|
|
699
|
-
|
|
700
|
+
// each subscription's notices go in order; different subscribers are told side by side
|
|
701
|
+
await pool(subscriptions(board.db).filter((sub) => sub.platform === 'supercode'), SEND_CONCURRENCY, async (sub) => {
|
|
700
702
|
const role = ['worker', 'dependency'].includes(sub.delivery_metadata?.role) ? sub.delivery_metadata.role : 'creator';
|
|
701
703
|
// a session that worked or watched a card and is gone hears nothing more; a creator is retried until it is back
|
|
702
704
|
if (role !== 'creator' && fleet.ok && !fleet.alive.has(sub.chat_id)) {
|
|
703
705
|
tx(board, (db) => db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?").run(sub.task_id, sub.chat_id));
|
|
704
|
-
|
|
706
|
+
return;
|
|
705
707
|
}
|
|
706
708
|
const kinds = MAIL_KINDS[role];
|
|
707
709
|
const events = board.db.prepare(`SELECT id, kind, payload, created_at FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id`)
|
|
708
710
|
.all(sub.task_id, sub.last_event_id, ...kinds);
|
|
709
|
-
if (!events.length)
|
|
711
|
+
if (!events.length) return;
|
|
710
712
|
const task = tx(board, (db) => getTask(db, sub.task_id));
|
|
711
713
|
let cursor = Number(sub.last_event_id);
|
|
712
714
|
let failed = null;
|
|
@@ -721,7 +723,7 @@ function notify(board, fleet, report) {
|
|
|
721
723
|
} else text = notice(ev.kind, task, p, role);
|
|
722
724
|
if (text) {
|
|
723
725
|
// Hermes's delivery modes: `notify` is passive (read at the session's next turn), `wake`/`notify+wake` start its turn
|
|
724
|
-
send(from, board.label, sub.chat_id, text, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { queue: sub.delivery_mode === 'notify' });
|
|
726
|
+
await send(from, board.label, sub.chat_id, text, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { queue: sub.delivery_mode === 'notify' });
|
|
725
727
|
report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
|
|
726
728
|
}
|
|
727
729
|
cursor = ev.id;
|
|
@@ -739,7 +741,7 @@ function notify(board, fleet, report) {
|
|
|
739
741
|
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 }), sub.task_id, sub.chat_id);
|
|
740
742
|
if (task?.status === 'archived' && cursor >= events.at(-1).id) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(sub.task_id, sub.chat_id);
|
|
741
743
|
});
|
|
742
|
-
}
|
|
744
|
+
});
|
|
743
745
|
}
|
|
744
746
|
|
|
745
747
|
// Hermes's terminal kinds for a chat subscriber, in its order of silence: archived and unblocked move the cursor and say nothing
|
|
@@ -905,7 +907,7 @@ function readMailbox(board, address, report) {
|
|
|
905
907
|
answer = `${clean.slice(0, 2).join(' ')}: ${(r.status === 0 ? r.stdout : r.stderr || r.stdout).trim().slice(-600) || `exit ${r.status}`}`;
|
|
906
908
|
}
|
|
907
909
|
report.mailed.push([envelope.from, argv?.[0] ?? '?']);
|
|
908
|
-
|
|
910
|
+
report.answers.push(send(address, board.label, envelope.from, answer, `board-answer-${envelope.id}`).catch((error) => { report.undelivered.push(['mail', envelope.from, String(error.message).slice(-200)]); }));
|
|
909
911
|
}
|
|
910
912
|
}
|
|
911
913
|
|
|
@@ -915,14 +917,17 @@ function readMailbox(board, address, report) {
|
|
|
915
917
|
* The board's heartbeat for a pane session: a running card's session never sits idle without deciding. Each tick, a
|
|
916
918
|
* session that is idle while its card is still running is asked to decide: `request-review` when the work is done,
|
|
917
919
|
* `block` with its kind when it cannot go on (waiting on a person, a capability, another card), or keep working. A
|
|
918
|
-
* goal card (Hermes's goal loop) is told to keep going.
|
|
919
|
-
*
|
|
920
|
+
* goal card (Hermes's goal loop) is told to keep going. Idle is judged by the session's last message time (supercode
|
|
921
|
+
* reads it from the transcript), never by its status label: a session is asked once its last message is a whole tick
|
|
922
|
+
* old; one whose messages ran on a whole tick past the last ask answered it by working, and its count starts again
|
|
923
|
+
* (a reply to the ask alone is not work). After the turn budget (`goal_max_turns`, else
|
|
920
924
|
* `kanban.goal_max_turns`) of asks in a row with no work and no decision (a goal card: of turns), the card is blocked `needs_input` and its creator is told.
|
|
921
925
|
* Whether the work is done is the session's call through its card, not an auxiliary judge's.
|
|
922
926
|
*/
|
|
923
|
-
function pursueGoals(board, fleet, settings, report) {
|
|
927
|
+
async function pursueGoals(board, fleet, settings, report) {
|
|
924
928
|
if (!fleet.ok) return;
|
|
925
929
|
const cards = board.db.prepare("SELECT id FROM tasks WHERE status = 'running'").all();
|
|
930
|
+
const nudges = [];
|
|
926
931
|
for (const { id } of cards) {
|
|
927
932
|
const nudge = tx(board, (db) => {
|
|
928
933
|
const task = getTask(db, id);
|
|
@@ -930,23 +935,22 @@ function pursueGoals(board, fleet, settings, report) {
|
|
|
930
935
|
const session = runRow?.metadata?.supercode;
|
|
931
936
|
if (!session?.session_id || session.surface !== 'pane') return null;
|
|
932
937
|
const live = fleet.sessions.find((x) => x.address?.endsWith(`:${session.session_id}`));
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
return null;
|
|
939
|
-
}
|
|
940
|
-
if (!session.idle_since) { setRunSession(db, runRow.id, { ...session, idle_since: now() }); return null; }
|
|
941
|
-
if (now() - session.idle_since < settings.interval) return null;
|
|
938
|
+
// a supercode that reports no last message time gives no reading, and no reading gives no verdict
|
|
939
|
+
const lastAt = Math.floor(Number(live.last_message_at_ms) / 1000);
|
|
940
|
+
if (!(lastAt > 0)) return null;
|
|
941
|
+
// a message within the last tick is a session working or between turns, not idle
|
|
942
|
+
if (now() - lastAt < settings.interval) return null;
|
|
942
943
|
if (session.goal_nudged_at && now() - session.goal_nudged_at < settings.interval * 2) return null;
|
|
943
|
-
|
|
944
|
+
// messages that ran on a whole tick past the last ask answered it by working: the count starts again (a goal
|
|
945
|
+
// card keeps Hermes's budget of turns)
|
|
946
|
+
const worked = Boolean(session.goal_nudged_at) && lastAt >= session.goal_nudged_at + settings.interval;
|
|
947
|
+
const turns = worked && !task.goal_mode ? 0 : Number(session.goal_turns ?? 0);
|
|
944
948
|
const budget = task.goal_max_turns ?? settings.goalMaxTurns;
|
|
945
949
|
if (turns >= budget) {
|
|
946
950
|
blockIn(db, id, { reason: `its session went idle ${budget} times without deciding done or blocked; review it`, kind: 'needs_input' });
|
|
947
951
|
return null;
|
|
948
952
|
}
|
|
949
|
-
setRunSession(db, runRow.id, { ...session, goal_turns: turns + 1, goal_nudged_at: now()
|
|
953
|
+
setRunSession(db, runRow.id, { ...session, goal_turns: turns + 1, goal_nudged_at: now() });
|
|
950
954
|
event(db, id, task.goal_mode ? 'goal_continued' : 'decision_asked', { turn: turns + 1, budget }, runRow.id);
|
|
951
955
|
const local = normalMachine(task.machine) === localMachine();
|
|
952
956
|
const where = local ? `--root ${board.root}${board.slug !== 'default' ? ` --board ${board.slug}` : ''}` : `--at ${boardAddress(board.label)}`;
|
|
@@ -957,11 +961,12 @@ function pursueGoals(board, fleet, settings, report) {
|
|
|
957
961
|
+ `A session that stays idle without deciding has its card blocked for its creator after ${budget}.`;
|
|
958
962
|
return { address: live.address, text, kind: task.goal_mode ? 'goal_continued' : 'decision_asked' };
|
|
959
963
|
});
|
|
960
|
-
if (nudge) {
|
|
961
|
-
try { send(boardAddress(board.label), board.label, nudge.address, nudge.text, `board-goal-${id}-${now()}`); report.notified.push([id, nudge.kind, nudge.address]); }
|
|
962
|
-
catch (error) { report.undelivered.push([id, nudge.address, String(error.message).slice(-200)]); }
|
|
963
|
-
}
|
|
964
|
+
if (nudge) nudges.push({ id, ...nudge });
|
|
964
965
|
}
|
|
966
|
+
await pool(nudges, SEND_CONCURRENCY, async (nudge) => {
|
|
967
|
+
try { await send(boardAddress(board.label), board.label, nudge.address, nudge.text, `board-goal-${nudge.id}-${now()}`); report.notified.push([nudge.id, nudge.kind, nudge.address]); }
|
|
968
|
+
catch (error) { report.undelivered.push([nudge.id, nudge.address, String(error.message).slice(-200)]); }
|
|
969
|
+
});
|
|
965
970
|
}
|
|
966
971
|
|
|
967
972
|
/** Hermes's auto-decompose: triage cards with a pane lane get a session that splits them, a few per tick. */
|
|
@@ -990,6 +995,32 @@ export function boardLabel(root, slug) {
|
|
|
990
995
|
return slug && slug !== 'default' ? `${home}/${slug}` : home;
|
|
991
996
|
}
|
|
992
997
|
|
|
998
|
+
/** `sc` without blocking the tick: notices to many sessions go out side by side. */
|
|
999
|
+
function scAsync(args, { input = null, timeout = 120_000 } = {}) {
|
|
1000
|
+
return new Promise((done) => {
|
|
1001
|
+
const child = spawnChild(supercodeBin(), args, { cwd: tmpdir(), env: process.env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
1002
|
+
let stdout = '';
|
|
1003
|
+
let stderr = '';
|
|
1004
|
+
const timer = setTimeout(() => { stderr += ` timed out after ${timeout}ms`; child.kill('SIGKILL'); }, timeout);
|
|
1005
|
+
child.stdout.setEncoding('utf8').on('data', (c) => { stdout += c; });
|
|
1006
|
+
child.stderr.setEncoding('utf8').on('data', (c) => { stderr += c; });
|
|
1007
|
+
child.on('error', (error) => { clearTimeout(timer); done({ code: null, stdout, stderr: stderr + error.message }); });
|
|
1008
|
+
child.on('close', (code) => { clearTimeout(timer); done({ code, stdout, stderr }); });
|
|
1009
|
+
child.stdin.end(input ?? '');
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// how many deliveries a tick has in flight at once (each is a `supercode message` process of a few seconds)
|
|
1014
|
+
const SEND_CONCURRENCY = 8;
|
|
1015
|
+
|
|
1016
|
+
/** Run `fn` over `items`, at most `n` at a time. */
|
|
1017
|
+
async function pool(items, n, fn) {
|
|
1018
|
+
let next = 0;
|
|
1019
|
+
await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
|
|
1020
|
+
while (next < items.length) { const i = next; next += 1; await fn(items[i]); }
|
|
1021
|
+
}));
|
|
1022
|
+
}
|
|
1023
|
+
|
|
993
1024
|
/**
|
|
994
1025
|
* One tick over every board of the home at `root`. `lanes` from `lanesOf`, `settings` from `boardSettings`.
|
|
995
1026
|
* `only` limits starting to one card (a create that answers whether its card started). Answers one report per board.
|
|
@@ -1005,7 +1036,9 @@ const CORRUPT = /malformed|not a database|corrupt|disk image/i;
|
|
|
1005
1036
|
*/
|
|
1006
1037
|
export async function tick({ root, lanes, settings, only = null, slugs = null, dispatch = true }) {
|
|
1007
1038
|
const needFleet = Object.values(lanes).some((l) => l.surface === 'pane');
|
|
1039
|
+
const fleetAt = Date.now();
|
|
1008
1040
|
const fleet = needFleet ? observeFleet() : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
|
|
1041
|
+
const fleetMs = Date.now() - fleetAt;
|
|
1009
1042
|
const boards = slugs ?? listBoards(root);
|
|
1010
1043
|
// the running cards of every board of the home, for kanban.max_in_progress (host-wide, as Hermes counts it)
|
|
1011
1044
|
let hostRunning = 0;
|
|
@@ -1014,7 +1047,9 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
1014
1047
|
}
|
|
1015
1048
|
const out = [];
|
|
1016
1049
|
for (const slug of boards) {
|
|
1017
|
-
const report = { board: slug, mailed: [], started: [], queued: [], failed: [], lost: [], crashed: [], timed_out: [], reclaimed: [], auto_blocked: [], promoted: 0, unassigned: [], nonspawnable: [], notified: [], undelivered: [], fleet: fleet.ok ? 'read' : fleet.error };
|
|
1050
|
+
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 };
|
|
1051
|
+
// the mailbox's answers in flight (not part of the report the caller prints)
|
|
1052
|
+
Object.defineProperty(report, 'answers', { value: [], enumerable: false });
|
|
1018
1053
|
const path = boardPath(root, slug);
|
|
1019
1054
|
const marker = `${path}.corrupt-seen`;
|
|
1020
1055
|
if (existsSync(marker) && readFileSync(marker, 'utf8').trim() === fingerprint(path)) { report.error = 'skipped: the board file was corrupt and has not changed since (`supercode workflow repair`)'; out.push(report); continue; }
|
|
@@ -1024,14 +1059,18 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
1024
1059
|
try {
|
|
1025
1060
|
board = openBoard(root, slug);
|
|
1026
1061
|
board.label = boardLabel(root, slug);
|
|
1027
|
-
|
|
1028
|
-
|
|
1062
|
+
// where the tick's time goes, phase by phase (ms), printed by `serve --verbose` and in `--json`
|
|
1063
|
+
let mark = Date.now();
|
|
1064
|
+
const phase = (name) => { const t = Date.now(); report.ms[name] = t - mark; mark = t; };
|
|
1065
|
+
readMailbox(board, boardAddress(board.label), report); phase('mailbox');
|
|
1066
|
+
supervise(board, fleet, settings, report); phase('supervise');
|
|
1029
1067
|
report.promoted = recomputeReady(board, settings.failureLimit);
|
|
1030
1068
|
const before = report.started.length;
|
|
1031
1069
|
if (dispatch) start(board, fleet, lanes, settings, report, { only, hostRunning });
|
|
1032
|
-
hostRunning += report.started.length - before;
|
|
1033
|
-
if (dispatch && !only && !report.paused) { pursueGoals(board, fleet, settings, report); autoDecompose(board, lanes, settings, report); }
|
|
1034
|
-
|
|
1070
|
+
hostRunning += report.started.length - before; phase('start');
|
|
1071
|
+
if (dispatch && !only && !report.paused) { await pursueGoals(board, fleet, settings, report); autoDecompose(board, lanes, settings, report); }
|
|
1072
|
+
phase('goals');
|
|
1073
|
+
await notify(board, fleet, report); phase('notify');
|
|
1035
1074
|
fireHooks(board, settings, report);
|
|
1036
1075
|
if (String(board.db.prepare('PRAGMA journal_mode').get()?.journal_mode).toLowerCase() === 'wal') board.db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get();
|
|
1037
1076
|
report.ready_waiting = Number(board.db.prepare("SELECT COUNT(*) AS n FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.status = 'ready' AND t.assignee IS NOT NULL AND c.subtask_of IS NULL").get().n);
|
|
@@ -1042,6 +1081,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
1042
1081
|
report.error = error.message;
|
|
1043
1082
|
if (CORRUPT.test(error.message)) writeFileSync(marker, fingerprint(path));
|
|
1044
1083
|
} finally {
|
|
1084
|
+
await Promise.all(report.answers);
|
|
1045
1085
|
board?.close();
|
|
1046
1086
|
lock.release();
|
|
1047
1087
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.29",
|
|
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": {
|