@volter/supercode-orchestrator 0.4.156 → 0.4.157

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.
@@ -0,0 +1,171 @@
1
+ // The board's capacity language, counted (docs/adr/0009-board-capacity.md). A workflow's `dispatch.capacity` lists its
2
+ // caps; each names where it keeps one count (`scope`), what it counts (`counts`), which cards and sessions it is about
3
+ // (`where`, less `unless`) and its `max`. This module counts what a home holds now and answers whether one more card may start.
4
+
5
+ import { json } from './store.mjs';
6
+ import { statusOf } from './workflow.mjs';
7
+
8
+ /** The scope's group a counted thing or a card falls in. */
9
+ const GROUP = {
10
+ global: () => 'home',
11
+ board: (x) => x.board,
12
+ machine: (x) => x.machine,
13
+ profile: (x) => x.assignee,
14
+ };
15
+
16
+ /** The fleet statuses of a session in a turn (`waiting`: a turn stopped on a question it asked). */
17
+ const IN_TURN = new Set(['busy', 'waiting']);
18
+
19
+ /** Whether `thing` (a counted card or session, or a card being started) is one the filter is about. */
20
+ export function matches(filter, thing) {
21
+ for (const [key, names] of Object.entries(filter ?? {})) {
22
+ const list = Array.isArray(names) ? names : [names];
23
+ if (list.length && !list.includes(thing[key] ?? null)) return false;
24
+ }
25
+ return true;
26
+ }
27
+
28
+ /** Whether a cap is about `thing`: its `where` matches and its `unless` (when it names anything) does not. */
29
+ const applies = (cap, thing) => matches(cap.where, thing) && !(Object.keys(cap.unless ?? {}).length && matches(cap.unless, thing));
30
+
31
+ /** Each slot's latest recorded session on a card (implementer `worker`, `reviewer`, …), newest run first. */
32
+ function sessionsOf(db, taskId) {
33
+ 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)) {
35
+ const s = json(row.metadata, {})?.supercode;
36
+ const slot = s?.slot ?? 'worker';
37
+ if (s?.session_id && !out.has(slot)) out.set(slot, s);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /**
43
+ * What one board holds now, as counted things: each card with a live run (`runs`), each card in any status but
44
+ * archived, once (`cards`; a cap's `where` names the statuses it counts), each live session its cards hold in any slot (`sessions`, those in a turn also
45
+ * `turns`). `alive(session)` and `liveRecord(session)` read the fleet; `modelOf(session)` is the model the session's
46
+ * own transcript reports it runs, or null where none can be read; `machineOf` normalises a machine name; `lanes` gives
47
+ * a profile's harness and model.
48
+ */
49
+ export function holdings(board, { lanes, fleetOk, alive, liveRecord, modelOf = () => null, machineOf }) {
50
+ const out = [];
51
+ const rows = board.db.prepare("SELECT t.id, t.status, t.assignee, t.current_run_id, t.model_override, c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.status != 'archived'").all();
52
+ for (const row of rows) {
53
+ const lane = lanes[row.assignee] ?? {};
54
+ const card = {
55
+ card: row.id, board: board.slug, assignee: row.assignee ?? null, status: statusOf(board.db, row),
56
+ machine: row.machine ? machineOf(row.machine) : null,
57
+ model: row.model_override ?? lane.model ?? null, harness: lane.harness ?? null,
58
+ };
59
+ // a session's model: what its transcript reports, else what its launch resolved, else the card's
60
+ const sessionOf = (s, slot) => {
61
+ const configured = s.model ?? card.model;
62
+ return { ...card, counts: 'sessions', slot, session: s.session_id ?? null, unconfigured: !configured,
63
+ machine: s.machine ? machineOf(s.machine) : card.machine, harness: s.harness ?? card.harness ?? 'claude-code',
64
+ model: (s.session_id ? modelOf(s) : null) ?? configured };
65
+ };
66
+ const sessions = fleetOk ? sessionsOf(board.db, row.id) : new Map();
67
+ const held = [];
68
+ let current = null;
69
+ if (row.current_run_id) {
70
+ current = json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.current_run_id)?.metadata, {})?.supercode ?? {};
71
+ out.push({ ...sessionOf(current, current.slot ?? 'worker'), session: undefined, slot: undefined, counts: 'runs' });
72
+ }
73
+ for (const [slot, s] of sessions) {
74
+ if (!alive(s)) continue;
75
+ const session = sessionOf(s, slot);
76
+ held.push(session);
77
+ if (IN_TURN.has(liveRecord(s)?.status)) out.push({ ...session, counts: 'turns' });
78
+ }
79
+ // a live run whose session the fleet does not list yet (launching) holds a session's place already
80
+ if (current && !held.some((x) => x.slot === (current.slot ?? 'worker'))) held.unshift(sessionOf(current, current.slot ?? 'worker'));
81
+ out.push(...held);
82
+ // the card itself, whatever its runs and sessions: a cap says which by its status (a started card holds its slot
83
+ // until it is done); its model and harness are its run's session's, else its newest live session's, else its own
84
+ out.push({ ...(held[0] ?? { ...card, unconfigured: !card.model }), session: undefined, slot: undefined, counts: 'cards' });
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /**
90
+ * The model each harness runs when nothing names one (the harness's own default, which its configuration may give only
91
+ * as an alias such as `opus`): what the transcripts of sessions started with no model report, the most of them. Each
92
+ * counted thing with no model of its own reads it, and so does a card about to start (`held.defaults`).
93
+ */
94
+ export function harnessDefaults(held) {
95
+ const seen = {};
96
+ for (const x of held) {
97
+ if (x.counts !== 'sessions' || !x.unconfigured || !x.model) continue;
98
+ (seen[x.harness] ??= new Map()).set(x.model, (seen[x.harness].get(x.model) ?? 0) + 1);
99
+ }
100
+ const defaults = Object.fromEntries(Object.entries(seen).map(([h, m]) => [h, [...m].sort((a, b) => b[1] - a[1])[0][0]]));
101
+ for (const x of held) if (!x.model && x.harness && defaults[x.harness]) x.model = defaults[x.harness];
102
+ return defaults;
103
+ }
104
+
105
+ /**
106
+ * The caps of a workflow over what a home holds (`held`: every board's `holdings`). `evaluate(expr, scope)` gives a
107
+ * cap's max (and a reserve's slots) for the card being started. `admit` says whether that card may start and, when not,
108
+ * which cap holds it and why; `take` counts it once it starts.
109
+ */
110
+ export function capacity(caps, held, evaluate) {
111
+ const list = (caps ?? []).map((cap) => ({ ...cap, where: cap.where ?? {} }));
112
+ // shared by every board of a tick, so what one board starts counts on the next
113
+ const things = held;
114
+ const counted = (cap, group) => things.filter((x) => x.counts === cap.counts && applies(cap, x) && GROUP[cap.scope](x) === group);
115
+ return {
116
+ caps: list,
117
+ /** Each cap's groups now: `{ name, scope, counts, where, groups: [{ group, count, cards }] }`. */
118
+ view() {
119
+ return list.map((cap) => {
120
+ const groups = new Map(cap.scope === 'global' ? [['home', []]] : []);
121
+ for (const x of things) {
122
+ if (x.counts !== cap.counts || !applies(cap, x)) continue;
123
+ const g = GROUP[cap.scope](x);
124
+ if (!groups.has(g)) groups.set(g, []);
125
+ groups.get(g).push(x.card);
126
+ }
127
+ return { name: cap.name, scope: cap.scope, counts: cap.counts, where: cap.where, unless: cap.unless ?? {}, max: cap.max,
128
+ groups: [...groups].map(([group, cards]) => ({ group, count: cards.length, cards })) };
129
+ });
130
+ },
131
+ /**
132
+ * Whether `entry` (the card about to start: `{ card, board, machine, assignee, status, model, harness }`) may
133
+ * start. `scope` evaluates each max; `waiting` is every card still to be planned this tick, for reserves (a waiting card
134
+ * the cap counts already takes no new place, so none is held for it). A card
135
+ * that already holds a place in a count (its implementer's live session while its review starts) keeps it: that
136
+ * cap does not hold it.
137
+ */
138
+ admit(entry, scope, waiting = []) {
139
+ for (const cap of list) {
140
+ if (!applies(cap, entry)) continue;
141
+ const max = evaluate(cap.max, scope);
142
+ if (max === null || max === undefined || max === '') continue;
143
+ const group = GROUP[cap.scope](entry);
144
+ const now = counted(cap, group);
145
+ if (now.some((x) => x.card === entry.card)) continue;
146
+ 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))
147
+ ? Number(evaluate(cap.reserve.slots, scope) ?? 0) : 0;
148
+ if (now.length >= Number(max) - reserved) {
149
+ const where = cap.scope === 'global' ? 'across the home' : `on ${cap.scope} ${group ?? '(none)'}`;
150
+ return { cap: cap.name, count: now.length, max: Number(max), reserved,
151
+ why: `waits on cap "${cap.name}": ${now.length} of ${Number(max)} ${cap.counts} ${where}${reserved ? `, ${reserved} held for ${describe(cap.reserve.for)}` : ''}` };
152
+ }
153
+ }
154
+ return null;
155
+ },
156
+ /**
157
+ * Count `entry` as started: its run, its card, its session (in a turn from its first prompt) and its start. What the card
158
+ * already holds is not counted again: the run takes the place its card holds.
159
+ */
160
+ take(entry) {
161
+ for (const counts of ['runs', 'cards', 'sessions', 'turns', 'starts']) {
162
+ const had = things.find((x) => x.card === entry.card && x.counts === counts);
163
+ // the card is counted already: it is now in the status its start moved it to
164
+ if (had) { if (counts === 'cards') Object.assign(had, { status: entry.status, model: had.model ?? entry.model, harness: had.harness ?? entry.harness }); }
165
+ else things.push({ ...entry, counts });
166
+ }
167
+ },
168
+ };
169
+ }
170
+
171
+ const describe = (filter) => Object.entries(filter ?? {}).map(([k, v]) => `${k} ${[].concat(v).join(' or ')}`).join(', ') || 'other cards';
package/board/cli.mjs CHANGED
@@ -42,6 +42,7 @@ Hermes Kanban's verbs, with their flags:
42
42
  decompose <id> [--child "<title>[@<profile>]"]... [--all]
43
43
  diagnostics [--task] [--severity] stats assignees log <id> [--tail N] tail <id> watch [--assignee] [--kinds]
44
44
  notify-subscribe <id> --platform P --chat-id C [--thread-id] notify-list [<id>] notify-unsubscribe <id> --platform P --chat-id C
45
+ capacity (each cap's count against its max, and why each card waits; docs/adr/0009-board-capacity.md)
45
46
  dispatch [--dry-run] [--max N] [--failure-limit N] daemon [--interval S] [--max N] [--failure-limit N] [--pidfile F] [--verbose] gc [--event-retention-days] [--log-retention-days] repair
46
47
  pause [reason] · resume (Hermes's emergency stop, the ESTOP file: new starts stop, running sessions go on)
47
48
 
@@ -768,6 +769,25 @@ export async function runWorkflow(argv) {
768
769
  else for (const r of reports) process.stdout.write(`${summary(r)}\n`);
769
770
  return reports.some((r) => r.error) ? 1 : 0;
770
771
  }
772
+ case 'capacity': {
773
+ // the home's caps as the dispatcher counts them now, and what a tick would start or hold, writing nothing
774
+ const { lanes, settings } = await laneState(root);
775
+ const reports = await plan({ root, lanes, settings });
776
+ const out = { capacity: reports.capacity, would: reports.flatMap((r) => r.would.map((w) => w.id)), waiting: reports.flatMap((r) => r.queued.map(([id, why]) => ({ id, why }))) };
777
+ if (args.json) { say(args, '', out); return 0; }
778
+ const lines = [];
779
+ for (const cap of out.capacity) {
780
+ const filter = Object.entries(cap.where ?? {}).map(([k, v]) => `${k} ${[].concat(v).join('|')}`).join(', ');
781
+ const except = Object.entries(cap.unless ?? {}).map(([k, v]) => `${k} ${[].concat(v).join('|')}`).join(', ');
782
+ lines.push(`cap "${cap.name}": ${cap.counts} per ${cap.scope}${filter ? `, where ${filter}` : ''}${except ? `, unless ${except}` : ''}, max ${cap.max}`);
783
+ if (!cap.groups.length) lines.push(' (nothing counted)');
784
+ for (const g of cap.groups) lines.push(` ${cap.scope === 'global' ? 'home' : g.group ?? '(none)'}: ${g.count} of ${g.max ?? 'no max'} ${[...new Set(g.cards)].join(' ')}`);
785
+ }
786
+ lines.push(out.would.length ? `would start: ${out.would.join(' ')}` : 'would start: nothing');
787
+ for (const w of out.waiting) lines.push(`${w.id} ${w.why}`);
788
+ process.stdout.write(`${lines.join('\n')}\n`);
789
+ return 0;
790
+ }
771
791
  case 'daemon': case 'serve': return serve(root, args);
772
792
 
773
793
  case 'gc': {
@@ -1094,7 +1114,7 @@ function ownCard(db, sessionId) {
1094
1114
  return engine.lastSession(db, hit.task_id)?.session_id === sessionId ? engine.getTask(db, hit.task_id) : null;
1095
1115
  }
1096
1116
 
1097
- const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'notify-list', 'help', 'init']);
1117
+ const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'notify-list', 'capacity', 'help', 'init']);
1098
1118
 
1099
1119
  /**
1100
1120
  * Who a caller is on card `cardId`, in the workflow's roles: a caller with no session behind it has no role (the
@@ -18,14 +18,15 @@
18
18
  import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
19
19
  import { createRequire } from 'node:module';
20
20
  import { spawn as spawnChild, spawnSync } from 'node:child_process';
21
- import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
22
- import { hostname, tmpdir, totalmem } from 'node:os';
21
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
22
+ import { homedir, hostname, tmpdir, totalmem } from 'node:os';
23
23
  import { basename, dirname, join } from 'node:path';
24
24
  import { createHash, randomUUID } from 'node:crypto';
25
25
  import { fileURLToPath } from 'node:url';
26
26
  import { view, boardCopyOf, boardPath, claimer, copyRefusal, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
27
27
  import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, templateValue, workflowFor } from './workflow.mjs';
28
28
  import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
29
+ import { capacity, harnessDefaults, holdings } from './capacity.mjs';
29
30
  import { evaluate, holds, render } from './expr.mjs';
30
31
  import { identifyNotice } from './notices.mjs';
31
32
  import { pendingRequestMail } from './request-mail.mjs';
@@ -462,13 +463,70 @@ export function whereOf(board, task) {
462
463
  // what the platform tells expressions about this machine and this tick
463
464
  const hostOf = (pressure) => ({ memory_pressure: pressure, max_in_progress: derivedMaxInProgress() });
464
465
 
466
+ /** What every board of the home at `root` holds now, counted for its caps (capacity.mjs). */
467
+ function homeHoldings(root, slugs, lanes, fleet) {
468
+ const live = (s) => fleet.sessions?.find((x) => x.address === addressOf(s)) ?? null;
469
+ const out = [];
470
+ for (const slug of slugs) {
471
+ try {
472
+ const b = openBoard(root, slug);
473
+ try { b.slug ??= slug; out.push(...holdings(b, { lanes, fleetOk: fleet.ok, alive: (s) => sessionAlive(fleet, s), liveRecord: live, modelOf: transcriptModel, machineOf: normalMachine })); }
474
+ finally { b.close(); }
475
+ } catch { /* its own tick reports it */ }
476
+ }
477
+ out.defaults = harnessDefaults(out);
478
+ return out;
479
+ }
480
+
481
+ // where each session's transcript is, once found (a session's file does not move)
482
+ const transcripts = new Map();
483
+
484
+ /**
485
+ * The model a session on this machine runs, as its own transcript reports it (Claude Code's assistant messages and
486
+ * Codex's turn contexts both carry `"model"`): the last one written, so a model changed mid-session is the one read.
487
+ * Null for another machine's session, or one whose transcript cannot be found or names none.
488
+ */
489
+ function transcriptModel(session) {
490
+ if (!session?.session_id || normalMachine(session.machine ?? localMachine()) !== localMachine()) return null;
491
+ const sid = session.session_id;
492
+ let file = transcripts.get(sid);
493
+ if (file === undefined) {
494
+ file = null;
495
+ try {
496
+ if ((session.harness ?? 'claude-code') === 'claude-code') {
497
+ const projects = join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects');
498
+ for (const dir of readdirSync(projects)) { const f = join(projects, dir, `${sid}.jsonl`); if (existsSync(f)) { file = f; break; } }
499
+ } else if (session.harness === 'codex') {
500
+ // Codex files its rollouts by day: sessions/YYYY/MM/DD/rollout-<time>-<id>.jsonl, newest days first
501
+ const root = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions');
502
+ const sorted = (dir) => readdirSync(dir).sort().reverse();
503
+ search: for (const y of sorted(root)) for (const m of sorted(join(root, y))) for (const d of sorted(join(root, y, m))) {
504
+ const hit = readdirSync(join(root, y, m, d)).find((f) => f.endsWith(`-${sid}.jsonl`));
505
+ if (hit) { file = join(root, y, m, d, hit); break search; }
506
+ }
507
+ }
508
+ } catch { file = null; }
509
+ if (file) transcripts.set(sid, file);
510
+ }
511
+ if (!file) return null;
512
+ try {
513
+ const size = statSync(file).size;
514
+ const fd = openSync(file, 'r');
515
+ const length = Math.min(size, 512 * 1024);
516
+ const buf = Buffer.alloc(length);
517
+ try { readSync(fd, buf, 0, length, size - length); } finally { closeSync(fd); }
518
+ const models = [...buf.toString('utf8').matchAll(/"model":"([^"]+)"/g)].map((m) => m[1]).filter((m) => !m.startsWith('<'));
519
+ return models.at(-1) ?? null;
520
+ } catch { return null; }
521
+ }
522
+
465
523
  /**
466
524
  * Start what the workflow's dispatcher may start this tick: cards whose status has a transition for `dispatch.start`,
467
- * that `dispatch.eligible` admits, in `dispatch.order`, under `dispatch.limits` (a cap per group, slots reserved for
468
- * the cards a reserve names while any waits). Each is sent the start event (its validators are Hermes's respawn
469
- * guard) and what its new status runs is launched.
525
+ * that `dispatch.eligible` admits, in `dispatch.order`, under `dispatch.capacity` (the capacity language: every cap
526
+ * that matches a card must have room; capacity.mjs). Each is sent the start event (its validators are Hermes's respawn
527
+ * guard) and what its new status runs is launched. `held` is what the home holds (homeHoldings), shared by its boards.
470
528
  */
471
- function startCards(board, fleet, lanes, settings, report, entry, effects, { only = null, hostRunning = 0, dry = false } = {}) {
529
+ function startCards(board, fleet, lanes, settings, report, entry, effects, { only = null, held = [], dry = false } = {}) {
472
530
  const stop = paused(board.root);
473
531
  if (stop) { report.paused = stop; return; }
474
532
  const { workflow, params } = entry;
@@ -518,42 +576,25 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
518
576
  }
519
577
  return 0;
520
578
  });
521
- // the caps: how many counted cards each group holds now
522
- const limits = (d.limits ?? []).map((l) => ({ ...l, tally: new Map() }));
523
- const cardScope = new Map(everyCard.map((c) => [c.id, scopeFor(c, { lane: lanes[c.assignee] ?? {}, fleet_alive: fleet.ok ? sessionAlive(fleet, lastSession(board.db, c.id)) : false })]));
524
- for (const l of limits) {
525
- for (const [, sc] of cardScope) {
526
- sc.card.session_live = sc.fleet_alive;
527
- if (!holds(l.counts, sc)) continue;
528
- const g = JSON.stringify(evaluate(l.group, sc));
529
- l.tally.set(g, (l.tally.get(g) ?? 0) + 1);
530
- }
531
- // the home's other boards count toward a group that is the whole home (kanban.max_in_progress across boards)
532
- const here = [...cardScope.values()].filter((sc) => holds(l.counts, sc)).length;
533
- if (evaluate(l.group, { card: {} }) === 'home') l.tally.set(JSON.stringify('home'), (l.tally.get(JSON.stringify('home')) ?? 0) + Math.max(0, hostRunning - here));
534
- }
535
- const reserveWaits = (l) => l.reserve && candidates.some((c) => holds(l.reserve.for, c.scope));
579
+ // the caps, over what the home holds now; what each candidate would be once started
580
+ const caps = capacity(d.capacity, held, evaluate);
581
+ const entryOf = (c) => ({ card: c.task.id, board: board.slug, machine: c.task.machine ? normalMachine(c.task.machine) : null, assignee: c.plan.profile ?? null,
582
+ status: 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 });
583
+ const waitingEntries = candidates.map(entryOf);
536
584
  // a dry run (dispatch --dry-run) plans the same way and writes nothing: no notes, no moves, no launches
537
585
  const note = (id, why) => { if (!dry) tx(board, (db) => noteGuard(db, id, why)); };
538
- for (const c of candidates) {
586
+ for (const [i, c] of candidates.entries()) {
539
587
  const { task, plan, scope } = c;
540
588
  if (!plan.profile) { report.unassigned.push(task.id); note(task.id, 'no profile runs it (no assignee, and the workflow names none)'); continue; }
541
589
  if (!plan.lane) { report.nonspawnable.push(task.id); note(task.id, `${plan.profile} is no lane of this home`); continue; }
542
- let why = null;
543
- for (const l of limits) {
544
- const max = evaluate(l.max, scope);
545
- if (max === null || max === undefined || max === '') continue;
546
- const g = JSON.stringify(evaluate(l.group, scope));
547
- const held = reserveWaits(l) && !holds(l.reserve.for, scope) ? Number(evaluate(l.reserve.slots, scope) ?? 0) : 0;
548
- if ((l.tally.get(g) ?? 0) >= Number(max) - held) { why = `${l.description ?? 'a cap'} (${max}${held ? `, ${held} held for others` : ''})`; break; }
549
- }
590
+ const capped = caps.admit(waitingEntries[i], scope, waitingEntries);
591
+ let why = capped?.why ?? null;
550
592
  // the launcher's own conditions: a lane that can start this card where it names
551
593
  if (!why) why = launcherCheck(board.db, task, plan.lane, fleet);
552
594
  if (why) { note(task.id, why); report.queued.push([task.id, why]); continue; }
553
595
  if (dry) {
554
596
  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 });
555
- // a card this tick starts counts toward its groups (its scope was read before the start, with no run yet)
556
- for (const l of limits) { const g = JSON.stringify(evaluate(l.group, scope)); l.tally.set(g, (l.tally.get(g) ?? 0) + 1); }
597
+ caps.take(waitingEntries[i]);
557
598
  continue;
558
599
  }
559
600
  let moved, checked;
@@ -575,7 +616,7 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
575
616
  continue;
576
617
  }
577
618
  effects.push(...moved.effects);
578
- for (const l of limits) { const g = JSON.stringify(evaluate(l.group, scope)); l.tally.set(g, (l.tally.get(g) ?? 0) + 1); }
619
+ caps.take(waitingEntries[i]);
579
620
  try {
580
621
  const session = launchRun(board, entry, task.id, plan.lane, fleet, settings);
581
622
  if (session) {
@@ -617,7 +658,8 @@ export function launchRun(board, entry, id, lane, fleet, settings) {
617
658
  const reviewer = view(board, (db) => lastSession(db, id, slot));
618
659
  if (slot === 'worker' || (worker?.session_id && worker.session_id === reviewer?.session_id)) throw new Error('a reviewer must have a different session from the implementer');
619
660
  }
620
- const meta = { status, role: spec.role, slot, ...(spec.role === 'reviewer' ? { review_candidate: reviewCandidate(board, id) } : {}) };
661
+ // the model it runs is recorded with the session, for caps that count by model
662
+ const meta = { status, role: spec.role, slot, model: task.model_override ?? lane.model ?? null, ...(spec.role === 'reviewer' ? { review_candidate: reviewCandidate(board, id) } : {}) };
621
663
  try {
622
664
  const result = surface === 'pane'
623
665
  ? launchPane(board, task, runId, lane, fleet, { input: prompt, slot, meta })
@@ -878,23 +920,7 @@ export async function sessionEffect(board, e) {
878
920
 
879
921
  // ------------------------------------------------------------------ start
880
922
 
881
- const running = (db, sql, ...args) => Number(db.prepare(`SELECT COUNT(*) AS n FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.current_run_id IS NOT NULL ${sql}`).get(...args).n);
882
-
883
- /**
884
- * How many sessions the board has on `machine`: every running card's, and every other card's latest session that is
885
- * still live (an idle session on a done or blocked card holds its place until it is closed).
886
- */
887
- function machineLoad(db, machine, fleet) {
888
- let n = 0;
889
- for (const card of db.prepare("SELECT t.id, t.status, c.machine FROM tasks t JOIN supercode_cards c ON c.task_id = t.id WHERE c.machine IS NOT NULL AND t.status != 'archived'").all()) {
890
- if (normalMachine(card.machine) !== machine) continue;
891
- if (card.current_run_id || (fleet.ok && sessionAlive(fleet, lastSession(db, card.id)))) n += 1;
892
- }
893
- return n;
894
- }
895
-
896
- /** Why a card cannot start now, or null. */
897
- /** What the launcher itself needs to start this card (the caps are the workflow's `dispatch.limits`). */
923
+ /** What the launcher itself needs to start this card (the caps are the workflow's `dispatch.capacity`). */
898
924
  function launcherCheck(db, task, lane, fleet) {
899
925
  if (lane.surface !== 'pane') return null;
900
926
  if (!task.machine) return 'the card names no machine; a pane session runs on the machine its card names';
@@ -1845,13 +1871,18 @@ export async function plan({ root, lanes, settings, slugs = null, params = {} })
1845
1871
  const entry = { ...loaded, params: { ...loaded.params, ...params } };
1846
1872
  const fleet = Object.values(lanes).some((l) => l.surface === 'pane') ? observeFleet() : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
1847
1873
  const out = [];
1874
+ // the home's caps count every board, whichever boards are planned
1875
+ const held = homeHoldings(root, listBoards(root), lanes, fleet);
1876
+ const caps = capacity(entry.workflow.dispatch?.capacity, held, evaluate);
1877
+ // what each cap counts now, before anything this plan would start
1878
+ Object.defineProperty(out, 'capacity', { value: caps.view().map((cap) => ({ ...cap, groups: cap.groups.map((g) => ({ ...g, max: capMax(cap, g, entry, lanes) })) })) });
1848
1879
  for (const slug of slugs ?? listBoards(root)) {
1849
1880
  const report = { board: slug, would: [], queued: [], unassigned: [], nonspawnable: [] };
1850
1881
  const board = openBoard(root, slug);
1851
1882
  try {
1852
1883
  board.label = boardLabel(root, slug);
1853
1884
  await activatePullRequestReviews(board, entry, report, [], { dry: true });
1854
- startCards(board, fleet, lanes, settings, report, entry, [], { dry: true });
1885
+ startCards(board, fleet, lanes, settings, report, entry, [], { dry: true, held });
1855
1886
  } finally { board.close(); }
1856
1887
  out.push(report);
1857
1888
  }
@@ -1924,6 +1955,17 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
1924
1955
  }
1925
1956
  }
1926
1957
 
1958
+ /**
1959
+ * A cap's max for one of its groups, for the capacity view: what a card in that group would be held to. A max read
1960
+ * from the card or its lane (a machine's or a profile's own capacity) is evaluated for that group's machine or profile.
1961
+ */
1962
+ function capMax(cap, group, entry, lanes) {
1963
+ const card = { machine: cap.scope === 'machine' ? group.group : null, assignee: cap.scope === 'profile' ? group.group : null, board: cap.scope === 'board' ? group.group : null };
1964
+ const lane = cap.scope === 'profile' ? lanes[group.group] ?? {} : {};
1965
+ try { const v = evaluate(cap.max, { card, lane, params: entry.params, host: hostOf(memoryPressure()), now: now() }); return v === undefined || v === '' ? null : v; }
1966
+ catch { return null; }
1967
+ }
1968
+
1927
1969
  /**
1928
1970
  * One tick over every board of the home at `root`. `lanes` from `lanesOf`, `settings` from `boardSettings`.
1929
1971
  * `only` limits starting to one card (a create that answers whether its card started). Each board is ticked under its
@@ -1939,11 +1981,8 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
1939
1981
  const fleet = needFleet ? observeFleet() : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
1940
1982
  const fleetMs = Date.now() - fleetAt;
1941
1983
  const boards = slugs ?? listBoards(root);
1942
- // the running cards of every board of the home, for kanban.max_in_progress (host-wide, as Hermes counts it)
1943
- let hostRunning = 0;
1944
- for (const slug of boards) {
1945
- try { const b = openBoard(root, slug); try { hostRunning += Number(b.db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE current_run_id IS NOT NULL").get().n); } finally { b.close(); } } catch { /* its own tick reports it */ }
1946
- }
1984
+ // what every board of the home holds, for caps whose scope is the whole home (kanban.max_in_progress counts so)
1985
+ const held = dispatch ? homeHoldings(root, boards, lanes, fleet) : [];
1947
1986
  const out = [];
1948
1987
  for (const slug of boards) {
1949
1988
  const report = { board: slug, ms: { fleet: fleetMs }, mailed: [], started: [], queued: [], failed: [], lost: [], crashed: [], timed_out: [], reclaimed: [], auto_blocked: [], promoted: 0, unassigned: [], nonspawnable: [], notified: [], undelivered: [], fleet: fleet.ok ? 'read' : fleet.error };
@@ -1975,9 +2014,8 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
1975
2014
  report.promoted = recomputeReady(board, effects, report.unsettled);
1976
2015
  if (dispatch) await activatePullRequestReviews(board, entry, report, effects, { only });
1977
2016
  await sendBoardMessages(board, report);
1978
- const before = report.started.length;
1979
- if (dispatch) startCards(board, fleet, lanes, settings, report, entry, effects, { only, hostRunning });
1980
- hostRunning += report.started.length - before; phase('start');
2017
+ if (dispatch) startCards(board, fleet, lanes, settings, report, entry, effects, { only, held });
2018
+ phase('start');
1981
2019
  if (dispatch && !only && !report.paused) observeIdle(board, fleet, settings, report, entry, effects);
1982
2020
  // what the moves asked for, once they are committed (messages to actors, closed panes, cleaned workspaces)
1983
2021
  await runEffects(board, effects);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.156",
3
+ "version": "0.4.157",
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": {