@volter/supercode-orchestrator 0.5.193 → 0.5.195

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.
@@ -16,6 +16,13 @@ const GROUP = {
16
16
  /** The fleet statuses of a session in a turn (`waiting`: a turn stopped on a question it asked). */
17
17
  const IN_TURN = new Set(['busy', 'waiting']);
18
18
 
19
+ // A kept dispatcher session is recorded as adopt too, but retains its launch key. An unissued claim reserves a
20
+ // place; a deferred launch has not started a session and waits for admission on its retry.
21
+ export const dispatched = (s) => Boolean(s?.launch_key || s?.surface === 'headless' || ['fresh', 'resume'].includes(s?.mode) || (!s?.mode && !s?.launch_deferred));
22
+
23
+ // Headless workers execute on the board host; only panes execute on their named target.
24
+ export const executionHost = (lane, target, home, normalise) => lane?.surface === 'pane' ? normalise(target ?? home) : home;
25
+
19
26
  /** Whether `thing` (a counted card or session, or a card being started) is one the filter is about. */
20
27
  export function matches(filter, thing) {
21
28
  for (const [key, names] of Object.entries(filter ?? {})) {
@@ -31,10 +38,10 @@ const applies = (cap, thing) => matches(cap.where, thing) && !(Object.keys(cap.u
31
38
  /** Each slot's latest recorded session on a card (implementer `worker`, `reviewer`, …), newest run first. */
32
39
  function sessionsOf(db, taskId) {
33
40
  const out = new Map();
34
- for (const row of db.prepare('SELECT metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC').all(taskId)) {
41
+ for (const row of db.prepare('SELECT id, metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC').all(taskId)) {
35
42
  const s = json(row.metadata, {})?.supercode;
36
43
  const slot = s?.slot ?? 'worker';
37
- if (s?.session_id && !out.has(slot)) out.set(slot, s);
44
+ if (s?.session_id && !out.has(slot)) out.set(slot, { ...s, run: row.id });
38
45
  }
39
46
  return out;
40
47
  }
@@ -46,7 +53,7 @@ function sessionsOf(db, taskId) {
46
53
  * own transcript reports it runs, or null where none can be read; `machineOf` normalises a machine name; `lanes` gives
47
54
  * a profile's harness and model.
48
55
  */
49
- export function holdings(board, { lanes, fleetOk, alive, liveRecord, modelOf = () => null, storedOf = null, machineOf, home = null }) {
56
+ export function holdings(board, { lanes, fleetOk, alive, liveRecord, modelOf = () => null, storedOf = null, machineOf, home = null, runLaneOf = null }) {
50
57
  const out = [];
51
58
  const rows = board.db.prepare("SELECT t.id, t.status, t.assignee, t.current_run_id, t.model_override, c.machine, c.subtask_of FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.status != 'archived'").all();
52
59
  for (const row of rows) {
@@ -64,14 +71,16 @@ export function holdings(board, { lanes, fleetOk, alive, liveRecord, modelOf = (
64
71
  const sessionOf = (s, slot) => {
65
72
  const configured = s.model ?? card.model;
66
73
  return { ...card, counts: 'sessions', slot, session: s.session_id ?? null, unconfigured: !configured,
67
- machine: s.machine ? machineOf(s.machine) : card.machine, harness: s.harness ?? card.harness ?? 'claude-code',
74
+ dispatch: dispatched(s), run: s.run ?? null,
75
+ machine: s.surface === 'headless' ? home : s.machine || s.surface ? machineOf(s.machine ?? row.machine ?? home)
76
+ : executionHost(runLaneOf ? runLaneOf(row) : lane, row.machine, home, machineOf), harness: s.harness ?? card.harness ?? 'claude-code',
68
77
  model: (s.session_id ? modelOf(s) : null) ?? configured };
69
78
  };
70
79
  const sessions = fleetOk ? sessionsOf(board.db, row.id) : new Map();
71
80
  const held = [];
72
81
  let current = null;
73
82
  if (row.current_run_id) {
74
- current = json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.current_run_id)?.metadata, {})?.supercode ?? {};
83
+ current = { ...json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.current_run_id)?.metadata, {})?.supercode, run: row.current_run_id };
75
84
  out.push({ ...sessionOf(current, current.slot ?? 'worker'), session: undefined, slot: undefined, counts: 'runs' });
76
85
  }
77
86
  for (const [slot, s] of sessions) {
@@ -114,6 +123,7 @@ export function harnessDefaults(held) {
114
123
  */
115
124
  export function capacity(caps, held, evaluate) {
116
125
  const list = (caps ?? []).map((cap) => ({ ...cap, where: cap.where ?? {} }));
126
+ const trackDispatcherSlots = list.some(cap => cap.counts === 'sessions' && cap.where.dispatch === true);
117
127
  // shared by every board of a tick, so what one board starts counts on the next
118
128
  const things = held;
119
129
  const counted = (cap, group) => things.filter((x) => x.counts === cap.counts && applies(cap, x) && GROUP[cap.scope](x) === group);
@@ -138,7 +148,8 @@ export function capacity(caps, held, evaluate) {
138
148
  * start. `scope` evaluates each max; `waiting` is every card still to be planned this tick, for reserves (a waiting card
139
149
  * the cap counts already takes no new place, so none is held for it). A card
140
150
  * that already holds a place in a count (its implementer's live session while its review starts) keeps it: that
141
- * cap does not hold it.
151
+ * cap does not hold it. A dispatcher-only sessions cap keeps a place only for the same slot: review is another
152
+ * session, so it must have room.
142
153
  */
143
154
  admit(entry, scope, waiting = []) {
144
155
  for (const cap of list) {
@@ -147,7 +158,8 @@ export function capacity(caps, held, evaluate) {
147
158
  if (max === null || max === undefined || max === '') continue;
148
159
  const group = GROUP[cap.scope](entry);
149
160
  const now = counted(cap, group);
150
- if (now.some((x) => x.card === entry.card)) continue;
161
+ const bySlot = cap.counts === 'sessions' && cap.where.dispatch === true;
162
+ if (now.some((x) => x.card === entry.card && (!bySlot || x.slot === entry.slot))) continue;
151
163
  const reserved = cap.reserve && !matches(cap.reserve.for, entry) && waiting.some((w) => w.card !== entry.card && matches(cap.reserve.for, w) && GROUP[cap.scope](w) === group && !now.some((x) => x.card === w.card))
152
164
  ? Number(evaluate(cap.reserve.slots, scope) ?? 0) : 0;
153
165
  if (now.length >= Number(max) - reserved) {
@@ -160,11 +172,13 @@ export function capacity(caps, held, evaluate) {
160
172
  },
161
173
  /**
162
174
  * Count `entry` as started: its run, its card, its session (in a turn from its first prompt) and its start. What the card
163
- * already holds is not counted again: the run takes the place its card holds.
175
+ * already holds is not counted again: the run takes the place its card holds. Dispatcher sessions are retained
176
+ * separately by slot so two reviews started in one round cannot share one place.
164
177
  */
165
178
  take(entry) {
166
179
  for (const counts of ['runs', 'cards', 'sessions', 'turns', 'starts']) {
167
- const had = things.find((x) => x.card === entry.card && x.counts === counts);
180
+ const had = things.find((x) => x.card === entry.card && x.counts === counts &&
181
+ (!trackDispatcherSlots || !entry.dispatch || !['sessions', 'turns'].includes(counts) || (x.dispatch && x.slot === entry.slot)));
168
182
  // the card is counted already: it is now in the status its start moved it to
169
183
  // the start is the card's next run, in its assignee's harness and model now (a card reassigned from Codex to
170
184
  // Claude Code counts against Claude Code's caps from this start, not as the harness its last session ran)
@@ -44,7 +44,7 @@ import { fileURLToPath } from 'node:url';
44
44
  import { view, boardCopyOf, boardPath, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, openBoard, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
45
45
  import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
46
46
  import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
47
- import { capacity, harnessDefaults, holdings } from './capacity.mjs';
47
+ import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
48
48
  import { evaluate, holds, render } from './expr.mjs';
49
49
  import { identifyNotice } from './notices.mjs';
50
50
  import { pendingRequestMail } from './request-mail.mjs';
@@ -391,6 +391,13 @@ export function lanesOf(profiles = {}) {
391
391
  return out;
392
392
  }
393
393
 
394
+ /** The current run's role can select a different execution surface from the card's assignee. */
395
+ function runLane(db, board, entry, task, lanes) {
396
+ const spec = runSpec(entry, statusOf(db, task), task);
397
+ const profile = spec ? evaluate(entry.workflow.roles?.[spec.role] ?? 'card.assignee', scopeOf(db, board, task, { params: entry.params })) : task.assignee;
398
+ return lanes[profile] ?? {};
399
+ }
400
+
394
401
  // The latest presence reading (observeFleet). A machine that did not answer it (offline, or unread this round) is not
395
402
  // asked again until the next reading: each remote call would wait out its timeout, and one silent machine held every
396
403
  // close and launch on this one. Its door answers `machine_offline`, which every caller tries again next round.
@@ -754,7 +761,8 @@ async function homeHoldings(root, slugs, lanes, fleet, entry) {
754
761
  // each run's session read before it is counted live or not (unknown stays unknown)
755
762
  for (const r of b.db.prepare('SELECT r.metadata FROM tasks t JOIN task_runs r ON r.id = t.current_run_id').all()) await readBefore(fleet, json(r.metadata, {})?.supercode);
756
763
  const storedOf = (id) => storedStatus(b.db, b, entry, id).stored;
757
- out.push(...holdings(b, { lanes, fleetOk: fleet.ok, alive: (s) => sessionAlive(fleet, s), liveRecord: live, modelOf: transcriptModel, storedOf, machineOf: normalMachine, home: localMachine() }));
764
+ out.push(...holdings(b, { lanes, fleetOk: fleet.ok, alive: (s) => sessionAlive(fleet, s), liveRecord: live, modelOf: transcriptModel, storedOf, machineOf: normalMachine, home: localMachine(),
765
+ runLaneOf: row => runLane(b.db, b, entry, getTask(b.db, row.id), lanes) }));
758
766
  }
759
767
  finally { b.close(); }
760
768
  } catch { /* its own tick reports it */ }
@@ -851,7 +859,7 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
851
859
  if (!startable.has(state.status)) { waiting(task, `no ${d.start} transition from ${state.status}`); continue; }
852
860
  const plan = planOf(task, scopeFor(task));
853
861
  if (!plan) { waiting(task, `start guard refused: ${transitionsFor(workflow, state.status, d.start).map(t => t.guard ?? 'sender role').join(' | ')}`); continue; }
854
- const scope = scopeFor(task, { lane: plan.lane ?? {}, run: { surface: plan.lane?.surface ?? null, local: plan.lane?.surface !== 'pane' || normalMachine(task.machine ?? '') === localMachine() } });
862
+ const scope = scopeFor(task, { lane: plan.lane ?? {}, run: { surface: plan.lane?.surface ?? null, local: executionHost(plan.lane, task.machine, localMachine(), normalMachine) === localMachine() } });
855
863
  if (!holds(d.eligible ?? null, scope)) { waiting(task, `dispatch eligibility refused (memory ${pressure}, local starts ${tickState.local_started}): ${d.eligible}`); continue; }
856
864
  candidates.push({ task, plan, scope });
857
865
  }
@@ -867,7 +875,8 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
867
875
  });
868
876
  // the caps, over what the home holds now; what each candidate would be once started
869
877
  const caps = capacity(d.capacity, held, evaluate);
870
- const entryOf = (c) => ({ card: c.task.id, board: board.slug, machine: normalMachine(c.task.machine ?? localMachine()), assignee: c.plan.profile ?? null,
878
+ const entryOf = (c) => ({ card: c.task.id, board: board.slug, machine: executionHost(c.plan.lane, c.task.machine, localMachine(), normalMachine), assignee: c.plan.profile ?? null,
879
+ dispatch: true, slot: String(templateValue(c.plan.runSpec?.session ?? 'worker', c.scope) ?? 'worker'),
871
880
  status: c.plan.target, stored: c.plan.target, model: c.task.model_override ?? c.plan.lane?.model ?? held.defaults?.[c.plan.lane?.harness] ?? null, harness: c.plan.lane?.harness ?? null });
872
881
  const waitingEntries = candidates.map(entryOf);
873
882
  // a dry run (dispatch --dry-run) plans the same way and writes nothing: no notes, no moves, no launches
@@ -882,7 +891,7 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
882
891
  if (!why) why = launcherCheck(board.db, task, plan.lane, fleet);
883
892
  if (why) { note(task.id, why); report.queued.push([task.id, why]); continue; }
884
893
  if (dry) {
885
- report.would.push({ id: task.id, status: plan.target, role: plan.runSpec?.role ?? null, assignee: plan.profile, machine: task.machine ?? null, surface: plan.lane.surface });
894
+ report.would.push({ id: task.id, status: plan.target, role: plan.runSpec?.role ?? null, assignee: plan.profile, machine: waitingEntries[i].machine, surface: plan.lane.surface });
886
895
  caps.take(waitingEntries[i]);
887
896
  continue;
888
897
  }
@@ -895,9 +904,13 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
895
904
  // still waits where it did and still fits.
896
905
  const now = getTask(db, task.id);
897
906
  if (!now || now.current_run_id || now.archived || statusOf(db, now) !== scope.card.status) throw Object.assign(new Error('started or moved by another round'), { claimedElsewhere: true });
898
- for (const row of db.prepare('SELECT t.id, t.assignee, t.model_override, c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.current_run_id IS NOT NULL').all()) {
907
+ for (const row of db.prepare('SELECT t.id, t.assignee, t.model_override, c.machine, r.metadata FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id JOIN task_runs r ON r.id=t.current_run_id WHERE t.current_run_id IS NOT NULL').all()) {
899
908
  const lane = lanes[row.assignee] ?? {}, status = statusOf(db, row);
900
- caps.take({ card: row.id, board: board.slug, machine: normalMachine(row.machine ?? localMachine()), assignee: row.assignee ?? null, status, stored: status, model: row.model_override ?? lane.model ?? null, harness: lane.harness ?? null });
909
+ const session = json(row.metadata, {})?.supercode ?? {};
910
+ const host = session.surface === 'headless' ? localMachine() : session.machine || session.surface ? normalMachine(session.machine ?? row.machine ?? localMachine())
911
+ : executionHost(runLane(db, board, entry, getTask(db, row.id), lanes), row.machine, localMachine(), normalMachine);
912
+ caps.take({ card: row.id, board: board.slug, machine: host, assignee: row.assignee ?? null, status, stored: status, model: row.model_override ?? lane.model ?? null, harness: lane.harness ?? null,
913
+ dispatch: dispatched(session), slot: session.slot ?? 'worker' });
901
914
  }
902
915
  const refused = caps.admit(waitingEntries[i], scope, waitingEntries);
903
916
  if (refused) throw Object.assign(new Error(refused.why), { claimedElsewhere: true });
@@ -921,7 +934,7 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
921
934
  try {
922
935
  const session = await launchRun(board, entry, task.id, plan.lane, fleet, settings);
923
936
  if (session) {
924
- if (session.surface !== 'pane' || normalMachine(task.machine ?? '') === localMachine()) tickState.local_started += 1;
937
+ if (normalMachine(session.machine ?? localMachine()) === localMachine()) tickState.local_started += 1;
925
938
  report.started.push([task.id, session.address ?? `pid ${session.pid}`, session.mode]);
926
939
  }
927
940
  } catch (error) {
@@ -1001,6 +1014,20 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
1001
1014
  // the run's session record knows its status, role and slot (a reviewer's slot is never the implementer's); the slot
1002
1015
  // is a template, so a step can ask for isolation by naming a new one
1003
1016
  const slot = String(templateValue(spec.session ?? 'worker', scope) ?? 'worker');
1017
+ // Explicit starts and deferred/resumed launches reach this path without startCards. Recheck the dispatcher-session
1018
+ // caps here too, excluding this attempt's own reservation. Other cap kinds keep their existing admission rules.
1019
+ const sessionCaps = entry.workflow.dispatch.capacity?.filter(cap => cap.counts === 'sessions' && cap.where?.dispatch === true) ?? [];
1020
+ if (sessionCaps.length) {
1021
+ const held = await homeHoldings(board.root, listBoards(board.root), lanesOf(entry.profiles), fleet, entry);
1022
+ const others = held.filter(x => !(x.board === board.slug && x.run === runId));
1023
+ const refused = capacity(sessionCaps, others, evaluate).admit({ card: id, board: board.slug, machine: executionHost(lane, task.machine, localMachine(), normalMachine),
1024
+ assignee: lane.name ?? task.assignee, status, stored: scope.card.stored ?? status, model: task.model_override ?? lane.model ?? null, harness: lane.harness,
1025
+ dispatch: true, slot }, { ...scope, lane });
1026
+ if (refused) {
1027
+ runTx(board, id, runId, db => setRunSession(db, runId, { status, role: spec.role, slot, launched_at: now(), launch_deferred: true, launch_deferred_error: refused.why }));
1028
+ throw Object.assign(new Error(refused.why), { launchPending: true });
1029
+ }
1030
+ }
1004
1031
  if (spec.role === 'reviewer') {
1005
1032
  const worker = view(board, (db) => lastSession(db, id, 'worker'));
1006
1033
  const reviewer = view(board, (db) => lastSession(db, id, slot));
@@ -1024,7 +1051,7 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
1024
1051
  prompt=`${agentPreamble(agent,{previous:previous.map(item=>({session:item.session,machine:item.machine,ended_at:item.ended_at,end_reason:item.end_reason}))})}\n\n${prompt}`;
1025
1052
  Object.assign(meta,{agent_id:agent.identity.id,agent_org:team.org,agent_context:context??null,
1026
1053
  agent_profile_intent:structuredClone(agent.intended.resolved),
1027
- agent_env:agentEnvironment(agent,{root:board.root,context:normalMachine(task.machine??localMachine())===localMachine()?localReaderContext(team.org,agent.identity.id)??undefined:undefined})});
1054
+ agent_env:agentEnvironment(agent,{root:board.root,context:executionHost(lane,task.machine,localMachine(),normalMachine)===localMachine()?localReaderContext(team.org,agent.identity.id)??undefined:undefined})});
1028
1055
  // The agent's own memory reaches every session of it: headless runs open its home; a pane, which may sit on
1029
1056
  // another machine, gets the home's snapshot in its run's brief. A shared profile's memory is never used.
1030
1057
  const agentHome=materializeAgentHome(board.root,team.org,agent.identity.id,lane);
@@ -1699,7 +1726,7 @@ export async function mergeReviewed(board, e) {
1699
1726
  function launcherCheck(db, task, lane, fleet) {
1700
1727
  if (lane.surface !== 'pane') return null;
1701
1728
  if (!task.machine) return 'the card names no machine; a pane session runs on the machine its card names';
1702
- const machine = normalMachine(task.machine);
1729
+ const machine = executionHost(lane, task.machine, localMachine(), normalMachine);
1703
1730
  if (!fleet.ok) return `supercode could not read the fleet: ${fleet.error}`;
1704
1731
  if (fleet.unknown?.has(machine)) return `machine ${machine}'s door did not answer; the card starts when it does`;
1705
1732
  // a machine is scheduled only when it can take a board launch: its board process guard runs this harness, and its
@@ -1991,7 +2018,7 @@ async function reconcileLaunches(board, fleet, report, entry, effects, settings,
1991
2018
  }
1992
2019
 
1993
2020
  async function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'worker', meta = {} } = {}) {
1994
- const machine = normalMachine(task.machine);
2021
+ const machine = executionHost(lane, task.machine, localMachine(), normalMachine);
1995
2022
  // Whether a board session opens a terminal window on its machine is the workflow's choice, stated on every launch;
1996
2023
  // no machine default decides it.
1997
2024
  const window = workflowFor(board.root).params.session_window;
@@ -2247,7 +2274,7 @@ async function launchHeadless(board, task, runId, lane, settings, { prompt = nul
2247
2274
  },
2248
2275
  });
2249
2276
  child.unref();
2250
- const session = { surface: 'headless', pid: child.pid, machine: localMachine(), mode: 'fresh', launched_at: now(), exit_file: exitFile, launch_secret: createHash('sha256').update(launchSecret).digest('hex'), ...meta };
2277
+ const session = { surface: 'headless', pid: child.pid, machine: executionHost(lane, task.machine, localMachine(), normalMachine), mode: 'fresh', launched_at: now(), exit_file: exitFile, launch_secret: createHash('sha256').update(launchSecret).digest('hex'), ...meta };
2251
2278
  // recorded against this run only while it is still the card's current one; a run ended meanwhile gets no worker
2252
2279
  const recorded = tx(board, (db) => {
2253
2280
  if (!runCurrent(db, task.id, runId)) return false;
package/ontology.d.ts CHANGED
@@ -178,6 +178,8 @@ export type CapCount =
178
178
 
179
179
  /** Which cards and sessions a cap is about: each key given narrows it; a key's names are alternatives. */
180
180
  export interface CapFilter {
181
+ /** Whether the session was started or resumed by the dispatcher, rather than attached by hand. */
182
+ dispatch?: boolean | null;
181
183
  /** The card itself, by id. */
182
184
  card?: Names;
183
185
  /** The model a session runs (the card's model, else its profile's). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.193",
3
+ "version": "0.5.195",
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": {
@@ -30,7 +30,7 @@
30
30
  "dependencies": {
31
31
  "@volter/supercode-terminal": "^0.3.8",
32
32
  "@volter/supercode-harness-sdk": "^0.3.101",
33
- "@volter/teams": "0.2.89",
33
+ "@volter/teams": "0.2.90",
34
34
  "@volter/ztrack": "^2.4.23",
35
35
  "yaml": "^2.9.0"
36
36
  },