@feigi/fleet-ctl 3.91.3 → 3.92.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/package.json +1 -1
- package/scripts/fleet-event.mjs +1 -1
- package/scripts/member-outcomes.mjs +119 -6
- package/scripts/member-record.mjs +58 -4
package/package.json
CHANGED
package/scripts/fleet-event.mjs
CHANGED
|
@@ -237,7 +237,7 @@ export const BAILS = {
|
|
|
237
237
|
fixed: { kind: "fixed", flag: "detail" },
|
|
238
238
|
};
|
|
239
239
|
// The commit a `fixed` bail's detail opens with.
|
|
240
|
-
const FIXED_SHA = /^([0-9a-f]{7,40})(?![0-9A-Za-z])/;
|
|
240
|
+
export const FIXED_SHA = /^([0-9a-f]{7,40})(?![0-9A-Za-z])/;
|
|
241
241
|
// The causes a `hands` detail opens with — the Pull's answer grammar — and the
|
|
242
242
|
// words each puts in the comment.
|
|
243
243
|
const HANDS = { "external access": "external access", "manual testing": "manual testing", judgment: "judgment during the work" };
|
|
@@ -15,6 +15,10 @@ import { join, basename } from "node:path";
|
|
|
15
15
|
import { readOmpSession, normalizeModel, parseMemberName, isOmpSessionDirName } from "./member-record.mjs";
|
|
16
16
|
import { reportFamily, reportStatus, defaultScratch } from "./report-files.mjs";
|
|
17
17
|
import { FleetDirUnresolvable } from "./fleet-dir.mjs";
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { FIXED_SHA } from "./fleet-event.mjs";
|
|
21
|
+
import { parseReport, reportViolation } from "./report-schema.mjs";
|
|
18
22
|
|
|
19
23
|
export { normalizeModel, parseMemberName };
|
|
20
24
|
|
|
@@ -61,7 +65,7 @@ export { normalizeModel, parseMemberName };
|
|
|
61
65
|
// run_date comes from the newest transcript's mtime rather than a clock
|
|
62
66
|
// read, so a backfill run in December still dates an August session in
|
|
63
67
|
// August.
|
|
64
|
-
export function rowsForSession(sessionDir, stats = {}) {
|
|
68
|
+
export function rowsForSession(sessionDir, stats = {}, { verifyMain = verifyMainWithGit } = {}) {
|
|
65
69
|
let names;
|
|
66
70
|
try { names = readdirSync(sessionDir, { recursive: true }); }
|
|
67
71
|
catch { names = []; }
|
|
@@ -76,7 +80,8 @@ export function rowsForSession(sessionDir, stats = {}) {
|
|
|
76
80
|
stats.torn = 0;
|
|
77
81
|
const session = basename(sessionDir);
|
|
78
82
|
const run_date = newest ? new Date(newest).toISOString().slice(0, 10) : "";
|
|
79
|
-
|
|
83
|
+
const endings = endingsOf(recs, verifyMain);
|
|
84
|
+
return recs.map((r, i) => ({
|
|
80
85
|
session, run_date,
|
|
81
86
|
harness: r.harness, role: r.role, member: r.member, model: r.model,
|
|
82
87
|
effort: r.thinking === "-" ? "" : r.thinking, ticket: r.ticket, pr: r.pr,
|
|
@@ -85,6 +90,7 @@ export function rowsForSession(sessionDir, stats = {}) {
|
|
|
85
90
|
subagentType: r.subagent_type,
|
|
86
91
|
tokensIn: r.tokens_in, tokensCacheRead: r.tokens_cache_read,
|
|
87
92
|
tokensCacheWrite1h: r.tokens_cache_write_1h ?? "", cost: r.cost == null ? "" : Number(r.cost.toFixed(6)),
|
|
93
|
+
ending: endings[i],
|
|
88
94
|
}));
|
|
89
95
|
}
|
|
90
96
|
|
|
@@ -101,6 +107,7 @@ export const COLUMNS = [
|
|
|
101
107
|
"session", "run_date", "role", "member", "model", "effort", "ticket", "pr",
|
|
102
108
|
"tokens_cache_create", "tokens_out", "wall_s", "turns", "agent", "harness",
|
|
103
109
|
"subagent_type", "report", "tokens_in", "tokens_cache_read", "tokens_cache_write_1h", "cost",
|
|
110
|
+
"ending",
|
|
104
111
|
];
|
|
105
112
|
|
|
106
113
|
// `report`: what reportStatus() says of the row's member under `scratch` — a
|
|
@@ -116,6 +123,98 @@ export function withReports(rows, scratch) {
|
|
|
116
123
|
}));
|
|
117
124
|
}
|
|
118
125
|
|
|
126
|
+
// The `ending` column: how an implementer's run ENDED, read off its own
|
|
127
|
+
// transcript and the reports it wrote. Blank on every row that is not an
|
|
128
|
+
// implementer (the member's leaf is `impl-<N>`, with an optional retry
|
|
129
|
+
// suffix): blank there means "not an implementer row", not unknown. `unknown`
|
|
130
|
+
// is where the transcript cannot say: a malformed line, no transcript, or a
|
|
131
|
+
// fixed sha origin/main cannot answer. Precedence, first that applies wins:
|
|
132
|
+
// pr the row's pr is named, the member's own `gh pr create`
|
|
133
|
+
// opened one (more than one leaves the pr column blank), or
|
|
134
|
+
// the last report claims a PR an earlier same-ticket
|
|
135
|
+
// implementer opened in this session;
|
|
136
|
+
// bail:<cause> the last report bails (`fixed` is checked against main);
|
|
137
|
+
// provider a model fell back, or the last turn errored;
|
|
138
|
+
// budget a budget notice reached the member;
|
|
139
|
+
// abort the last turn was aborted;
|
|
140
|
+
// crash the session never exited and wrote no yield sidecar;
|
|
141
|
+
// hollow none of the above: the member stopped on its own.
|
|
142
|
+
const IMPLEMENTER_LEAF = /^impl-\d+(?:-[a-z0-9]+)?$/;
|
|
143
|
+
const isImplementer = (member) => IMPLEMENTER_LEAF.test(member.split("/").pop());
|
|
144
|
+
|
|
145
|
+
export function endingOf({ member, pr, signals = null, earlierPrs = new Set(), verifyMain = verifyMainWithGit }) {
|
|
146
|
+
if (!isImplementer(member)) return "";
|
|
147
|
+
if (pr) return "pr";
|
|
148
|
+
if (!signals || signals.malformedNonLastLines > 0) return "unknown";
|
|
149
|
+
if (signals.openedPrs.length > 0) return "pr";
|
|
150
|
+
const last = lastReport(signals.reportWrites, member.split("/").pop());
|
|
151
|
+
if (last?.outcome === "pr" && earlierPrs.has(String(last.pr))) return "pr";
|
|
152
|
+
// lastReport passed the impl schema, whose bailCause is a BAILS key or null.
|
|
153
|
+
if (last?.bailCause) return bailEnding(last, verifyMain);
|
|
154
|
+
if (signals.providerFallback || signals.lastStopReason === "error") return "provider";
|
|
155
|
+
if (signals.budgetNotice) return "budget";
|
|
156
|
+
if (signals.lastStopReason === "aborted") return "abort";
|
|
157
|
+
if (!signals.sessionExit && !signals.hasYield) return "crash";
|
|
158
|
+
return "hollow";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The member's last report, when the last one it wrote is a valid impl report
|
|
162
|
+
// naming this member; null when it wrote none, or the last one is malformed.
|
|
163
|
+
function lastReport(reportWrites, leaf) {
|
|
164
|
+
const last = reportWrites.filter((r) => r.file === `${leaf}.json`).at(-1);
|
|
165
|
+
if (!last) return null;
|
|
166
|
+
const parsed = parseReport(last.content);
|
|
167
|
+
if ("violation" in parsed) return null;
|
|
168
|
+
if (reportViolation("impl", parsed.value) !== null) return null;
|
|
169
|
+
return parsed.value.member === leaf ? parsed.value : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A bail's ending: its cause, except `fixed`, which holds only while origin/main
|
|
173
|
+
// reaches the sha its detail opens with.
|
|
174
|
+
function bailEnding(report, verifyMain) {
|
|
175
|
+
if (report.bailCause !== "fixed") return `bail:${report.bailCause}`;
|
|
176
|
+
const m = FIXED_SHA.exec((report.bailDetail ?? "").trim());
|
|
177
|
+
if (!m) return "unknown";
|
|
178
|
+
const status = verifyMain(m[1]);
|
|
179
|
+
if (status === 0) return "bail:fixed";
|
|
180
|
+
if (status === 1) return "bail:fixed-refuted";
|
|
181
|
+
return "unknown";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Each record's ending. `earlierPrs` is the set of PRs the same-ticket
|
|
185
|
+
// implementers opened before this one started, so a continuation's report can
|
|
186
|
+
// name its predecessor's PR and still read `pr`. A record never joins itself:
|
|
187
|
+
// its own start is not before its own start. An `impl-<ticket>` record's `pr`
|
|
188
|
+
// is blank or its one opened PR, so `openedPrs` alone holds every PR it opened.
|
|
189
|
+
function endingsOf(recs, verifyMain) {
|
|
190
|
+
return recs.map((r) => {
|
|
191
|
+
const earlierPrs = new Set();
|
|
192
|
+
if (r.ticket && isImplementer(r.member)) {
|
|
193
|
+
for (const o of recs) {
|
|
194
|
+
if (o.ticket !== r.ticket || !isImplementer(o.member)) continue;
|
|
195
|
+
if (!(o.transcript.startedAt < r.transcript.startedAt)) continue;
|
|
196
|
+
for (const n of o.transcript.openedPrs) earlierPrs.add(String(n));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return endingOf({ member: r.member, pr: r.pr, signals: r.transcript, earlierPrs, verifyMain });
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The verifier the `fixed` bail calls: `verify-sha.sh main <sha>` answers
|
|
204
|
+
// 0 when origin/main reaches the sha, 1 when it does not, and 2 when it could
|
|
205
|
+
// not say (its exit status, or none when the script did not run at all). A
|
|
206
|
+
// verifier that could not say leaves the bail `unknown`, so its own diagnosis
|
|
207
|
+
// goes to stderr: without it an offline scrape and a broken script both read
|
|
208
|
+
// as a bare `unknown`.
|
|
209
|
+
const VERIFY_SHA = fileURLToPath(new URL("./verify-sha.sh", import.meta.url));
|
|
210
|
+
function verifyMainWithGit(sha) {
|
|
211
|
+
const r = spawnSync("sh", [VERIFY_SHA, "main", sha], { encoding: "utf8" });
|
|
212
|
+
if (r.status === 0 || r.status === 1) return r.status;
|
|
213
|
+
const why = r.error ? r.error.message : (r.stderr ?? "").trim() || `exit ${r.status ?? r.signal}`;
|
|
214
|
+
process.stderr.write(`${NAME}: verify-sha.sh main ${sha} could not answer, so its fixed bail reads unknown: ${why}\n`);
|
|
215
|
+
return 2;
|
|
216
|
+
}
|
|
217
|
+
|
|
119
218
|
// Row objects use camelCase; the file uses snake_case. One map, one direction
|
|
120
219
|
// each, so a rename cannot silently drop a column.
|
|
121
220
|
const FIELD = {
|
|
@@ -134,9 +233,22 @@ const key = (r) => `${r.session}\0${r.agent}`;
|
|
|
134
233
|
// Replace-by-key, not append. This is what makes a close-out re-run and a full
|
|
135
234
|
// regeneration both safe, and it is the reason no backfill-only code path is
|
|
136
235
|
// needed: the idempotency backfill wants is the idempotency a re-run wants.
|
|
137
|
-
|
|
236
|
+
//
|
|
237
|
+
// One cell is the exception: an incoming `unknown` ending keeps the ending the
|
|
238
|
+
// replaced row recorded. `unknown` is this scrape failing to say (a verifier
|
|
239
|
+
// that is offline, a transcript gone unreadable), not a reading, so it never
|
|
240
|
+
// overwrites one: a recorded `bail:fixed` would otherwise flip to `unknown`
|
|
241
|
+
// on any re-scrape run without network. `stats.endingsKept` counts them.
|
|
242
|
+
export function mergeRows(existing, incoming, stats = {}) {
|
|
138
243
|
const by = new Map((existing ?? []).map((r) => [key(r), r]));
|
|
139
|
-
|
|
244
|
+
stats.endingsKept = 0;
|
|
245
|
+
for (const r of incoming ?? []) {
|
|
246
|
+
const old = by.get(key(r));
|
|
247
|
+
if (r.ending === "unknown" && old?.ending && old.ending !== "unknown") {
|
|
248
|
+
by.set(key(r), { ...r, ending: old.ending });
|
|
249
|
+
stats.endingsKept++;
|
|
250
|
+
} else by.set(key(r), r);
|
|
251
|
+
}
|
|
140
252
|
return [...by.values()].sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
|
|
141
253
|
}
|
|
142
254
|
|
|
@@ -268,7 +380,7 @@ if (isCLI(import.meta.url)) {
|
|
|
268
380
|
}
|
|
269
381
|
|
|
270
382
|
const stats = {};
|
|
271
|
-
const merged = mergeRows(existing, withReports(rowsForSession(sessionDir, stats), scratch));
|
|
383
|
+
const merged = mergeRows(existing, withReports(rowsForSession(sessionDir, stats), scratch), stats);
|
|
272
384
|
// Write-then-rename: writeFileSync truncates before it writes, so an interrupt
|
|
273
385
|
// over the committed corpus left a torn file that the NEXT scrape read, merged
|
|
274
386
|
// and rewrote — losing every other session's rows for good.
|
|
@@ -278,8 +390,9 @@ if (isCLI(import.meta.url)) {
|
|
|
278
390
|
// so a session where every member dropped printed the same reassuring line as
|
|
279
391
|
// a healthy one — 0-of-2 was indistinguishable from full success.
|
|
280
392
|
const torn = stats.torn ? `, ${stats.torn} torn mid-write` : "";
|
|
393
|
+
const kept = stats.endingsKept ? `, ${stats.endingsKept} read unknown and kept the ending already recorded` : "";
|
|
281
394
|
process.stderr.write(
|
|
282
395
|
`${NAME}: scraped ${stats.seen - stats.dropped} of ${stats.seen} members ` +
|
|
283
|
-
`(${stats.dropped} dropped${torn}); ${file} now holds ${merged.length} rows\n`,
|
|
396
|
+
`(${stats.dropped} dropped${torn}${kept}); ${file} now holds ${merged.length} rows\n`,
|
|
284
397
|
);
|
|
285
398
|
}
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// the transcript carried no `session_init` agent to read (no such line, or
|
|
23
23
|
// one without a string `agent`). An untyped `task` dispatch is not that
|
|
24
24
|
// shape: the omp reader records the generic `task` for it.
|
|
25
|
-
import { readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
25
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
26
26
|
import { join, basename, relative, isAbsolute, sep } from "node:path";
|
|
27
27
|
|
|
28
28
|
import { classifyRole, canonicalMemberName, CANONICAL_MEMBER_NAME_PREFIXES } from "./compute-spend.mjs";
|
|
@@ -361,6 +361,12 @@ export function foldOmpTranscript(jsonlText, filePath) {
|
|
|
361
361
|
let input = 0, cacheWrite = 0, cacheWrite1h = 0, cacheRead = 0, output = 0, cost = 0, turns = 0;
|
|
362
362
|
let sawCost = false, unsplit = false;
|
|
363
363
|
let malformedNonLastLines = 0;
|
|
364
|
+
// What the transcript's END says, read for the `ending` column: the last
|
|
365
|
+
// assistant turn's stopReason, whether a model fell back, whether the
|
|
366
|
+
// session exited cleanly, whether a budget notice reached the member, and
|
|
367
|
+
// the report files the member wrote (last write wins, read by the caller).
|
|
368
|
+
let lastStopReason = null, providerFallback = false, sessionExit = false, budgetNotice = false;
|
|
369
|
+
const reportWrites = [];
|
|
364
370
|
const entries = [];
|
|
365
371
|
const createCallIds = new Set();
|
|
366
372
|
const openedPrs = new Set();
|
|
@@ -379,6 +385,8 @@ export function foldOmpTranscript(jsonlText, filePath) {
|
|
|
379
385
|
assertOmpShaped(d, filePath);
|
|
380
386
|
if (typeof d.timestamp === "string") { firstTs ??= d.timestamp; lastTs = d.timestamp; }
|
|
381
387
|
if (d.type === "thinking_level_change" && typeof d.thinkingLevel === "string") thinking = d.thinkingLevel;
|
|
388
|
+
if (d.type === "model_change" && d.resolvedModelIsFallback === true) providerFallback = true;
|
|
389
|
+
if (d.type === "custom" && d.customType === "session_exit") sessionExit = true;
|
|
382
390
|
if (d.type === "session_init") {
|
|
383
391
|
if (typeof d.task === "string") task = d.task;
|
|
384
392
|
if (typeof d.resolvedModelIdentity === "string") resolvedModelIdentity = d.resolvedModelIdentity;
|
|
@@ -388,9 +396,11 @@ export function foldOmpTranscript(jsonlText, filePath) {
|
|
|
388
396
|
const m = d.message;
|
|
389
397
|
if (d.type === "message" && m?.role === "assistant") {
|
|
390
398
|
const blocks = Array.isArray(m.content) ? m.content : [];
|
|
399
|
+
lastStopReason = typeof m.stopReason === "string" ? m.stopReason : null;
|
|
391
400
|
for (const c of blocks) {
|
|
392
401
|
if (c?.type === "toolCall" && c.name === "bash" && typeof c.arguments?.command === "string"
|
|
393
402
|
&& GH_PR_CREATE_RE.test(c.arguments.command)) createCallIds.add(c.id);
|
|
403
|
+
reportWrites.push(...reportWritesOf(c));
|
|
394
404
|
}
|
|
395
405
|
if (m.usage) {
|
|
396
406
|
const u = m.usage;
|
|
@@ -419,6 +429,7 @@ export function foldOmpTranscript(jsonlText, filePath) {
|
|
|
419
429
|
for (const n of createdPrNumbers(text)) openedPrs.add(n);
|
|
420
430
|
}
|
|
421
431
|
}
|
|
432
|
+
if (d.type === "message" && m?.role === "user" && messageText(m).trimStart().startsWith("[budget notice]")) budgetNotice = true;
|
|
422
433
|
}
|
|
423
434
|
const span = firstTs && lastTs ? (Date.parse(lastTs) - Date.parse(firstTs)) / 1000 : 0;
|
|
424
435
|
return {
|
|
@@ -436,9 +447,42 @@ export function foldOmpTranscript(jsonlText, filePath) {
|
|
|
436
447
|
entries,
|
|
437
448
|
malformedNonLastLines,
|
|
438
449
|
openedPrs: [...openedPrs],
|
|
450
|
+
startedAt: firstTs,
|
|
451
|
+
reportWrites,
|
|
452
|
+
lastStopReason, providerFallback, sessionExit, budgetNotice,
|
|
439
453
|
};
|
|
440
454
|
}
|
|
441
455
|
|
|
456
|
+
// The report files one transcript tool call writes, as { file, content } with
|
|
457
|
+
// `file` the written path's basename: the `write` tool's arguments, or each
|
|
458
|
+
// `cat > <path> <<'EOF'` heredoc a `bash` call carries. Only a path under a
|
|
459
|
+
// `reports/` directory counts.
|
|
460
|
+
const HEREDOC_RE = /cat\s*>\s*(\S+)\s*<<\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?[^\n]*\n([\s\S]*?)\n\2[ \t]*(?=\n|$)/g;
|
|
461
|
+
const REPORT_PATH_RE = /\/reports\/[^/\s]+\.json$/;
|
|
462
|
+
function reportWritesOf(c) {
|
|
463
|
+
if (c?.type !== "toolCall") return [];
|
|
464
|
+
const a = c.arguments ?? {};
|
|
465
|
+
if (c.name === "write" && typeof a.path === "string" && typeof a.content === "string") {
|
|
466
|
+
return REPORT_PATH_RE.test(a.path) ? [{ file: basename(a.path), content: a.content }] : [];
|
|
467
|
+
}
|
|
468
|
+
if (c.name === "bash" && typeof a.command === "string") {
|
|
469
|
+
const out = [];
|
|
470
|
+
for (const m of a.command.matchAll(HEREDOC_RE)) {
|
|
471
|
+
const path = m[1].replace(/^["']|["']$/g, "");
|
|
472
|
+
if (REPORT_PATH_RE.test(path)) out.push({ file: basename(path), content: m[3] });
|
|
473
|
+
}
|
|
474
|
+
return out;
|
|
475
|
+
}
|
|
476
|
+
return [];
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// The text a transcript message carries: a bare string, or its text blocks.
|
|
480
|
+
function messageText(m) {
|
|
481
|
+
if (typeof m?.content === "string") return m.content;
|
|
482
|
+
if (!Array.isArray(m?.content)) return "";
|
|
483
|
+
return m.content.map((b) => (b?.type === "text" && typeof b.text === "string" ? b.text : "")).join("\n");
|
|
484
|
+
}
|
|
485
|
+
|
|
442
486
|
// One member record from one omp transcript's fold. `member` is the AgentId
|
|
443
487
|
// (the filename stem, e.g. `InstallVerifySearch`) — there is no separate
|
|
444
488
|
// display name, so `ticket`/`pr` extraction runs against it directly — except
|
|
@@ -577,7 +621,7 @@ export function ompSessionTranscripts(sessionDir) {
|
|
|
577
621
|
|
|
578
622
|
// Walks one omp session directory into records, via the walk above.
|
|
579
623
|
//
|
|
580
|
-
// Deliberately NOT wrapped in a blanket try/catch around
|
|
624
|
+
// Deliberately NOT wrapped in a blanket try/catch around foldOmpTranscript: the
|
|
581
625
|
// wrong-shape refusal (assertOmpShaped, inside foldOmpTranscript) must
|
|
582
626
|
// propagate all the way out of readMembers, uncaught. Only the directory
|
|
583
627
|
// listing and the raw file read are given the ordinary per-member tolerance.
|
|
@@ -591,9 +635,19 @@ export function readOmpSession(sessionDir) {
|
|
|
591
635
|
let text;
|
|
592
636
|
try { text = readFileSync(file, "utf8"); }
|
|
593
637
|
catch { continue; }
|
|
594
|
-
const
|
|
638
|
+
const folded = foldOmpTranscript(text, file); // may throw — see comment above
|
|
639
|
+
const rec = ompMemberRecord(folded, agent, spawnDepth);
|
|
595
640
|
if (!rec) continue;
|
|
596
|
-
|
|
641
|
+
// How the transcript ENDED, for the `ending` column (member-outcomes.mjs):
|
|
642
|
+
// the fold's end-of-transcript signals, and whether the member wrote its
|
|
643
|
+
// yield sidecar beside the transcript.
|
|
644
|
+
rows.push({ ...rec, session, agent, transcript: {
|
|
645
|
+
startedAt: folded.startedAt, reportWrites: folded.reportWrites, openedPrs: folded.openedPrs,
|
|
646
|
+
lastStopReason: folded.lastStopReason, providerFallback: folded.providerFallback,
|
|
647
|
+
sessionExit: folded.sessionExit, budgetNotice: folded.budgetNotice,
|
|
648
|
+
malformedNonLastLines: folded.malformedNonLastLines,
|
|
649
|
+
hasYield: existsSync(file.replace(/\.jsonl$/, ".md")),
|
|
650
|
+
} });
|
|
597
651
|
}
|
|
598
652
|
return rows;
|
|
599
653
|
}
|