@volter/supercode-orchestrator 0.5.24 → 0.5.26
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 +30 -9
- package/board/dispatch.mjs +21 -7
- package/board/event-fleet.mjs +13 -6
- package/board/store.mjs +10 -3
- package/package.json +3 -3
package/board/cli.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { taskStatusOf } from './published.mjs';
|
|
8
8
|
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { recordSlow, takeSteps } from '@volter/supercode-harness-sdk/slow-log';
|
|
10
11
|
import { basename, join, resolve } from 'node:path';
|
|
11
12
|
import { homedir, tmpdir, userInfo } from 'node:os';
|
|
12
13
|
import { read } from '../orchestration.mjs';
|
|
@@ -298,13 +299,13 @@ const metadataOf = (v) => {
|
|
|
298
299
|
};
|
|
299
300
|
|
|
300
301
|
/**
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
* from the registry at most once an hour.
|
|
302
|
+
* What is wrong with this orchestrator's version, or null: a build no release names (a hand install of a branch) or one
|
|
303
|
+
* older than npm's latest. Every workflow call says it and the dispatcher tells its managers; action is required until
|
|
304
|
+
* the release is installed. The latest is read from the registry at most once an hour.
|
|
304
305
|
*/
|
|
305
|
-
function
|
|
306
|
+
function releaseBehind() {
|
|
306
307
|
let running;
|
|
307
|
-
try { running = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; } catch { return; }
|
|
308
|
+
try { running = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; } catch { return null; }
|
|
308
309
|
const cache = join(tmpdir(), 'supercode-orchestrator-latest.json');
|
|
309
310
|
let latest = null;
|
|
310
311
|
let fresh = false;
|
|
@@ -315,13 +316,18 @@ function sayReleaseBehind() {
|
|
|
315
316
|
latest = r.status === 0 ? r.stdout.trim() || null : null;
|
|
316
317
|
try { writeFileSync(cache, JSON.stringify({ at: Date.now(), latest })); } catch { /* read again next call */ }
|
|
317
318
|
}
|
|
318
|
-
if (!latest || running === latest) return;
|
|
319
|
+
if (!latest || running === latest) return null;
|
|
319
320
|
const parts = (v) => v.split('-')[0].split('.').map(Number);
|
|
320
321
|
const [a, b] = [parts(running), parts(latest)];
|
|
321
322
|
const older = a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
322
323
|
const fix = `npm install -g @volter/supercode-orchestrator@${latest} (and each install's pinned copy)`;
|
|
323
|
-
if (running.includes('-'))
|
|
324
|
-
|
|
324
|
+
if (running.includes('-')) return { key: `${running}>${latest}`, text: `orchestrator ${running} is not a published release (latest ${latest}); action required: ${fix}` };
|
|
325
|
+
if (older < 0) return { key: `${running}>${latest}`, text: `orchestrator ${running} is behind the published ${latest}; action required: ${fix}` };
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
function sayReleaseBehind() {
|
|
329
|
+
const behind = releaseBehind();
|
|
330
|
+
if (behind) process.stderr.write(`board: error: ${behind.text}\n`);
|
|
325
331
|
}
|
|
326
332
|
|
|
327
333
|
export async function runWorkflow(argv) {
|
|
@@ -1394,7 +1400,17 @@ async function serve(root, args = {}) {
|
|
|
1394
1400
|
const FAILED_ROUNDS_ALERT = 5;
|
|
1395
1401
|
const failing = new Map();
|
|
1396
1402
|
let round = 0;
|
|
1403
|
+
// A dispatcher that is not the published release is told up the chain, once per version pair: it keeps running, and
|
|
1404
|
+
// its manager hears that action is required (a caller's stderr line alone reached no one).
|
|
1405
|
+
const versionsTold = new Set();
|
|
1406
|
+
const tellReleaseBehind = async () => {
|
|
1407
|
+
const behind = releaseBehind();
|
|
1408
|
+
if (!behind || versionsTold.has(behind.key)) return;
|
|
1409
|
+
// the send's key makes a retry safe; a pair counts as told only once a manager got it
|
|
1410
|
+
if (await alertManagers(root, 'default', `The dispatcher of ${root} runs ${behind.text}`, `release-behind-${behind.key}`) > 0) versionsTold.add(behind.key);
|
|
1411
|
+
};
|
|
1397
1412
|
const watchRounds = async (reports) => {
|
|
1413
|
+
await tellReleaseBehind().catch(() => {});
|
|
1398
1414
|
if (reports.every((r) => !r.error)) {
|
|
1399
1415
|
const temporary = `${heartbeat}.${process.pid}.tmp`;
|
|
1400
1416
|
try {
|
|
@@ -1402,7 +1418,10 @@ async function serve(root, args = {}) {
|
|
|
1402
1418
|
renameSync(temporary, heartbeat);
|
|
1403
1419
|
} catch { /* the next round writes it */ }
|
|
1404
1420
|
}
|
|
1405
|
-
|
|
1421
|
+
// A whole tick that failed is counted as board '*'; a round in which every board succeeded clears it too, so its
|
|
1422
|
+
// recovery is said and a later failure starts a count of its own.
|
|
1423
|
+
const settled = reports.every((r) => !r.error) && failing.has('*') ? [...reports, { board: '*' }] : reports;
|
|
1424
|
+
for (const r of settled) {
|
|
1406
1425
|
const run = failing.get(r.board) ?? { count: 0, since: Date.now(), told: 0 };
|
|
1407
1426
|
if (!r.error) {
|
|
1408
1427
|
if (run.count >= FAILED_ROUNDS_ALERT) await alertManagers(root, r.board === '*' ? 'default' : r.board, `The dispatcher's rounds on board ${r.board} succeed again after ${run.count} failed rounds since ${new Date(run.since).toISOString()}.`, `dispatcher-recovered-${r.board}-${run.since}`);
|
|
@@ -1488,6 +1507,8 @@ async function serve(root, args = {}) {
|
|
|
1488
1507
|
log(`tick failed: ${error.message}`);
|
|
1489
1508
|
await watchRounds([{ board: '*', error: `tick failed: ${error.message}` }]);
|
|
1490
1509
|
}
|
|
1510
|
+
// each round is a line of its own in the slow log, with the steps it spent its time on
|
|
1511
|
+
recordSlow({ kind: 'call', name: 'dispatch round', ms: Date.now() - began, slowest: null, extra: { steps: takeSteps() } });
|
|
1491
1512
|
// a newer install is looked for after each tick and through the wait before the next
|
|
1492
1513
|
const newer = () => { const now = installed(); return running && now && now !== running ? now : null; };
|
|
1493
1514
|
let moved = newer();
|
package/board/dispatch.mjs
CHANGED
|
@@ -1074,11 +1074,22 @@ export async function mergeReviewed(board, e) {
|
|
|
1074
1074
|
return true;
|
|
1075
1075
|
}
|
|
1076
1076
|
const entry = await loadWorkflow(board.root);
|
|
1077
|
-
|
|
1077
|
+
// A fault other than the card's refusal keeps the effect for the next round, and the round goes on past it.
|
|
1078
|
+
try { tx(board, (db) => {
|
|
1078
1079
|
event(db, e.id, 'reviewed_merged', { merges });
|
|
1079
|
-
|
|
1080
|
+
// A card that moved after its pass cannot take the event: that is said to the manager once, never thrown, so the
|
|
1081
|
+
// kept effect does not stop every round after it (as merge_refused, above).
|
|
1082
|
+
let r;
|
|
1083
|
+
try { r = fire(db, board, entry, e.id, 'reviewed_merged', { roles: ['platform'], payload: { merges, summary: merges.length ? `Merged at the reviewed heads: ${merges.map((m) => `${m.pr} (${m.reviewed.slice(0, 8)} → ${String(m.merge).slice(0, 8)})`).join(', ')}` : 'The pass named no PR to merge.' } }); }
|
|
1084
|
+
catch (error) {
|
|
1085
|
+
// only the card's refusal is said and consumed; anything else (a write contention, a fault) is thrown and retried
|
|
1086
|
+
if (!error.noTransition && !error.refusal && !/does not apply|there is for/.test(error.message)) throw error;
|
|
1087
|
+
const to = [].concat(entry.params.managers ?? []).find(Boolean);
|
|
1088
|
+
if (to) event(db, e.id, 'board_message', { to, text: `${e.id}: its reviewed PRs are merged (${merges.map((m) => m.pr).join(', ') || 'none'}), but the card could not take reviewed_merged: ${String(error.message).slice(0, 300)}` }, null);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1080
1091
|
for (const eff of r.effects) db.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(eff));
|
|
1081
|
-
});
|
|
1092
|
+
}); } catch (error) { process.stderr.write(`board: ${e.id}: reviewed_merged kept for the next round: ${String(error.message).slice(0, 300)}\n`); return false; }
|
|
1082
1093
|
return true;
|
|
1083
1094
|
}
|
|
1084
1095
|
|
|
@@ -1304,11 +1315,14 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes
|
|
|
1304
1315
|
try{synchronizeAgentRun(board.root,org,row,{context,intended:json(launch?.metadata,{})?.supercode?.agent_profile_intent});}
|
|
1305
1316
|
catch(error){if(error.code==='rate_limited'){report.queued.push([row.run_id,`agent session sync pending: ${error.message}`]);break;}const gone=error.claimRefused&&error.status===404&&fleet.ok&&sessionAlive(fleet,{address:`sc:${row.machine||localMachine()}:${row.session_ref}`,machine:row.machine||localMachine()})===false;if(gone)abandonAgentRun(board.root,org,row.session_ref);report.failed.push([row.run_id,`agent session sync failed: ${error.message}${gone?`; ${row.session_ref} is stopped and Teams refuses it, so it leaves the sync`:''}`]);}
|
|
1306
1317
|
}
|
|
1307
|
-
const deferred=view(board,db=>db.prepare("SELECT t.id,r.started_at FROM tasks t JOIN task_runs r ON r.id=t.current_run_id WHERE r.ended_at IS NULL AND json_extract(r.metadata,'$.supercode.launch_deferred')=1").all());
|
|
1318
|
+
const deferred=view(board,db=>db.prepare("SELECT t.id,r.id run_id,COALESCE(json_extract(r.metadata,'$.supercode.launched_at'),r.started_at) launched_at FROM tasks t JOIN task_runs r ON r.id=t.current_run_id WHERE r.ended_at IS NULL AND json_extract(r.metadata,'$.supercode.launch_deferred')=1").all());
|
|
1308
1319
|
// A deferred launch was never issued (a reading or door was not yet available): it is the start its manager, creator
|
|
1309
|
-
// or workflow asked for, carried out within the launch window; supervise surfaces it once when the window ends
|
|
1310
|
-
|
|
1311
|
-
|
|
1320
|
+
// or workflow asked for, carried out within the launch window; supervise surfaces it once when the window ends. The
|
|
1321
|
+
// window counts only time the fleet could be read: a launch waiting on an unreadable fleet was never tried, so its
|
|
1322
|
+
// clock moves with the wait instead of running out (every resumed card failed so in the 22:17Z event-stream outage).
|
|
1323
|
+
for(const {id,run_id,launched_at} of deferred) {
|
|
1324
|
+
if (now() - launched_at >= settings.launchWindow) continue;
|
|
1325
|
+
if (!fleet.ok) { tx(board,db=>setRunSession(db,run_id,{launched_at:now()})); report.queued.push([id,`launch waits for the fleet: ${fleet.error}`]); continue; }
|
|
1312
1326
|
try {
|
|
1313
1327
|
const task=getTaskNow(board,id),status=view(board,db=>statusOf(db,task)),spec=runSpec(entry,status,task);
|
|
1314
1328
|
const profile=spec&&view(board,db=>evaluate(entry.workflow.roles?.[spec.role]??'card.assignee',scopeOf(db,board,task,{params:entry.params})));
|
package/board/event-fleet.mjs
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { eventClient } from '@volter/teams/event-client';
|
|
5
|
+
import { eventClient, retryAfterMs } from '@volter/teams/event-client';
|
|
6
6
|
const pathOf=root=>join(root,'.session-events.json');
|
|
7
7
|
const normalize=value=>String(value??'').toLowerCase().replace(/[^a-z0-9-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'');
|
|
8
|
-
const wait=signal=>new Promise(resolve=>{const timer=setTimeout(resolve,
|
|
8
|
+
const wait=(signal,ms)=>new Promise(resolve=>{const timer=setTimeout(resolve,ms);signal.addEventListener('abort',()=>{clearTimeout(timer);resolve();},{once:true});});
|
|
9
9
|
|
|
10
10
|
export function followFleet({root,context,supercodeHome,wake,log}){
|
|
11
11
|
const abort=new AbortController();let resources={},cursor=null,connected=false,snapshot=null;
|
|
@@ -20,20 +20,27 @@ export function followFleet({root,context,supercodeHome,wake,log}){
|
|
|
20
20
|
target[key]={sequence:frame.sequence,data:event.data};return true;
|
|
21
21
|
};
|
|
22
22
|
void(async()=>{
|
|
23
|
+
// Refused for load, the follower waits out the server's retry_after, doubling while it is refused (to 5 min), and
|
|
24
|
+
// keeps its cursor: asking every 5 s spent the slot it waited for on the next refusal.
|
|
25
|
+
let refused=0;
|
|
23
26
|
while(!abort.signal.aborted){
|
|
27
|
+
let pause=5000;
|
|
24
28
|
try{
|
|
25
29
|
const {team}=eventClient({context,supercodeHome});
|
|
26
30
|
// Every connection begins with a complete, ACL-filtered snapshot. A process restart cannot trust an old cache.
|
|
27
31
|
for await(const frame of team.events.follow({kinds:['session.started','session.ended','session.changed','resource.changed']}, {signal:abort.signal,after:cursor})){
|
|
28
32
|
if(frame.type==='snapshot_begin'){snapshot={};connected=false;persist();}
|
|
29
33
|
else if(frame.type==='snapshot_item')apply(snapshot??resources,frame);
|
|
30
|
-
else if(frame.type==='snapshot_end'){resources=snapshot??resources;snapshot=null;cursor=frame.cursor;connected=true;persist();wake('session-events-snapshot');}
|
|
34
|
+
else if(frame.type==='snapshot_end'){resources=snapshot??resources;snapshot=null;cursor=frame.cursor;connected=true;refused=0;persist();wake('session-events-snapshot');}
|
|
31
35
|
else if(frame.type==='event'){const changed=apply(resources,frame);cursor=frame.cursor;connected=true;persist();if(changed)wake('session-events');}
|
|
32
|
-
else if(frame.type==='checkpoint'){const recovered=!connected;cursor=frame.cursor;connected=true;persist();if(recovered)wake('session-events-reconnected');}
|
|
36
|
+
else if(frame.type==='checkpoint'){const recovered=!connected;cursor=frame.cursor;connected=true;refused=0;persist();if(recovered)wake('session-events-reconnected');}
|
|
33
37
|
else if(frame.terminal){cursor=null;connected=false;persist();wake('session-events-unavailable');break;}
|
|
34
38
|
}
|
|
35
|
-
}catch(error){
|
|
36
|
-
|
|
39
|
+
}catch(error){
|
|
40
|
+
const asked=retryAfterMs(error);if(asked){pause=Math.min(300000,asked*2**refused);refused++;}
|
|
41
|
+
if(!abort.signal.aborted)log(`dispatch session events unavailable: ${error.code??error.message}${asked?` (retrying in ${Math.round(pause/1000)} s)`:''}`);if(error.status===409)cursor=null;
|
|
42
|
+
}
|
|
43
|
+
connected=false;persist();wake('session-events-unavailable');if(!abort.signal.aborted)await wait(abort.signal,pause);
|
|
37
44
|
}
|
|
38
45
|
})();
|
|
39
46
|
return {close(){abort.abort();connected=false;persist();}};
|
package/board/store.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// This module opens a board in its backing and gives its callers one primitive, a write transaction; the transitions
|
|
10
10
|
// are in `engine.mjs`. Hermes's own tools may write a `kanban.db` at the same time: every write there takes SQLite's
|
|
11
11
|
// native transaction boundary and retries an optimistic conflict.
|
|
12
|
+
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
12
13
|
import { randomBytes } from 'node:crypto';
|
|
13
14
|
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
14
15
|
import { hostname } from 'node:os';
|
|
@@ -200,9 +201,15 @@ export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing
|
|
|
200
201
|
* A read inside the transaction is the state it writes over.
|
|
201
202
|
*/
|
|
202
203
|
export const tx = (board, fn) => {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
204
|
+
// Each transaction is a step named by the function that asked for it, so a round's slow-log line says which of its
|
|
205
|
+
// steps make its transactions (about a hundred a round, at 150 ms each, on 2026-10-02).
|
|
206
|
+
const caller = /at (?:async )?([\w.$<>]+) /.exec(String(new Error().stack).split('\n')[2] ?? '')?.[1] ?? 'unknown';
|
|
207
|
+
const started = performance.now();
|
|
208
|
+
try {
|
|
209
|
+
const result = board.tx(db => {const result=fn(db);recordCardPublications(db,actor);return result;});
|
|
210
|
+
signalBoardWrite(board.root, actor);
|
|
211
|
+
return result;
|
|
212
|
+
} finally { noteStep(`board tx from ${caller}`, performance.now() - started); }
|
|
206
213
|
};
|
|
207
214
|
// SQLite's own contention (BUSY, LOCKED, and their extended codes) and nothing else: a refusal whose words happen to
|
|
208
215
|
// say "blocked" or "busy" reaches its caller at once instead of being retried forever.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.26",
|
|
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": {
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"check-types": "node scripts/generate-types.mjs --check"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@volter/supercode-harness-sdk": "^0.3.
|
|
28
|
-
"@volter/teams": "0.2.
|
|
27
|
+
"@volter/supercode-harness-sdk": "^0.3.60",
|
|
28
|
+
"@volter/teams": "0.2.12",
|
|
29
29
|
"@volter/ztrack": "^2.4.23",
|
|
30
30
|
"yaml": "^2.9.0"
|
|
31
31
|
},
|