@volter/supercode-orchestrator 0.5.25 → 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 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
- * Every workflow call says when this orchestrator is not the published release: a build no release names (a hand
302
- * install of a branch) or one older than npm's latest. Action is required until it is installed; the latest is read
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 sayReleaseBehind() {
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('-')) process.stderr.write(`board: error: orchestrator ${running} is not a published release (latest ${latest}); action required: ${fix}\n`);
324
- else if (older < 0) process.stderr.write(`board: error: orchestrator ${running} is behind the published ${latest}; action required: ${fix}\n`);
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 {
@@ -1491,6 +1507,8 @@ async function serve(root, args = {}) {
1491
1507
  log(`tick failed: ${error.message}`);
1492
1508
  await watchRounds([{ board: '*', error: `tick failed: ${error.message}` }]);
1493
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() } });
1494
1512
  // a newer install is looked for after each tick and through the wait before the next
1495
1513
  const newer = () => { const now = installed(); return running && now && now !== running ? now : null; };
1496
1514
  let moved = newer();
@@ -1315,11 +1315,14 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes
1315
1315
  try{synchronizeAgentRun(board.root,org,row,{context,intended:json(launch?.metadata,{})?.supercode?.agent_profile_intent});}
1316
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`:''}`]);}
1317
1317
  }
1318
- 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());
1319
1319
  // A deferred launch was never issued (a reading or door was not yet available): it is the start its manager, creator
1320
- // or workflow asked for, carried out within the launch window; supervise surfaces it once when the window ends
1321
- for(const {id,started_at} of deferred) {
1322
- if (now() - started_at >= settings.launchWindow) continue;
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; }
1323
1326
  try {
1324
1327
  const task=getTaskNow(board,id),status=view(board,db=>statusOf(db,task)),spec=runSpec(entry,status,task);
1325
1328
  const profile=spec&&view(board,db=>evaluate(entry.workflow.roles?.[spec.role]??'card.assignee',scopeOf(db,board,task,{params:entry.params})));
@@ -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,5000);signal.addEventListener('abort',()=>{clearTimeout(timer);resolve();},{once:true});});
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){if(!abort.signal.aborted)log(`dispatch session events unavailable: ${error.code??error.message}`);if(error.status===409)cursor=null;}
36
- connected=false;persist();wake('session-events-unavailable');if(!abort.signal.aborted)await wait(abort.signal);
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.25",
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.58",
28
- "@volter/teams": "0.2.11",
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
  },