atris 3.61.0 → 3.62.0
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/README.md +37 -5
- package/atris/skills/engines/SKILL.md +9 -9
- package/bin/atris.js +1 -0
- package/commands/autoland.js +36 -5
- package/commands/autopilot.js +105 -6
- package/commands/engine.js +96 -21
- package/commands/host.js +56 -4
- package/commands/land.js +129 -8
- package/commands/member.js +33 -8
- package/commands/mission.js +112 -3
- package/commands/one-lap.js +14 -10
- package/commands/stream.js +56 -22
- package/commands/team.js +82 -10
- package/commands/worktree.js +39 -0
- package/commands/youtube.js +36 -26
- package/lib/engine-ask.js +112 -9
- package/lib/engine-registry.js +283 -39
- package/lib/fleet.js +444 -26
- package/lib/functional-owner.js +15 -6
- package/lib/host.js +296 -170
- package/lib/member-engine.js +11 -1
- package/lib/member-park.js +138 -0
- package/lib/member-scaffold.js +37 -0
- package/lib/roster-markdown.js +1 -1
- package/lib/roster-prep.js +289 -0
- package/lib/roster-runs.js +389 -0
- package/lib/roster-suggest.js +145 -0
- package/lib/router-brain.js +111 -3
- package/lib/self-drive.js +26 -2
- package/lib/state-detection.js +3 -3
- package/lib/task-db.js +38 -3
- package/lib/team-lineup.js +7 -2
- package/lib/todo.js +1 -1
- package/lib/wish-audit.js +1 -1
- package/package.json +1 -1
- package/scripts/det/cpu-hotspots.js +216 -0
- package/scripts/det/ytnotes +105 -19
- package/scripts/det/ytquote-repair.js +18 -4
- package/templates/members/host/MEMBER.md +5 -3
- package/templates/members/host/MISSION.md +1 -1
- package/templates/members/host/skills/host/SKILL.md +64 -16
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// One line per run launched from a roster pick, so the owner can judge each
|
|
4
|
+
// tool and model on real work instead of memory. A line says when, which job,
|
|
5
|
+
// which member (if any), the engine, model, effort, time cap, seconds taken,
|
|
6
|
+
// and how it ended: landed, failed, stalled, credit out, and who took over
|
|
7
|
+
// when it was handed on. Tokens and cost appear only when the tool printed
|
|
8
|
+
// them in output we already capture; nothing is guessed.
|
|
9
|
+
//
|
|
10
|
+
// Writing never throws into the run that called it, and reading only looks
|
|
11
|
+
// at the tail of each file, skipping any line that does not parse.
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const RUNS_FILE = path.join('.atris', 'state', 'roster_runs.jsonl');
|
|
17
|
+
// Rotated logs sit beside the live one, named roster_runs.<UTC stamp to the
|
|
18
|
+
// millisecond>-<pid>.jsonl so two writers rotating in the same breath can
|
|
19
|
+
// never rename onto each other. The name starts with a digit so the legacy
|
|
20
|
+
// roster_runs.1.jsonl still counts and roster_runs.pending.jsonl does not.
|
|
21
|
+
const ROTATED_NAME = /^roster_runs\.\d.*\.jsonl$/;
|
|
22
|
+
const ROTATED_KEEP = 3;
|
|
23
|
+
const ROTATED_READ = 2;
|
|
24
|
+
const OUTCOMES = Object.freeze(['landed', 'failed', 'stalled', 'credit out']);
|
|
25
|
+
const DEFAULT_DAYS = 7;
|
|
26
|
+
const TAIL_BYTES = 512 * 1024;
|
|
27
|
+
// Past this size the log is renamed aside before the append, never trimmed:
|
|
28
|
+
// rotation moves whole records instead of cutting the oldest ones away.
|
|
29
|
+
const RUNS_ROTATE_BYTES = 2 * 1024 * 1024;
|
|
30
|
+
const JOB_ALIASES = Object.freeze({ navigator: 'search', executor: 'build', builder: 'build', validator: 'review', reviewer: 'review' });
|
|
31
|
+
|
|
32
|
+
function rosterRunsPath(root = process.cwd()) {
|
|
33
|
+
return path.join(root, RUNS_FILE);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Rotated logs, oldest name first; the stamp in each name makes name order
|
|
37
|
+
// the order they were renamed in.
|
|
38
|
+
function rotatedRunsPaths(root = process.cwd()) {
|
|
39
|
+
const dir = path.dirname(path.join(root, RUNS_FILE));
|
|
40
|
+
let names;
|
|
41
|
+
try {
|
|
42
|
+
names = fs.readdirSync(dir);
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
return names.filter((name) => ROTATED_NAME.test(name)).sort().map((name) => path.join(dir, name));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function nowMs(now) {
|
|
50
|
+
if (now instanceof Date) return now.getTime();
|
|
51
|
+
if (typeof now === 'function') return nowMs(now());
|
|
52
|
+
const value = Number(now);
|
|
53
|
+
return Number.isFinite(value) && now !== null && now !== undefined ? value : Date.now();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function clean(value) {
|
|
57
|
+
return String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// "executor", "small-build", and "Small Build" all read as the job's name.
|
|
61
|
+
function jobName(value) {
|
|
62
|
+
const text = clean(value).toLowerCase().replace(/[-_]+/g, ' ');
|
|
63
|
+
return JOB_ALIASES[text] || text;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function wholeNumber(value) {
|
|
67
|
+
const number = Number(String(value == null ? '' : value).replace(/,/g, ''));
|
|
68
|
+
return Number.isFinite(number) && number >= 0 ? Math.round(number) : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function usageTotal(usage) {
|
|
72
|
+
if (!usage || typeof usage !== 'object') return null;
|
|
73
|
+
const parts = ['input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens']
|
|
74
|
+
.map((key) => wholeNumber(usage[key]))
|
|
75
|
+
.filter((value) => value !== null);
|
|
76
|
+
return parts.length ? parts.reduce((sum, value) => sum + value, 0) : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Tokens and cost the tool itself reported, or nothing. Claude's json result
|
|
80
|
+
// carries usage and total_cost_usd; codex prints "tokens used" and the count.
|
|
81
|
+
function parseRunUsage({ usage = null, cost_usd = null, stdout = '', stderr = '' } = {}) {
|
|
82
|
+
const found = {};
|
|
83
|
+
const direct = usageTotal(usage);
|
|
84
|
+
if (direct !== null) found.tokens = direct;
|
|
85
|
+
if (typeof cost_usd === 'number' && Number.isFinite(cost_usd)) found.cost_usd = cost_usd;
|
|
86
|
+
const text = `${String(stdout || '')}\n${String(stderr || '')}`;
|
|
87
|
+
if (found.tokens === undefined) {
|
|
88
|
+
for (const line of text.split(/\r?\n/).reverse()) {
|
|
89
|
+
const trimmed = line.trim();
|
|
90
|
+
if (!trimmed.startsWith('{') || !trimmed.includes('"usage"')) continue;
|
|
91
|
+
try {
|
|
92
|
+
const event = JSON.parse(trimmed);
|
|
93
|
+
const tokens = usageTotal(event && event.usage);
|
|
94
|
+
if (tokens === null) continue;
|
|
95
|
+
found.tokens = tokens;
|
|
96
|
+
if (found.cost_usd === undefined && typeof event.total_cost_usd === 'number') found.cost_usd = event.total_cost_usd;
|
|
97
|
+
break;
|
|
98
|
+
} catch {}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (found.tokens === undefined) {
|
|
102
|
+
const codex = /tokens used[:\s]*([\d,]+)/gi;
|
|
103
|
+
let match;
|
|
104
|
+
let last = null;
|
|
105
|
+
while ((match = codex.exec(text))) last = match[1];
|
|
106
|
+
const tokens = wholeNumber(last);
|
|
107
|
+
if (last && tokens !== null) found.tokens = tokens;
|
|
108
|
+
}
|
|
109
|
+
return found;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// One normalized line. Empty fields stay out so a line only says what we know.
|
|
113
|
+
function normalizeRun(input = {}, { now } = {}) {
|
|
114
|
+
const outcome = OUTCOMES.includes(input.outcome) ? input.outcome : 'failed';
|
|
115
|
+
const record = {
|
|
116
|
+
at: clean(input.at) || new Date(nowMs(now)).toISOString(),
|
|
117
|
+
job: jobName(input.job) || 'build',
|
|
118
|
+
engine: clean(input.engine) || 'unknown',
|
|
119
|
+
outcome,
|
|
120
|
+
};
|
|
121
|
+
const optional = {
|
|
122
|
+
member: clean(input.member),
|
|
123
|
+
model: clean(input.model),
|
|
124
|
+
effort: clean(input.effort),
|
|
125
|
+
task: clean(input.task),
|
|
126
|
+
detail: clean(input.detail).slice(0, 240),
|
|
127
|
+
handed_over_to: clean(input.handed_over_to),
|
|
128
|
+
source: clean(input.source),
|
|
129
|
+
prep: clean(input.prep).slice(0, 160),
|
|
130
|
+
};
|
|
131
|
+
for (const [key, value] of Object.entries(optional)) if (value) record[key] = value;
|
|
132
|
+
const cap = wholeNumber(input.max_seconds);
|
|
133
|
+
if (cap) record.max_seconds = cap;
|
|
134
|
+
const seconds = Number(input.seconds);
|
|
135
|
+
if (Number.isFinite(seconds) && seconds >= 0) record.seconds = Math.round(seconds);
|
|
136
|
+
const tokens = wholeNumber(input.tokens);
|
|
137
|
+
if (input.tokens !== undefined && input.tokens !== null && tokens !== null) record.tokens = tokens;
|
|
138
|
+
if (typeof input.cost_usd === 'number' && Number.isFinite(input.cost_usd)) record.cost_usd = input.cost_usd;
|
|
139
|
+
for (const key of ['brief_lines', 'brief_bytes']) {
|
|
140
|
+
const value = wholeNumber(input[key]);
|
|
141
|
+
if (input[key] !== undefined && input[key] !== null && value !== null) record[key] = value;
|
|
142
|
+
}
|
|
143
|
+
return record;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Past the cap, the log moves aside under a fresh stamped name, so a second
|
|
147
|
+
// writer rotating in the same breath adds a file instead of replacing one.
|
|
148
|
+
// A rename that fails, including ENOENT when another writer got there
|
|
149
|
+
// first, deletes nothing: the append still lands in the live log. Only
|
|
150
|
+
// after a rename lands are rotated files past the newest few removed.
|
|
151
|
+
function rotateRunsFile(root, file, options = {}) {
|
|
152
|
+
let size = 0;
|
|
153
|
+
try {
|
|
154
|
+
size = fs.statSync(file).size;
|
|
155
|
+
} catch {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (size < RUNS_ROTATE_BYTES) return;
|
|
159
|
+
const dir = path.dirname(file);
|
|
160
|
+
const stamp = new Date(nowMs(options.now)).toISOString().replace(/[-:]/g, '');
|
|
161
|
+
let rotated = path.join(dir, `roster_runs.${stamp}-${process.pid}.jsonl`);
|
|
162
|
+
for (let attempt = 1; fs.existsSync(rotated); attempt += 1) {
|
|
163
|
+
rotated = path.join(dir, `roster_runs.${stamp}-${process.pid}-${attempt}.jsonl`);
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
fs.renameSync(file, rotated);
|
|
167
|
+
} catch {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const rotatedFiles = rotatedRunsPaths(root);
|
|
171
|
+
for (const old of rotatedFiles.slice(0, Math.max(0, rotatedFiles.length - ROTATED_KEEP))) {
|
|
172
|
+
try { fs.rmSync(old, { force: true }); } catch {}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// One whole line per call, no lock. Before the append, a log at the cap
|
|
177
|
+
// rotates aside so every record stays readable, and recording never throws
|
|
178
|
+
// into the run that made it.
|
|
179
|
+
function appendRosterRun(root = process.cwd(), input = {}, options = {}) {
|
|
180
|
+
try {
|
|
181
|
+
const record = normalizeRun(input, options);
|
|
182
|
+
const file = rosterRunsPath(root);
|
|
183
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
184
|
+
rotateRunsFile(root, file, options);
|
|
185
|
+
fs.appendFileSync(file, `${JSON.stringify(record)}\n`, 'utf8');
|
|
186
|
+
return record;
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Several attempts at one job, oldest first. Each attempt that was followed by
|
|
193
|
+
// another names who took over.
|
|
194
|
+
function appendRosterAttempts(root, attempts = [], shared = {}, options = {}) {
|
|
195
|
+
const list = (attempts || []).filter(Boolean);
|
|
196
|
+
return list.map((attempt, index) => appendRosterRun(root, {
|
|
197
|
+
...shared,
|
|
198
|
+
...attempt,
|
|
199
|
+
...(index + 1 < list.length && list[index + 1].engine ? { handed_over_to: list[index + 1].engine } : {}),
|
|
200
|
+
}, options));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The last few hundred KB only: the file grows forever and the roster view
|
|
204
|
+
// never needs more than recent days.
|
|
205
|
+
function readTail(file, bytes = TAIL_BYTES) {
|
|
206
|
+
let fd;
|
|
207
|
+
try {
|
|
208
|
+
fd = fs.openSync(file, 'r');
|
|
209
|
+
const size = fs.fstatSync(fd).size;
|
|
210
|
+
const start = Math.max(0, size - bytes);
|
|
211
|
+
const buffer = Buffer.alloc(size - start);
|
|
212
|
+
fs.readSync(fd, buffer, 0, buffer.length, start);
|
|
213
|
+
const text = buffer.toString('utf8');
|
|
214
|
+
return start > 0 ? text.slice(text.indexOf('\n') + 1) : text;
|
|
215
|
+
} catch {
|
|
216
|
+
return '';
|
|
217
|
+
} finally {
|
|
218
|
+
if (fd !== undefined) try { fs.closeSync(fd); } catch {}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const TEXT_FIELDS = Object.freeze(['at', 'job', 'engine', 'outcome', 'member', 'model', 'effort', 'task', 'detail', 'handed_over_to', 'source', 'prep']);
|
|
223
|
+
const NUMBER_FIELDS = Object.freeze(['max_seconds', 'seconds', 'tokens', 'cost_usd', 'brief_lines', 'brief_bytes']);
|
|
224
|
+
|
|
225
|
+
// A line as read back, with every known field its expected type: text
|
|
226
|
+
// trimmed or dropped, numbers finite or dropped ("0.42" reads as 0.42), so a
|
|
227
|
+
// hand-edited line can never break the views.
|
|
228
|
+
function readableRun(row) {
|
|
229
|
+
const out = {};
|
|
230
|
+
for (const [key, value] of Object.entries(row)) {
|
|
231
|
+
if (TEXT_FIELDS.includes(key)) {
|
|
232
|
+
if (typeof value !== 'string') continue;
|
|
233
|
+
const text = value.trim();
|
|
234
|
+
if (text) out[key] = text;
|
|
235
|
+
} else if (NUMBER_FIELDS.includes(key)) {
|
|
236
|
+
const number = typeof value === 'number' ? value
|
|
237
|
+
: typeof value === 'string' && value.trim() ? Number(value.trim())
|
|
238
|
+
: NaN;
|
|
239
|
+
if (Number.isFinite(number) && number >= 0) out[key] = number;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Runs from the last `days` days, oldest first, read from the last
|
|
246
|
+
// `tailBytes` of each file. A missing file or a bad line is never an error.
|
|
247
|
+
function readRosterRuns(root = process.cwd(), { now, days = DEFAULT_DAYS, tailBytes = TAIL_BYTES } = {}) {
|
|
248
|
+
const since = nowMs(now) - Math.max(0, Number(days) || DEFAULT_DAYS) * 86400000;
|
|
249
|
+
const rows = [];
|
|
250
|
+
const bytes = Number(tailBytes) > 0 ? Number(tailBytes) : TAIL_BYTES;
|
|
251
|
+
// The newest rotated files hold the older records. A read that races a
|
|
252
|
+
// rotation can miss a file for that one read; the next read sees it, and
|
|
253
|
+
// these records only feed suggestions and ranking, so a miss costs
|
|
254
|
+
// nothing.
|
|
255
|
+
for (const file of [...rotatedRunsPaths(root).slice(-ROTATED_READ), rosterRunsPath(root)]) {
|
|
256
|
+
for (const line of readTail(file, bytes).split(/\r?\n/)) {
|
|
257
|
+
if (!line.trim()) continue;
|
|
258
|
+
let row;
|
|
259
|
+
try {
|
|
260
|
+
row = JSON.parse(line);
|
|
261
|
+
} catch {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) continue;
|
|
265
|
+
row = readableRun(row);
|
|
266
|
+
if (!row.engine || !OUTCOMES.includes(row.outcome)) continue;
|
|
267
|
+
const at = Date.parse(row.at);
|
|
268
|
+
if (!Number.isFinite(at) || at < since) continue;
|
|
269
|
+
rows.push(row);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return rows;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function median(values) {
|
|
276
|
+
const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
|
|
277
|
+
if (!sorted.length) return null;
|
|
278
|
+
const middle = Math.floor(sorted.length / 2);
|
|
279
|
+
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function durationText(seconds) {
|
|
283
|
+
const value = Math.round(Number(seconds) || 0);
|
|
284
|
+
if (value < 60) return `${value}s`;
|
|
285
|
+
if (value < 3600) return `${Math.round(value / 60)} min`;
|
|
286
|
+
const hours = Math.floor(value / 3600);
|
|
287
|
+
const minutes = Math.round((value % 3600) / 60);
|
|
288
|
+
return minutes ? `${hours} h ${minutes} min` : `${hours} h`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// The runs that belong to one worker on one job. A worker with a pinned model
|
|
292
|
+
// counts only runs on that model; with no pin, every run of the engine counts.
|
|
293
|
+
function workerRuns(runs, { job, engine, model = '' } = {}) {
|
|
294
|
+
const wantJob = jobName(job);
|
|
295
|
+
const wantEngine = clean(engine);
|
|
296
|
+
const wantModel = clean(model).toLowerCase();
|
|
297
|
+
return (runs || []).filter((run) => jobName(run.job) === wantJob
|
|
298
|
+
&& run.engine === wantEngine
|
|
299
|
+
&& (!wantModel || clean(run.model).toLowerCase() === wantModel));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// "last 7 days: 9 runs, 7 landed, 1 stalled, 1 failed, median 11 min", or
|
|
303
|
+
// null when there are no runs.
|
|
304
|
+
function summarizeRuns(runs, { days = DEFAULT_DAYS } = {}) {
|
|
305
|
+
const list = runs || [];
|
|
306
|
+
if (!list.length) return null;
|
|
307
|
+
const count = (outcome) => list.filter((run) => run.outcome === outcome).length;
|
|
308
|
+
const summary = {
|
|
309
|
+
days,
|
|
310
|
+
runs: list.length,
|
|
311
|
+
landed: count('landed'),
|
|
312
|
+
failed: count('failed'),
|
|
313
|
+
stalled: count('stalled'),
|
|
314
|
+
credit_out: count('credit out'),
|
|
315
|
+
handed_over: list.filter((run) => run.handed_over_to).length,
|
|
316
|
+
median_seconds: median(list.map((run) => Number(run.seconds)).filter((value) => value >= 0)),
|
|
317
|
+
};
|
|
318
|
+
const tokens = list.map((run) => run.tokens).filter((value) => Number.isFinite(value));
|
|
319
|
+
if (tokens.length) summary.tokens = tokens.reduce((sum, value) => sum + value, 0);
|
|
320
|
+
const parts = [`${summary.runs} run${summary.runs === 1 ? '' : 's'}`];
|
|
321
|
+
if (summary.landed) parts.push(`${summary.landed} landed`);
|
|
322
|
+
if (summary.stalled) parts.push(`${summary.stalled} stalled`);
|
|
323
|
+
if (summary.failed) parts.push(`${summary.failed} failed`);
|
|
324
|
+
if (summary.credit_out) parts.push(`${summary.credit_out} credit out`);
|
|
325
|
+
if (summary.median_seconds !== null) parts.push(`median ${durationText(summary.median_seconds)}`);
|
|
326
|
+
summary.text = `last ${days} day${days === 1 ? '' : 's'}: ${parts.join(', ')}`;
|
|
327
|
+
return summary;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function clockText(at) {
|
|
331
|
+
const date = new Date(at);
|
|
332
|
+
if (!Number.isFinite(date.getTime())) return String(at || '');
|
|
333
|
+
const day = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }).toLowerCase();
|
|
334
|
+
return `${day} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function briefSizeText(bytes) {
|
|
338
|
+
const value = Number(bytes) || 0;
|
|
339
|
+
return value < 1024 ? `${value} bytes` : `${(value / 1024).toFixed(1).replace(/\.0$/, '')} KB`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function outcomeText(run) {
|
|
343
|
+
let text = run.outcome;
|
|
344
|
+
if (run.detail && run.detail !== run.outcome) text = run.outcome === 'stalled' && /^stalled\b/.test(run.detail) ? run.detail : `${run.outcome}, ${run.detail}`;
|
|
345
|
+
if (run.handed_over_to) text += `, handed over to ${run.handed_over_to}`;
|
|
346
|
+
if (run.prep) text += `, ${run.prep}${run.brief_bytes ? ` (${briefSizeText(run.brief_bytes)} brief)` : ''}`;
|
|
347
|
+
return text;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// One plain line per attempt.
|
|
351
|
+
function renderRunLine(run) {
|
|
352
|
+
const worker = [run.engine, run.model, run.effort].filter(Boolean).join(' ');
|
|
353
|
+
const facts = [
|
|
354
|
+
run.seconds !== undefined ? durationText(run.seconds) : '',
|
|
355
|
+
run.max_seconds ? `cap ${durationText(run.max_seconds)}` : '',
|
|
356
|
+
run.tokens !== undefined ? `${run.tokens.toLocaleString('en-US')} tokens` : '',
|
|
357
|
+
run.cost_usd !== undefined ? `$${run.cost_usd.toFixed(2)}` : '',
|
|
358
|
+
].filter(Boolean).join(', ');
|
|
359
|
+
const who = [run.job, run.member ? `for ${run.member}` : '', run.task].filter(Boolean).join(' ');
|
|
360
|
+
return `${clockText(run.at)} ${who}: ${worker}${facts ? ` (${facts})` : ''}, ${outcomeText(run)}`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Newest first, at most `limit`, optionally one job only.
|
|
364
|
+
function recentRuns(runs, { job = '', limit = 20 } = {}) {
|
|
365
|
+
const wantJob = job ? jobName(job) : '';
|
|
366
|
+
return (runs || [])
|
|
367
|
+
.filter((run) => !wantJob || jobName(run.job) === wantJob)
|
|
368
|
+
.map((run, index) => ({ run, index }))
|
|
369
|
+
.sort((a, b) => (Date.parse(b.run.at) - Date.parse(a.run.at)) || (b.index - a.index))
|
|
370
|
+
.slice(0, limit)
|
|
371
|
+
.map(({ run }) => run);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
module.exports = {
|
|
375
|
+
OUTCOMES,
|
|
376
|
+
DEFAULT_DAYS,
|
|
377
|
+
RUNS_ROTATE_BYTES,
|
|
378
|
+
rosterRunsPath,
|
|
379
|
+
rotatedRunsPaths,
|
|
380
|
+
jobName,
|
|
381
|
+
parseRunUsage,
|
|
382
|
+
appendRosterRun,
|
|
383
|
+
appendRosterAttempts,
|
|
384
|
+
readRosterRuns,
|
|
385
|
+
workerRuns,
|
|
386
|
+
summarizeRuns,
|
|
387
|
+
renderRunLine,
|
|
388
|
+
recentRuns,
|
|
389
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Suggestions from the run record. When a job's first worker keeps stalling
|
|
4
|
+
// or failing and a worker further down its list keeps landing, the roster
|
|
5
|
+
// view says so once, with the command that would move the better worker up.
|
|
6
|
+
// Nothing here writes ROSTER.md: only a person (or a person telling an
|
|
7
|
+
// agent) changes the roster. This file only reads runs and words a line.
|
|
8
|
+
|
|
9
|
+
const { readRosterRuns, workerRuns } = require('./roster-runs');
|
|
10
|
+
const { rosterToolLabel } = require('./engine-registry');
|
|
11
|
+
|
|
12
|
+
// Only the last week counts, and within it each worker's newest few runs.
|
|
13
|
+
const SUGGEST_DAYS = 7;
|
|
14
|
+
const SUGGEST_WINDOW_RUNS = 5;
|
|
15
|
+
// The lead needs enough runs to judge; the challenger needs at least two.
|
|
16
|
+
const SUGGEST_MIN_LEAD_RUNS = 3;
|
|
17
|
+
const SUGGEST_MIN_CHALLENGER_RUNS = 2;
|
|
18
|
+
// The lead lands under half its runs, and the challenger's landed rate is
|
|
19
|
+
// at least 25 points higher.
|
|
20
|
+
const SUGGEST_LEAD_BELOW_POINTS = 50;
|
|
21
|
+
const SUGGEST_MIN_GAP_POINTS = 25;
|
|
22
|
+
// The boot line reads only this much of the end of the record.
|
|
23
|
+
const BOOT_TAIL_BYTES = 64 * 1024;
|
|
24
|
+
|
|
25
|
+
// A credit wall says nothing about how well a worker does the job, and
|
|
26
|
+
// engine health already benches it, so credit out runs are left out.
|
|
27
|
+
function judgedRuns(runs) {
|
|
28
|
+
return runs
|
|
29
|
+
.filter((run) => run.outcome !== 'credit out')
|
|
30
|
+
.map((run, index) => ({ run, index }))
|
|
31
|
+
.sort((a, b) => (Date.parse(b.run.at) - Date.parse(a.run.at)) || (b.index - a.index))
|
|
32
|
+
.slice(0, SUGGEST_WINDOW_RUNS)
|
|
33
|
+
.map(({ run }) => run);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function tally(runs) {
|
|
37
|
+
const landed = runs.filter((run) => run.outcome === 'landed').length;
|
|
38
|
+
return { runs: runs.length, landed };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function workerName(worker) {
|
|
42
|
+
if (worker.runs && worker.runs.text) return worker.runs.text;
|
|
43
|
+
return [rosterToolLabel(worker.engine), worker.model].filter(Boolean).join(' ');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function quoteArg(value) {
|
|
47
|
+
const text = String(value);
|
|
48
|
+
return /^[a-z0-9._:/@+-]+$/i.test(text) ? text : `"${text.replace(/(["\\$`])/g, '\\$1')}"`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The assign command that moves the challenger to the top of the job. It
|
|
52
|
+
// names the challenger's line exactly: the tool, plus its model and effort
|
|
53
|
+
// when its line has them, so --promote moves that worker and no other line
|
|
54
|
+
// of the same tool. --promote reorders lines as written, so the demoted
|
|
55
|
+
// lead keeps its own cap, prep, and notes.
|
|
56
|
+
function assignArgs(job, challenger) {
|
|
57
|
+
const words = [rosterToolLabel(challenger.engine), challenger.model, challenger.effort].filter(Boolean).join(' ');
|
|
58
|
+
return ['assign', job, '--promote', words];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function leadText(name, count) {
|
|
62
|
+
const missed = count.runs - count.landed;
|
|
63
|
+
const outcomes = [...new Set(count.list.filter((run) => run.outcome !== 'landed').map((run) => run.outcome))];
|
|
64
|
+
const last = `of its last ${count.runs} run${count.runs === 1 ? '' : 's'}`;
|
|
65
|
+
if (missed && outcomes.length === 1) return `${name} ${outcomes[0]} ${missed} ${last}`;
|
|
66
|
+
return `${name} landed ${count.landed} ${last}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// At most one suggestion for one job row of the roster report, or null. A
|
|
70
|
+
// row's workers carry their status: a worker that is cooling, expired, not
|
|
71
|
+
// ready, or on a bad line is skipped and is never suggested.
|
|
72
|
+
function jobSuggestion(row, runs) {
|
|
73
|
+
if (!row || !row.pick || !Array.isArray(row.workers) || row.workers.length < 2) return null;
|
|
74
|
+
const job = row.key || row.job;
|
|
75
|
+
const usable = row.workers.filter((worker) => worker.engine && !worker.text);
|
|
76
|
+
const lead = usable[0];
|
|
77
|
+
if (!lead) return null;
|
|
78
|
+
const countFor = (worker) => {
|
|
79
|
+
const list = judgedRuns(workerRuns(runs, { job, engine: worker.engine, model: worker.model || '' }));
|
|
80
|
+
return { ...tally(list), list };
|
|
81
|
+
};
|
|
82
|
+
const leadCount = countFor(lead);
|
|
83
|
+
if (leadCount.runs < SUGGEST_MIN_LEAD_RUNS) return null;
|
|
84
|
+
if (leadCount.landed * 100 >= SUGGEST_LEAD_BELOW_POINTS * leadCount.runs) return null;
|
|
85
|
+
let best = null;
|
|
86
|
+
for (const worker of usable.slice(1)) {
|
|
87
|
+
if (worker.status === 'skipped') continue;
|
|
88
|
+
const count = countFor(worker);
|
|
89
|
+
if (count.runs < SUGGEST_MIN_CHALLENGER_RUNS) continue;
|
|
90
|
+
// Whole-number math: the challenger's rate minus the lead's, in points.
|
|
91
|
+
const gap = 100 * (count.landed * leadCount.runs - leadCount.landed * count.runs);
|
|
92
|
+
if (gap < SUGGEST_MIN_GAP_POINTS * leadCount.runs * count.runs) continue;
|
|
93
|
+
const rate = count.landed / count.runs;
|
|
94
|
+
if (!best || rate > best.rate || (rate === best.rate && count.runs > best.count.runs)) best = { worker, count, rate };
|
|
95
|
+
}
|
|
96
|
+
if (!best) return null;
|
|
97
|
+
const leadName = workerName(lead);
|
|
98
|
+
const challengerName = workerName(best.worker);
|
|
99
|
+
const args = assignArgs(row.job, best.worker);
|
|
100
|
+
const command = `atris engine ${args.map(quoteArg).join(' ')}`;
|
|
101
|
+
const record = `${leadText(leadName, leadCount)}; ${challengerName} landed ${best.count.landed} of ${best.count.runs}`;
|
|
102
|
+
return {
|
|
103
|
+
job: row.job,
|
|
104
|
+
days: SUGGEST_DAYS,
|
|
105
|
+
lead: { engine: lead.engine, model: lead.model || null, runs: leadCount.runs, landed: leadCount.landed },
|
|
106
|
+
challenger: { engine: best.worker.engine, model: best.worker.model || null, runs: best.count.runs, landed: best.count.landed },
|
|
107
|
+
command,
|
|
108
|
+
args,
|
|
109
|
+
text: `${record}. to move ${rosterToolLabel(best.worker.engine)} up: ${command}`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Attach a suggestion to each job row that has one. Returns how many.
|
|
114
|
+
function attachSuggestions(jobs, runs) {
|
|
115
|
+
let count = 0;
|
|
116
|
+
for (const row of jobs || []) {
|
|
117
|
+
const suggestion = jobSuggestion(row, runs);
|
|
118
|
+
if (suggestion) {
|
|
119
|
+
row.suggestion = suggestion;
|
|
120
|
+
count += 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return count;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// How many jobs have a suggestion, reading only the end of the record. For
|
|
127
|
+
// the boot line: never throws, 0 when anything goes wrong.
|
|
128
|
+
function suggestionCount(jobs, root, { now } = {}) {
|
|
129
|
+
try {
|
|
130
|
+
const runs = readRosterRuns(root, { now, days: SUGGEST_DAYS, tailBytes: BOOT_TAIL_BYTES });
|
|
131
|
+
if (!runs.length) return 0;
|
|
132
|
+
return (jobs || []).filter((row) => jobSuggestion(row, runs)).length;
|
|
133
|
+
} catch {
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = {
|
|
139
|
+
SUGGEST_MIN_LEAD_RUNS,
|
|
140
|
+
SUGGEST_MIN_CHALLENGER_RUNS,
|
|
141
|
+
SUGGEST_LEAD_BELOW_POINTS,
|
|
142
|
+
SUGGEST_MIN_GAP_POINTS,
|
|
143
|
+
attachSuggestions,
|
|
144
|
+
suggestionCount,
|
|
145
|
+
};
|
package/lib/router-brain.js
CHANGED
|
@@ -85,6 +85,18 @@ function recordTimestamp(containers, fallbackMs) {
|
|
|
85
85
|
return Number.isFinite(fallbackMs) ? fallbackMs : Date.now();
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// The id a receipt and a roster run record share for the same attempt: the
|
|
89
|
+
// task id (`task`, `task_id`), else the brief id. '' means the record names
|
|
90
|
+
// nothing shared, so it can never be deduped against another.
|
|
91
|
+
function recordRunRef(containers) {
|
|
92
|
+
const raw = firstField(
|
|
93
|
+
containers,
|
|
94
|
+
['task_id', 'taskId', 'task', 'brief_id', 'briefId', 'run_id', 'runId'],
|
|
95
|
+
(value) => (typeof value === 'string' && value.trim()) || typeof value === 'number',
|
|
96
|
+
);
|
|
97
|
+
return raw === null ? '' : normalizedLabel(raw);
|
|
98
|
+
}
|
|
99
|
+
|
|
88
100
|
function observationFromRecord(record, source, fallbackMs) {
|
|
89
101
|
const containers = recordContainers(record);
|
|
90
102
|
const engine = normalizedLabel(firstField(containers, [
|
|
@@ -115,6 +127,7 @@ function observationFromRecord(record, source, fallbackMs) {
|
|
|
115
127
|
duration_ms: recordDuration(containers),
|
|
116
128
|
at_ms: recordTimestamp(containers, fallbackMs),
|
|
117
129
|
source,
|
|
130
|
+
run_ref: recordRunRef(containers),
|
|
118
131
|
};
|
|
119
132
|
}
|
|
120
133
|
|
|
@@ -188,12 +201,107 @@ function readDispatchReceiptObservations(root) {
|
|
|
188
201
|
return observations;
|
|
189
202
|
}
|
|
190
203
|
|
|
191
|
-
|
|
192
|
-
|
|
204
|
+
// Roster runs (.atris/state/roster_runs.jsonl) as one more signal, for jobs
|
|
205
|
+
// the router ranks because no roster line picks for them. Each run counts as
|
|
206
|
+
// one receipt, the same weight and the same 30-day half-life as a task
|
|
207
|
+
// receipt: landed is a pass; stalled and failed are a miss. Credit out is
|
|
208
|
+
// left out, since engine health already benches that worker. The job name
|
|
209
|
+
// maps to the role the router ranks by: build to executor, search to
|
|
210
|
+
// navigator, review to validator, and an owner's job by its last word
|
|
211
|
+
// ("small build" is build work). No record file means no observations, so
|
|
212
|
+
// the ranking is exactly what it was without one.
|
|
213
|
+
const ROSTER_RUN_DAYS = 30;
|
|
214
|
+
const ROSTER_JOB_ROLES = Object.freeze({ build: 'executor', search: 'navigator', review: 'validator' });
|
|
215
|
+
|
|
216
|
+
function readRosterRunObservations(root, now) {
|
|
217
|
+
const { readRosterRuns, rosterRunsPath, rotatedRunsPaths } = require('./roster-runs');
|
|
218
|
+
const file = rosterRunsPath(root);
|
|
219
|
+
if (!fs.existsSync(file) && !rotatedRunsPaths(root).length) return [];
|
|
220
|
+
const source = path.relative(root, file);
|
|
221
|
+
return readRosterRuns(root, { days: ROSTER_RUN_DAYS, ...(now !== undefined ? { now } : {}) }).flatMap((run) => {
|
|
222
|
+
if (run.outcome === 'credit out') return [];
|
|
223
|
+
const words = String(run.job || '').trim().toLowerCase().split(/\s+/);
|
|
224
|
+
const role = ROSTER_JOB_ROLES[words[words.length - 1]];
|
|
225
|
+
const atMs = Date.parse(run.at);
|
|
226
|
+
if (!role || !Number.isFinite(atMs)) return [];
|
|
227
|
+
return [{
|
|
228
|
+
engine: normalizedLabel(run.engine),
|
|
229
|
+
task_type: role,
|
|
230
|
+
verified_passed: run.outcome === 'landed',
|
|
231
|
+
duration_ms: Number.isFinite(run.seconds) ? run.seconds * 1000 : null,
|
|
232
|
+
at_ms: atMs,
|
|
233
|
+
source,
|
|
234
|
+
run_ref: normalizedLabel(run.task),
|
|
235
|
+
}];
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// One landed dispatch writes both a receipt result row and a roster run
|
|
240
|
+
// record for the same attempt. The shared id is the task id; the receipt
|
|
241
|
+
// stamps the moment the run ended, the run record the moment it started.
|
|
242
|
+
// Same engine, same task id, a few minutes apart at either end means one
|
|
243
|
+
// run, and the receipt wins because its verified flag is authoritative.
|
|
244
|
+
// A receipt can pair with a run near either end, which is two separate
|
|
245
|
+
// windows per run, so no greedy order is safe. Each task id and engine
|
|
246
|
+
// group is tiny, so pairing uses exact maximum matching (augmenting paths):
|
|
247
|
+
// the pair count is maximal, one receipt cancels at most one run, a pair
|
|
248
|
+
// counts once, and a run or receipt left over still counts once on its own.
|
|
249
|
+
// A retry after a landed run is its own attempt and still counts.
|
|
250
|
+
const RUN_RECEIPT_WINDOW_MS = 5 * 60 * 1000;
|
|
251
|
+
|
|
252
|
+
// How far the receipt sits from the run's start or finish; the nearer end
|
|
253
|
+
// decides whether they describe the same attempt.
|
|
254
|
+
function receiptRunGap(receipt, run) {
|
|
255
|
+
const end = Number.isFinite(run.duration_ms) ? run.at_ms + run.duration_ms : run.at_ms;
|
|
256
|
+
return Math.min(Math.abs(receipt.at_ms - run.at_ms), Math.abs(receipt.at_ms - end));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Maximum matching between receipt and run indexes, where canPair says
|
|
260
|
+
// whether a receipt and a run may describe the same attempt. Returns the
|
|
261
|
+
// set of paired run indexes.
|
|
262
|
+
function maxPairedRuns(receiptIds, runIds, canPair) {
|
|
263
|
+
const runOwner = new Map();
|
|
264
|
+
const tryReceipt = (r, seen) => {
|
|
265
|
+
for (const n of runIds) {
|
|
266
|
+
if (seen.has(n) || !canPair(r, n)) continue;
|
|
267
|
+
seen.add(n);
|
|
268
|
+
if (!runOwner.has(n) || tryReceipt(runOwner.get(n), seen)) {
|
|
269
|
+
runOwner.set(n, r);
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
};
|
|
275
|
+
for (const r of receiptIds) tryReceipt(r, new Set());
|
|
276
|
+
return new Set(runOwner.keys());
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function loadRouterHistory(root = process.cwd(), { now } = {}) {
|
|
280
|
+
const receipts = [
|
|
193
281
|
...readTaskReceiptObservations(root),
|
|
194
282
|
...readMissionEventObservations(root),
|
|
195
283
|
...readDispatchReceiptObservations(root),
|
|
196
284
|
];
|
|
285
|
+
const runs = readRosterRunObservations(root, now);
|
|
286
|
+
const groups = new Map();
|
|
287
|
+
for (const [r, receipt] of receipts.entries()) {
|
|
288
|
+
if (!receipt.run_ref) continue;
|
|
289
|
+
const key = `${receipt.engine}\u0000${receipt.run_ref}`;
|
|
290
|
+
if (!groups.has(key)) groups.set(key, { receipts: [], runs: [] });
|
|
291
|
+
groups.get(key).receipts.push(r);
|
|
292
|
+
}
|
|
293
|
+
for (const [n, run] of runs.entries()) {
|
|
294
|
+
if (!run.run_ref) continue;
|
|
295
|
+
const group = groups.get(`${run.engine}\u0000${run.run_ref}`);
|
|
296
|
+
if (group) group.runs.push(n);
|
|
297
|
+
}
|
|
298
|
+
const pairedRuns = new Set();
|
|
299
|
+
const canPair = (r, n) => receiptRunGap(receipts[r], runs[n]) <= RUN_RECEIPT_WINDOW_MS;
|
|
300
|
+
for (const group of groups.values()) {
|
|
301
|
+
for (const n of maxPairedRuns(group.receipts, group.runs, canPair)) pairedRuns.add(n);
|
|
302
|
+
}
|
|
303
|
+
return [...receipts, ...runs.filter((run, n) => !pairedRuns.has(n))]
|
|
304
|
+
.map(({ run_ref, ...observation }) => observation);
|
|
197
305
|
}
|
|
198
306
|
|
|
199
307
|
function median(values) {
|
|
@@ -290,7 +398,7 @@ function rankEnginesDetailed(candidates, options = {}) {
|
|
|
290
398
|
const minReceipts = Number.isInteger(minValue) && minValue > 0 ? minValue : DEFAULT_MIN_RECEIPTS;
|
|
291
399
|
const observations = Array.isArray(options.observations)
|
|
292
400
|
? options.observations
|
|
293
|
-
: loadRouterHistory(options.root || process.cwd());
|
|
401
|
+
: loadRouterHistory(options.root || process.cwd(), { now: options.now });
|
|
294
402
|
const stats = computeEngineTaskStats(observations, options);
|
|
295
403
|
const statsByEngine = new Map(stats
|
|
296
404
|
.filter((row) => row.task_type === taskType)
|