@volter/supercode-orchestrator 0.4.128 → 0.4.130

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
@@ -302,6 +302,7 @@ export async function runWorkflow(argv) {
302
302
  }), true);
303
303
  let answer = { id: created.id, existing: created.existing };
304
304
  if (args.adopt) {
305
+ withBoard(b => { if (!rolesFor(b.db, created.id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
305
306
  const adopted = withBoard(b => adoptSession(b, created.id, args.adopt, { ttl: settings.ttl }));
306
307
  answer = { ...answer, started: true, session: adopted.address, mode: adopted.mode };
307
308
  } else if (!args['no-start'] && !created.existing && !args['subtask-of']) {
@@ -827,6 +828,7 @@ export async function runWorkflow(argv) {
827
828
  }
828
829
  case 'adopt': {
829
830
  need(2, 'a card id and a session');
831
+ withBoard(b => { if (!rolesFor(b.db, id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
830
832
  const { settings } = await laneState(root);
831
833
  const got = withBoard((b) => adoptSession(b, id, rest[1], { workspace: args.workspace ?? null, ttl: settings.ttl, assignee: args.assignee ?? null, role: args.role ?? null }));
832
834
  say(args, `${id} is worked by ${got.address} (pane ${got.pane ?? '?'} in ${got.workspace ?? 'its card\'s directory'})`, { ok: true, ...got });
@@ -450,16 +450,23 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
450
450
  return { target, runSpec: spec, profile, lane: profile ? lanes[profile] ?? null : null };
451
451
  };
452
452
  const candidates = [];
453
+ const waiting = (task, why) => {
454
+ report.queued.push([task.id, why]);
455
+ if (!dry) tx(board, db => noteGuard(db, task.id, why));
456
+ };
453
457
  for (const task of everyCard) {
454
458
  if (only && task.id !== only) continue;
455
459
  const state = scopeFor(task).card;
456
- if (state.dispatch_hold) continue;
457
- if (workflow.run && (task.subtask_of || state.paused || state.archived || state.blocked_by.length || task.current_run_id || (state.every && state.next_run_at > now()))) continue;
458
- if (!startable.has(statusOf(board.db, task))) continue;
460
+ if (task.subtask_of || state.archived || task.current_run_id || ['done', 'cancelled'].includes(state.status)) continue;
461
+ if (state.dispatch_hold) { waiting(task, 'dispatch held: an explicit start or verified adoption must release it'); continue; }
462
+ if (workflow.run && state.paused) { waiting(task, `paused: ${state.paused.reason ?? 'manager pause'}`); continue; }
463
+ if (workflow.run && state.blocked_by.length) { waiting(task, `blocked by ${state.blocked_by.map(b => b.id).join(', ')}`); continue; }
464
+ if (workflow.run && state.every && state.next_run_at > now()) { waiting(task, `next scheduled run at ${state.next_run_at}`); continue; }
465
+ if (!startable.has(state.status)) { waiting(task, `no ${d.start} transition from ${state.status}`); continue; }
459
466
  const plan = planOf(task, scopeFor(task));
460
- if (!plan) continue;
467
+ if (!plan) { waiting(task, `start guard refused: ${transitionsFor(workflow, state.status, d.start).map(t => t.guard ?? 'sender role').join(' | ')}`); continue; }
461
468
  const scope = scopeFor(task, { lane: plan.lane ?? {}, run: { surface: plan.lane?.surface ?? null, local: plan.lane?.surface !== 'pane' || normalMachine(task.machine ?? '') === localMachine() } });
462
- if (!holds(d.eligible ?? null, scope)) continue;
469
+ if (!holds(d.eligible ?? null, scope)) { waiting(task, `dispatch eligibility refused (memory ${pressure}, local starts ${tickState.local_started}): ${d.eligible}`); continue; }
463
470
  candidates.push({ task, plan, scope });
464
471
  }
465
472
  const keyOf = (c) => (d.order ?? []).map((o) => { const desc = o.startsWith('-'); const v = evaluate(desc ? o.slice(1) : o, c.scope); return { v, desc }; });
@@ -770,16 +777,17 @@ export async function sessionNotice(board, e) {
770
777
  return true;
771
778
  }
772
779
  e = { ...e, address: e.address ?? addressOf(prior) };
773
- let error = null;
780
+ let error = null, receipt = null;
774
781
  try {
775
782
  if (!e.address) throw new Error('lifecycle notice awaits the owned launch session identity');
776
- await send(boardAddress(board.label), board.label, e.address, e.text, `board-run-notice-${e.id}-${e.durableId}`, { queue: true }); }
783
+ receipt = await send(boardAddress(board.label), board.label, e.address, e.text, `board-run-notice-${e.id}-${e.durableId}`);
784
+ if (!receipt.delivered) error = receipt.reason ?? 'notice queued; native transcript confirmation pending'; }
777
785
  catch (failure) { error = String(failure.message).slice(-500); }
778
786
  tx(board, (db) => {
779
787
  const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
780
788
  if (session) setRunSession(db, e.runId, { ...session,
781
789
  ...(error ? { lifecycle_notice_error: error } : { lifecycle_notice_error: null, ...(session.lifecycle_notice_pending === e.durableId ? { lifecycle_notice_pending: null } : {}) }) });
782
- if (!error || session?.lifecycle_notice_error !== error) event(db, e.id, error ? 'session_notice_failed' : 'session_notice_delivered', { address: e.address, verb: e.verb, source_event: e.sourceEvent, notice: e.durableId, ...(error ? { error, retry: true } : {}) }, e.runId);
790
+ if (!error || session?.lifecycle_notice_error !== error) event(db, e.id, error ? (receipt?.queued ? 'session_notice_queued' : 'session_notice_failed') : 'session_notice_delivered', { address: e.address, verb: e.verb, source_event: e.sourceEvent, notice: e.durableId, receipt, ...(error ? { error, retry: true } : {}) }, e.runId);
783
791
  });
784
792
  return !error;
785
793
  }
@@ -787,12 +795,21 @@ export async function sessionNotice(board, e) {
787
795
  /** A session effect a committed move asked for: a message to the card's current actor, or closing a slot's pane. */
788
796
  export async function sessionEffect(board, e) {
789
797
  if (e.kind === 'send_to_actor') {
790
- const run = view(board, (db) => currentRun(db, e.id));
791
- const session = run?.metadata?.supercode;
798
+ const row = view(board, db => e.runId ? db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId) : null);
799
+ const session = json(row?.metadata, {})?.supercode;
792
800
  const address = addressOf(session);
793
- if (!address) return;
794
- await send(boardAddress(board.label), board.label, address, e.text, `board-actor-${e.id}-${now()}`);
795
- return;
801
+ let receipt;
802
+ try {
803
+ if (!address) throw new Error('actor session identity unavailable');
804
+ receipt = await send(boardAddress(board.label), board.label, address, e.text, `board-actor-${e.id}-${e.durableId}`);
805
+ } catch (error) { receipt = { delivered: false, reason: error.message }; }
806
+ const kind = receipt.delivered ? 'actor_notice_delivered' : receipt.queued ? 'actor_notice_queued' : 'actor_notice_failed';
807
+ tx(board, db => {
808
+ const previous = db.prepare("SELECT kind,payload FROM task_events WHERE task_id=? AND kind IN ('actor_notice_delivered','actor_notice_queued','actor_notice_failed') ORDER BY id DESC LIMIT 1").get(e.id);
809
+ const payload = { notice: e.durableId, address, receipt };
810
+ if (previous?.kind !== kind || JSON.stringify(json(previous?.payload, {})?.receipt) !== JSON.stringify(receipt)) event(db, e.id, kind, payload, e.runId);
811
+ });
812
+ return receipt.delivered;
796
813
  }
797
814
  if (e.kind === 'close_session' || e.kind === 'run_ended') {
798
815
  tx(board, (db) => requestSessionClose(db, e.id, e.kind === 'run_ended' ? { runId: e.runId } : { slot: e.slot, through: e.runId }));
@@ -1212,6 +1229,12 @@ function resolveLocalWorkspace(board, task) {
1212
1229
  * its card. `who` is an address, a session id, or a `name@machine` the fleet lists.
1213
1230
  */
1214
1231
  export function adoptSession(board, id, who, { workspace = null, ttl, assignee = null, role = null } = {}) {
1232
+ // Hold before discovery or any other process I/O: a failed attachment remains
1233
+ // visible and cannot race a dispatcher into launching a replacement worker.
1234
+ tx(board, db => {
1235
+ if (!getTask(db, id)) throw new Error(`no card ${id}`);
1236
+ event(db, id, 'dispatch_held', { session: who, reason: 'adoption requested' });
1237
+ });
1215
1238
  const fleet = observeFleet();
1216
1239
  if (!fleet.ok) throw new Error(`supercode could not read the fleet: ${fleet.error}`);
1217
1240
  const found = fleet.sessions.find((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who);
@@ -1293,6 +1316,7 @@ async function send(from, label, to, body, key, { queue = false, ask = false } =
1293
1316
  const r = await scAsync(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 180_000 });
1294
1317
  const answer = lastJson(r.stdout) ?? {};
1295
1318
  if (answer.code !== 0) throw new Error((answer.text || r.stderr || `no answer (exit ${r.code})`).trim().slice(-300));
1319
+ return answer.receipt ?? { delivered: false, reason: 'mail door returned no delivery receipt' };
1296
1320
  }
1297
1321
 
1298
1322
  /**
package/board/engine.mjs CHANGED
@@ -170,6 +170,7 @@ export function createCard(board, spec) {
170
170
  ws.path = declared.path;
171
171
  spec = { ...spec, branch: declared.branch ?? spec.branch };
172
172
  const initial = spec.initialStatus ?? null;
173
+ if (initial === 'running' && (spec.noStart || spec.adopt)) throw new Error('A held or awaiting-adoption card cannot be created initially running.');
173
174
  if (initial && !['blocked', 'running'].includes(initial)) throw new Error('initial status is blocked or running');
174
175
  if (spec.provider && !spec.model) throw new Error('a provider needs a model');
175
176
  return tx(board, (db) => {
@@ -396,7 +397,7 @@ export async function runEffects(board, effects = [], { retryNotices = false } =
396
397
  else if (e.kind === 'close_session' || e.kind === 'send_to_actor' || e.kind === 'run_ended') {
397
398
  if (e.kind === 'run_ended' && e.outcome === 'reclaimed') stopWorker(board, e.runId);
398
399
  const { sessionEffect } = await import('./dispatch.mjs');
399
- await sessionEffect(board, e);
400
+ if (await sessionEffect(board, e) === false) continue;
400
401
  }
401
402
  if (e.durableId) tx(board, (db) => db.prepare('DELETE FROM supercode_effects WHERE id = ?').run(e.durableId));
402
403
  }
package/board/wake.mjs CHANGED
@@ -20,7 +20,7 @@ export function signalBoardWrite(root, actor) {
20
20
  /** One coalescing queue shared by standalone serve and the hosted dispatcher. */
21
21
  export function watchBoard({ root, binary, env, log = () => {} }) {
22
22
  const watchers = new Map();
23
- let pending = null, waiter = null, closed = false, child = null, retry = null, lastError = null;
23
+ let pending = null, waiter = null, closed = false, child = null, retry = null, lastError = null, catalogue = null;
24
24
  const wake = (source, at = Date.now()) => {
25
25
  if (closed) return;
26
26
  if (!pending) pending = { at, sources: new Set() };
@@ -83,7 +83,7 @@ export function watchBoard({ root, binary, env, log = () => {} }) {
83
83
  if (closed) return;
84
84
  let buffer = '', error = '';
85
85
  const command = env.SUPERCODE_TEAMS_ENTRY ? process.execPath : binary;
86
- const args = [...(env.SUPERCODE_TEAMS_ENTRY ? [env.SUPERCODE_TEAMS_ENTRY] : []), 'sessions', 'list', '--watch', '--json'];
86
+ const args = [...(env.SUPERCODE_TEAMS_ENTRY ? [env.SUPERCODE_TEAMS_ENTRY] : []), 'sessions', 'list', '--watch', '--json', '--limit', '200'];
87
87
  child = spawn(command, args, { cwd: tmpdir(), env, stdio: ['ignore', 'pipe', 'pipe'] });
88
88
  child.stdout.setEncoding('utf8').on('data', chunk => {
89
89
  buffer += chunk;
@@ -94,7 +94,24 @@ export function watchBoard({ root, binary, env, log = () => {} }) {
94
94
  try {
95
95
  const event = JSON.parse(line);
96
96
  lastError = null;
97
- if (!['heartbeat', 'ping', 'keepalive'].includes(event.type)) wake('session-catalogue');
97
+ if (['heartbeat', 'ping', 'keepalive'].includes(event.type)) continue;
98
+ if (event.type === 'catalog_reset' && Array.isArray(event.data?.items)) {
99
+ // Transcript revisions and sort order are not scheduling events.
100
+ // The safety sweep reads quiet timestamps; wake for session identity,
101
+ // availability and available delivery/control doors changing now.
102
+ const next = JSON.stringify(event.data.items.map(item => ({
103
+ id:item.id, harness:item.harness, machines:[...(item.machine_ids ?? [])].sort(),
104
+ bindings:(item.bindings ?? []).map(binding => ({ id:binding.id, machine:binding.machine_id,
105
+ incarnation:binding.incarnation_id, runtime:binding.native_runtime_id, session:binding.native_session_id,
106
+ harness:binding.harness, state:binding.state, capabilities:[...(binding.native_capabilities ?? [])].sort(),
107
+ })).sort((a,b)=>String(a.id).localeCompare(String(b.id))), actions:item.actions,
108
+ presence:item.activity?.presence, turn:item.activity?.turn,
109
+ status:item.live_status, delivery:item.delivery,
110
+ })).sort((a,b)=>String(a.id).localeCompare(String(b.id))));
111
+ if (next === catalogue) continue;
112
+ catalogue = next;
113
+ }
114
+ wake('session-catalogue');
98
115
  } catch { /* CLI diagnostics are not events */ }
99
116
  }
100
117
  });
@@ -359,7 +359,15 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
359
359
  const allowed = candidates.filter((t) => !t.by?.length || t.by.some((r) => roles.includes(r)));
360
360
  if (!allowed.length) throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(candidates.flatMap((t) => t.by ?? []))].join(' or ')}. ${candidates.map((t) => t.description).filter(Boolean).join(' ')}`);
361
361
  const taken = allowed.find((t) => holds(t.guard ?? null, scope));
362
- if (!taken) { const none = new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} does not apply to it now`); none.noTransition = true; throw none; }
362
+ if (!taken) {
363
+ const state = scope.card;
364
+ const reasons = [state.paused && `paused: ${state.paused.reason ?? 'manager pause'}`,
365
+ state.blocked_by.length && `blocked by ${state.blocked_by.map(b => b.id).join(', ')}`,
366
+ state.dispatch_hold && 'dispatch held', state.current_run_id && `current run ${state.current_run_id}`,
367
+ state.archived && 'archived'].filter(Boolean);
368
+ const none = new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} cannot proceed: ${reasons.join('; ') || `guard ${allowed.map(t => t.guard).join(' | ')}`}`);
369
+ none.noTransition = true; throw none;
370
+ }
363
371
  for (const v of taken.validate ?? []) {
364
372
  if (!holds(v.expr, scope)) { const refusal = new Error(v.message); refusal.refusal = v.message; throw refusal; }
365
373
  }
@@ -763,7 +771,11 @@ const ACTIONS = {
763
771
  record(ctx.db, ctx.id, 'board_message', { to: String(to), text: `${String(text ?? '')}\n\nAnswer with reply_to ${id}.`, message_id: id }, null);
764
772
  ctx.effects.push({ kind: 'board_message', id: ctx.id });
765
773
  },
766
- send_to_actor(ctx, text) { ctx.effects.push({ kind: 'send_to_actor', id: ctx.id, text }); },
774
+ send_to_actor(ctx, text) {
775
+ const effect = { kind: 'send_to_actor', id: ctx.id, runId: ctx.runId, text };
776
+ const inserted = run(ctx.db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
777
+ ctx.effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
778
+ },
767
779
  };
768
780
 
769
781
  /** Binding a newer run transfers ownership; stale run references cannot veto its later stop. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.128",
3
+ "version": "0.4.130",
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": {