@volter/supercode-orchestrator 0.4.140 → 0.4.142

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
@@ -385,7 +385,7 @@ export async function runWorkflow(argv) {
385
385
  const card = withBoard((b) => {
386
386
  const got = engine.showCard(b.db, id);
387
387
  got.runs = engine.runs(b.db, id, { stateType: args['state-type'] ?? null, stateName: args['state-name'] ?? null });
388
- got.diagnostics = engine.diagnose(b.db, got.task, { failureLimit: settings.failureLimit });
388
+ got.diagnostics = engine.diagnose(b.db, got.task, { failureLimit: settings.failureLimit, launchWindowSeconds: settings.launchWindow });
389
389
  got.stage = statusOf(b.db, got.task);
390
390
  got.time = scopeOf(b.db, b, got.task, { params: workflowFor(root).params }).card.time;
391
391
  return got;
@@ -644,7 +644,7 @@ export async function runWorkflow(argv) {
644
644
  const order = ['warning', 'error', 'critical'];
645
645
  const found = withBoard((b) => {
646
646
  const cards = args.task ? [engine.getTask(b.db, args.task)].filter(Boolean) : engine.listCards(b.db, {});
647
- return cards.flatMap((t) => engine.diagnose(b.db, t, { failureLimit: settings.failureLimit, fleet }).map((d) => ({ task: t.id, title_of_task: t.title, ...d })))
647
+ return cards.flatMap((t) => engine.diagnose(b.db, t, { failureLimit: settings.failureLimit, fleet, launchWindowSeconds: settings.launchWindow }).map((d) => ({ task: t.id, title_of_task: t.title, ...d })))
648
648
  .filter((d) => !args.severity || order.indexOf(d.severity) >= order.indexOf(args.severity));
649
649
  });
650
650
  if (args.json) { say(args, '', found); return 0; }
@@ -21,7 +21,7 @@ import { spawn as spawnChild, spawnSync } from 'node:child_process';
21
21
  import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
22
22
  import { hostname, tmpdir } from 'node:os';
23
23
  import { basename, dirname, join } from 'node:path';
24
- import { randomUUID } from 'node:crypto';
24
+ import { createHash, randomUUID } from 'node:crypto';
25
25
  import { fileURLToPath } from 'node:url';
26
26
  import { view, boardPath, claimer, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
27
27
  import { doors, fire, loadWorkflow, resumeOf, runSpec, requestSessionClose, retireSupersededRuns, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, templateValue, workflowFor } from './workflow.mjs';
@@ -195,6 +195,7 @@ export function boardSettings(params = {}) {
195
195
  failureLimit: pos(params.failure_limit) ?? DEFAULT_FAILURE_LIMIT,
196
196
  ttl: claimTtl(params.claim_ttl_seconds),
197
197
  grace: pos(params.session_grace_seconds) ?? 120,
198
+ launchWindow: pos(params.session_launch_window_seconds) ?? 300,
198
199
  interval: Math.max(Number(params.dispatch_interval_seconds) || 60, 1),
199
200
  idleQuiet: pos(params.session_idle_quiet_seconds) ?? 600,
200
201
  logRotateBytes: pos(params.worker_log_rotate_bytes) ?? 2 * 1024 * 1024,
@@ -258,10 +259,6 @@ function supervise(board, fleet, settings, report, entry, effects) {
258
259
  if (!task || (entry.workflow.run ? !task.current_run_id : task.status !== 'running')) return;
259
260
  const runRow = currentRun(db, id);
260
261
  let session = runRow?.metadata?.supercode ?? null;
261
- if (session?.launch_pending || session?.launch_deferred) {
262
- extendClaim(db, id, { ttl: settings.ttl, reason: 'launch_receipt_pending' });
263
- return; // An uncertain launch owns this run until a machine receipt settles it.
264
- }
265
262
  if (session?.pane) {
266
263
  let found;
267
264
  try { found = continuationPane(fleet, session, observed.get(session.machine)); }
@@ -286,6 +283,20 @@ function supervise(board, fleet, settings, report, entry, effects) {
286
283
  }
287
284
  };
288
285
  const limits = limitsOf(db, board, entry, task, session);
286
+ if (runRow && (session?.launch_pending || session?.launch_deferred || (!session?.session_id && !session?.pid))) {
287
+ const age = t - Number(session?.launched_at ?? runRow.started_at);
288
+ if (age >= settings.launchWindow) {
289
+ const reason = session?.launch_deferred_error ?? session?.launch_error ?? 'no session or process was bound to the dispatched run';
290
+ const error = `launch window ${settings.launchWindow}s expired after ${age}s: ${reason}`;
291
+ if (!session?.launch_expired_at) {
292
+ setRunSession(db, runRow.id, { launch_expired_at: t, launch_error: error });
293
+ event(db, id, 'session_launch_expired', { error, age_seconds: age, window_seconds: settings.launchWindow, key: session?.launch_key ?? null, pane: session?.pane ?? null }, runRow.id);
294
+ }
295
+ observe('spawn_failed', { error, launch_window_seconds: settings.launchWindow }, null);
296
+ report.failed.push([id, error]);
297
+ } else extendClaim(db, id, { ttl: settings.ttl, reason: 'launch_receipt_pending' });
298
+ return;
299
+ }
289
300
  // Availability is the same retained linked/unlinked feed subscribers hear.
290
301
  // A failed stream is unknown, not evidence that every remote machine left.
291
302
  if (session?.session_id && session.surface !== 'headless') {
@@ -681,7 +692,7 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
681
692
  if (observed.error) throw new Error(observed.error);
682
693
  if (observed.live.has(session.pane)) {
683
694
  const pendingNotice = view(board, (db) => db.prepare("SELECT id FROM supercode_effects WHERE json_extract(effect, '$.kind')='session_notice' AND json_extract(effect, '$.runId')=? LIMIT 1").get(row.id));
684
- if (pendingNotice || session.lifecycle_notice_pending) throw new Error(`close deferred: lifecycle notice ${session.lifecycle_notice_pending} has not been delivered: ${session.lifecycle_notice_error ?? 'pending'}`);
695
+ if (pendingNotice || session.lifecycle_notice_pending) throw new Error(`close deferred: lifecycle notice ${pendingNotice?.id ?? session.lifecycle_notice_pending} has not been delivered: ${session.lifecycle_notice_error ?? 'pending'}`);
685
696
  const protectedNow = view(board, (db) => db.prepare(`SELECT 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())
686
697
  .some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); });
687
698
  if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
@@ -767,6 +778,12 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
767
778
  }
768
779
  }
769
780
 
781
+ function effectMessageKey(effect) {
782
+ if (effect.messageKey) return effect.messageKey;
783
+ const identity = JSON.stringify([effect.kind, effect.id, effect.runId, effect.sourceEvent, effect.durableId, effect.verb, effect.text]);
784
+ return `board-effect-${createHash('sha256').update(identity).digest('hex')}`;
785
+ }
786
+
770
787
  /** The verb drains this durable notice immediately; the dispatcher retries after a failure. */
771
788
  export async function sessionNotice(board, e) {
772
789
  const prior = view(board, (db) => json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId)?.metadata, {})?.supercode);
@@ -781,7 +798,7 @@ export async function sessionNotice(board, e) {
781
798
  let error = null, receipt = null;
782
799
  try {
783
800
  if (!e.address) throw new Error('lifecycle notice awaits the owned launch session identity');
784
- receipt = await send(boardAddress(board.label), board.label, e.address, e.text, e.messageKey ?? `board-run-notice-${e.id}-${e.durableId}`);
801
+ receipt = await send(boardAddress(board.label), board.label, e.address, e.text, effectMessageKey(e));
785
802
  if (!receipt.delivered) error = receipt.reason ?? 'notice queued; native transcript confirmation pending'; }
786
803
  catch (failure) { error = String(failure.message).slice(-500); }
787
804
  tx(board, (db) => {
@@ -802,7 +819,7 @@ export async function sessionEffect(board, e) {
802
819
  let receipt;
803
820
  try {
804
821
  if (!address) throw new Error('actor session identity unavailable');
805
- receipt = await send(boardAddress(board.label), board.label, address, e.text, `board-actor-${e.id}-${e.durableId}`);
822
+ receipt = await send(boardAddress(board.label), board.label, address, e.text, effectMessageKey(e));
806
823
  } catch (error) { receipt = { delivered: false, reason: error.message }; }
807
824
  const kind = receipt.delivered ? 'actor_notice_delivered' : receipt.queued ? 'actor_notice_queued' : 'actor_notice_failed';
808
825
  tx(board, db => {
@@ -951,8 +968,9 @@ export function reconcileCallerLaunch(board, sid) {
951
968
  }
952
969
 
953
970
  function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes) {
954
- const deferred=view(board,db=>db.prepare("SELECT t.id 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());
955
- for(const {id} of deferred) {
971
+ 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());
972
+ for(const {id,started_at} of deferred) {
973
+ if (now() - started_at >= settings.launchWindow) continue;
956
974
  try {
957
975
  const task=getTaskNow(board,id),status=view(board,db=>statusOf(db,task)),spec=runSpec(entry,status,task);
958
976
  const profile=spec&&view(board,db=>evaluate(entry.workflow.roles?.[spec.role]??'card.assignee',scopeOf(db,board,task,{params:entry.params})));
package/board/engine.mjs CHANGED
@@ -710,11 +710,21 @@ const hours = (s) => s / 3600;
710
710
  * same thing can go wrong, and supercode's own where only its sessions can): each `{ kind, severity, title, data,
711
711
  * actions }`, worst first.
712
712
  */
713
- export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, strandedSeconds = 30 * 60, blockedHours = 24, fleet = null } = {}) {
713
+ export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, strandedSeconds = 30 * 60, blockedHours = 24, fleet = null, launchWindowSeconds = 300 } = {}) {
714
714
  const out = [];
715
715
  const t = now();
716
716
  const events = all(db, 'SELECT kind, payload, created_at FROM task_events WHERE task_id = ? ORDER BY id', task.id);
717
717
  const failures = Number(task.consecutive_failures || 0);
718
+ const active = task.current_run_id ? one(db, 'SELECT id,started_at,metadata FROM task_runs WHERE id=?', task.current_run_id) : null;
719
+ const session = json(active?.metadata, {})?.supercode;
720
+ if (active && (session?.launch_pending || session?.launch_deferred || (!session?.session_id && !session?.pid))) {
721
+ const age = t - Number(session?.launched_at ?? active.started_at);
722
+ const reason = session?.launch_deferred_error ?? session?.launch_error ?? 'no session or process has been bound';
723
+ out.push({ kind: 'session_launch_pending', severity: age >= launchWindowSeconds ? 'error' : 'warning',
724
+ title: `run ${active.id} has no confirmed session after ${age}s: ${reason}`,
725
+ data: { run_id: active.id, age_seconds: age, window_seconds: launchWindowSeconds, reason, launch_key: session?.launch_key ?? null, pane: session?.pane ?? null },
726
+ actions: [`supercode workflow log ${task.id}`] });
727
+ }
718
728
  if (failures >= Math.max(1, (task.max_retries ?? failureLimit) - 1) && task.status !== 'done') {
719
729
  out.push({ kind: 'repeated_failures', severity: task.status === 'blocked' ? 'error' : 'warning', title: `${failures} consecutive failures`,
720
730
  data: { failures, last_error: task.last_failure_error }, actions: [`supercode workflow reclaim ${task.id}`, `supercode workflow reassign ${task.id} <profile>`] });
package/board/rows.mjs CHANGED
@@ -5,7 +5,7 @@ const quote = s => `"${s.replaceAll('"', '""')}"`;
5
5
  const RUNTIME = new Set(['claim_lock','claim_expires','worker_pid','last_heartbeat_at','current_run_id','consecutive_failures','last_failure_error','block_recurrences','session_id','workspace_path']);
6
6
  const OMIT = new Set(['tasks','supercode_cards','task_links','sqlite_sequence']);
7
7
  export class RuntimeRows {
8
- constructor(db) { this.db=db; this.revision=null; this.tables=new Map(); this.runtime={}; this.clocks={}; this.cachedTasks={}; this.cachedCards={}; }
8
+ constructor(db) { this.db=db; this.revision=null; this.tables=new Map(); this.runtime={}; this.clocks={}; this.sequences={}; this.cachedTasks={}; this.cachedCards={}; }
9
9
  normalized() { return Boolean(this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='runtime_rows'").get()); }
10
10
  watch(mem) {
11
11
  mem.exec('CREATE TEMP TABLE IF NOT EXISTS _runtime_dirty (name TEXT, key TEXT)');
@@ -40,16 +40,24 @@ export class RuntimeRows {
40
40
  const value=json(row.value,null);
41
41
  if(row.name==='@runtime') { if(value)this.runtime[row.key]=value;else delete this.runtime[row.key]; }
42
42
  else if(row.name==='@clock') { if(value)this.clocks[row.key]=value;else delete this.clocks[row.key]; }
43
+ else if(row.name==='@sequence') { if(value)this.sequences[row.key]=value;else delete this.sequences[row.key]; }
43
44
  else if(row.name==='@task_cache') { if(value)this.cachedTasks[row.key]=value;else delete this.cachedTasks[row.key]; }
44
45
  else if(row.name==='@card_cache') { if(value)this.cachedCards[row.key]=value;else delete this.cachedCards[row.key]; }
45
46
  else this.insert(mem,row.name,value,row.key);
46
47
  }
48
+ if (mem.prepare("SELECT 1 FROM sqlite_master WHERE name='sqlite_sequence'").get()) {
49
+ for (const [name, value] of Object.entries(this.sequences)) {
50
+ const sequence = mem.prepare('SELECT seq FROM sqlite_sequence WHERE name=?').get(name);
51
+ if (sequence) mem.prepare('UPDATE sqlite_sequence SET seq=MAX(seq,?) WHERE name=?').run(value.seq,name);
52
+ else mem.prepare('INSERT INTO sqlite_sequence(name,seq) VALUES(?,?)').run(name,value.seq);
53
+ }
54
+ }
47
55
  this.revision=Number(this.db.prepare("SELECT value FROM state WHERE key='rows_revision'").get()?.value??0);
48
56
  return true;
49
57
  }
50
58
  changes(mem,{all=false}={}) {
51
59
  const dirty=all?[...this.tables.values()].flatMap(t=>mem.prepare(`SELECT ${t.keys.map(quote).join(',')} FROM ${quote(t.name)}`).all().map(row=>({name:t.name,key:JSON.stringify(t.keys.map(k=>row[k]))}))):mem.prepare('SELECT DISTINCT name,key FROM _runtime_dirty').all();
52
- return dirty.map(({name,key})=>{
60
+ const changes = dirty.map(({name,key})=>{
53
61
  const table=this.tables.get(name);
54
62
  const row=mem.prepare(`SELECT * FROM ${quote(name)} WHERE ${table.keys.map(k=>`${quote(k)} IS ?`).join(' AND ')}`).get(...JSON.parse(key))??null;
55
63
  if(name==='tasks')return {name:'@runtime',key:JSON.parse(key)[0],value:row&&Object.fromEntries(Object.entries(row).filter(([k])=>RUNTIME.has(k))),source:{name,key,row}};
@@ -72,6 +80,12 @@ export class RuntimeRows {
72
80
  if(name==='@clock')return JSON.stringify(source.row)!==JSON.stringify(this.cachedCards[key]??null);
73
81
  return false;
74
82
  });
83
+ if (mem.prepare("SELECT 1 FROM sqlite_master WHERE name='sqlite_sequence'").get()) {
84
+ for (const row of mem.prepare('SELECT name,seq FROM sqlite_sequence').all()) {
85
+ if (Number(this.sequences[row.name]?.seq) !== Number(row.seq)) changes.push({name:'@sequence',key:row.name,value:row,source:{name:'sqlite_sequence',key:JSON.stringify([row.name]),row}});
86
+ }
87
+ }
88
+ return changes;
75
89
  }
76
90
  mirror(changes, revision) {
77
91
  const previous=Number(this.db.prepare("SELECT value FROM state WHERE key='read_revision'").get()?.value??-1);
@@ -108,6 +122,7 @@ export class RuntimeRows {
108
122
  }
109
123
  if(name==='@runtime') { if(value)this.runtime[key]=value;else delete this.runtime[key]; }
110
124
  if(name==='@clock') { if(value)this.clocks[key]=value;else delete this.clocks[key]; }
125
+ if(name==='@sequence') { if(value)this.sequences[key]=value;else delete this.sequences[key]; }
111
126
  }
112
127
  for(const {name,sql} of this.tables.values())if(!OMIT.has(name))this.db.prepare('INSERT OR IGNORE INTO runtime_schema VALUES(?,?)').run(name,sql);
113
128
  this.db.prepare("INSERT INTO state VALUES('rows_revision',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(String(revision));
@@ -338,7 +338,7 @@ function queueTransitionNotice(db, board, entry, task, from, name, payload, befo
338
338
  const scope = scopeOf(db, board, after, { params: entry.params, event: { ...payload, verb: name, from, to }, extra: { run: { id: beforeRun.id } } });
339
339
  const text = render(entry.workflow.notify?.worker?.run_changed ?? '{{card.id}}: {{event.verb}} changed run {{run.id}} ({{event.from}} → {{event.to}}). Stop work under the previous run; follow the board’s current state. {{event.reason ?? ""}}', scope);
340
340
  const source = one(db, 'SELECT MAX(id) AS id FROM task_events WHERE task_id = ?', task.id)?.id;
341
- const effect = { kind: 'session_notice', id: task.id, runId: beforeRun.id, address, text, sourceEvent: source, verb: name };
341
+ const effect = { kind: 'session_notice', id: task.id, runId: beforeRun.id, address, text, sourceEvent: source, verb: name, messageKey: `board-run-notice-${randomBytes(16).toString('hex')}` };
342
342
  const inserted = run(db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
343
343
  effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
344
344
  const current = json(one(db, 'SELECT metadata FROM task_runs WHERE id = ?', beforeRun.id)?.metadata, {}) ?? {};
@@ -791,7 +791,7 @@ const ACTIONS = {
791
791
  ctx.effects.push({ kind: 'board_message', id: ctx.id });
792
792
  },
793
793
  send_to_actor(ctx, text) {
794
- const effect = { kind: 'send_to_actor', id: ctx.id, runId: ctx.runId, text };
794
+ const effect = { kind: 'send_to_actor', id: ctx.id, runId: ctx.runId, text, messageKey: `board-actor-${randomBytes(16).toString('hex')}` };
795
795
  const inserted = run(ctx.db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
796
796
  ctx.effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
797
797
  },
package/board/ztrack.mjs CHANGED
@@ -625,6 +625,7 @@ class ZtrackBoard {
625
625
  if (id) sources.set(`tasks:${JSON.stringify([id])}`, {name:'tasks',key:JSON.stringify([id]),row:this.mem.prepare('SELECT * FROM tasks WHERE id=?').get(id)});
626
626
  }
627
627
  for (const {name,key,row:source} of sources.values()) {
628
+ if (name === 'sqlite_sequence') continue;
628
629
  if (!compat.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name)) continue;
629
630
  const table = this.rows.tables.get(name);
630
631
  if (name === 'task_events') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.140",
3
+ "version": "0.4.142",
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": {