@volter/supercode-orchestrator 0.4.155 → 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.
- package/board/capacity.mjs +171 -0
- package/board/ci.mjs +4 -2
- package/board/cli.mjs +21 -1
- package/board/dispatch.mjs +98 -60
- package/package.json +1 -1
|
@@ -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/ci.mjs
CHANGED
|
@@ -181,8 +181,8 @@ export function openReviewPullRequests(board, id) {
|
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
/** An arc that changed no code (a disposition, such as closing a PR with its reason): its work branch holds no commit
|
|
184
|
-
* beyond its repository's default branch, locally or on origin,
|
|
185
|
-
* other branch. Answers what its review reads instead of a diff, or null when the arc carries (or may carry) code. */
|
|
184
|
+
* beyond its repository's default branch, locally or on origin, its checkout holds no uncommitted work, no PR was
|
|
185
|
+
* ever opened from it, and it registered no other branch. Answers what its review reads instead of a diff, or null when the arc carries (or may carry) code. */
|
|
186
186
|
export function noCodeChange(board, id) {
|
|
187
187
|
const own = repositoryWorkspace(board, id);
|
|
188
188
|
if (!own) return null;
|
|
@@ -193,6 +193,8 @@ export function noCodeChange(board, id) {
|
|
|
193
193
|
const base = run('git', ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], own);
|
|
194
194
|
if (!repository || !branch || !base || branch === base.replace(/^origin\//, '')) return null;
|
|
195
195
|
if (registeredBranches(board, id).some((r) => r.repository !== repository || r.branch !== branch)) return null;
|
|
196
|
+
// Uncommitted work in the checkout is code not yet committed: a dirty worktree is never no-code.
|
|
197
|
+
if (run('git', ['status', '--porcelain', '--untracked-files=normal'], own)) return null;
|
|
196
198
|
// An unknown local or remote commit is code this checkout cannot see: run() throws and the arc waits.
|
|
197
199
|
if (run('git', ['for-each-ref', '--format=%(refname)', `refs/heads/${branch}`], own)) run('git', ['merge-base', '--is-ancestor', `refs/heads/${branch}`, base], own);
|
|
198
200
|
const remote = run('git', ['ls-remote', 'origin', `refs/heads/${branch}`], own).split(/\s+/)[0];
|
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
|
package/board/dispatch.mjs
CHANGED
|
@@ -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.
|
|
468
|
-
*
|
|
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,
|
|
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
|
|
522
|
-
const
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
543
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1943
|
-
|
|
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
|
-
|
|
1979
|
-
|
|
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.
|
|
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": {
|