@feigi/fleet-ctl 3.95.4 → 3.96.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/member-outcomes.mjs +106 -15
- package/scripts/member-record.mjs +28 -5
package/package.json
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
// Scraper for per-member model/effort facts. Pure over omp's own
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Scraper for per-member model/effort facts. Pure over omp's own transcripts:
|
|
2
|
+
// no clock, no network, no gh. Two columns look beside the subagent ones:
|
|
3
|
+
// `work_s` also reads the controller's own `<session>.jsonl` next to the
|
|
4
|
+
// session directory, and `report` asks whether the member's report file is in
|
|
5
|
+
// the scratch root (`--scratch <dir>`, default the root scratch-root.mjs
|
|
6
|
+
// prints).
|
|
5
7
|
//
|
|
6
8
|
// The transcript parsing (fold, model/effort extraction, ticket/pr naming,
|
|
7
9
|
// and the `gh pr create` result an implementer's `pr` comes from)
|
|
@@ -10,7 +12,7 @@
|
|
|
10
12
|
// them from this file needs to change.
|
|
11
13
|
|
|
12
14
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
13
|
-
import { join, basename } from "node:path";
|
|
15
|
+
import { join, basename, dirname } from "node:path";
|
|
14
16
|
|
|
15
17
|
import { readOmpSession, normalizeModel, parseMemberName, isOmpSessionDirName } from "./member-record.mjs";
|
|
16
18
|
import { stripLevel } from "./tier-roles.mjs";
|
|
@@ -75,7 +77,13 @@ export function rowsForSession(sessionDir, stats = {}, { verifyMain = verifyMain
|
|
|
75
77
|
for (const f of jsonlNames) {
|
|
76
78
|
try { newest = Math.max(newest, statSync(join(sessionDir, f)).mtimeMs); } catch { /* raced away */ }
|
|
77
79
|
}
|
|
78
|
-
const
|
|
80
|
+
const activity = { stamps: [], unread: 0 };
|
|
81
|
+
const recs = readOmpSession(sessionDir, activity); // may throw — a wrong-root refusal, not a per-file fault
|
|
82
|
+
const controller = controllerStamps(sessionDir);
|
|
83
|
+
const workUnknown = typeof controller === "string" ? controller
|
|
84
|
+
: activity.unread ? `${activity.unread} unreadable transcript${activity.unread === 1 ? "" : "s"}` : null;
|
|
85
|
+
const halts = workUnknown ? [] : haltsOf(activity.stamps.concat(controller));
|
|
86
|
+
stats.workSUnknown = workUnknown;
|
|
79
87
|
stats.seen = jsonlNames.length;
|
|
80
88
|
stats.dropped = jsonlNames.length - recs.length;
|
|
81
89
|
stats.torn = 0;
|
|
@@ -93,9 +101,78 @@ export function rowsForSession(sessionDir, stats = {}, { verifyMain = verifyMain
|
|
|
93
101
|
tokensCacheWrite1h: r.tokens_cache_write_1h ?? "", cost: r.cost == null ? "" : Number(r.cost.toFixed(6)),
|
|
94
102
|
ending: endings[i],
|
|
95
103
|
dispatchModel: dispatchModel(r.resolvedModelIdentity),
|
|
104
|
+
workS: workUnknown ? null : r.transcript.timed ? workSeconds(r.transcript.workParts, halts) : "",
|
|
96
105
|
}));
|
|
97
106
|
}
|
|
98
107
|
|
|
108
|
+
// The `work_s` column: a member's working time in whole seconds. Its parts
|
|
109
|
+
// are the fold's (member-record.mjs): each run of entries between two
|
|
110
|
+
// `session_exit` entries, from its first entry to its last, so omp's idle
|
|
111
|
+
// wait before each exit and the wait until a wake are in no part. Every halt
|
|
112
|
+
// is then subtracted where it falls inside a part. `wall_s` stays the raw
|
|
113
|
+
// span, teardown included.
|
|
114
|
+
//
|
|
115
|
+
// A halt is a stretch over HALT_MS in which no transcript of the run records
|
|
116
|
+
// an entry: every transcript in the session directory, nested ones and those
|
|
117
|
+
// that book no row included, and the controller's own `<session>.jsonl` beside
|
|
118
|
+
// the directory, the one that keeps writing while no member runs. Halts are
|
|
119
|
+
// the gaps between consecutive instants of that one sorted list, so no two
|
|
120
|
+
// overlap, and a part never shares time with another part of the same member:
|
|
121
|
+
// no overlap is subtracted twice.
|
|
122
|
+
//
|
|
123
|
+
// Without the controller transcript no halt can be told from a member's own
|
|
124
|
+
// quiet stretch, and with any transcript of the run unreadable, or a line of
|
|
125
|
+
// the controller's lost, entries are missing from the instants, so a stretch
|
|
126
|
+
// they wrote through reads as a halt. Each way every row's `work_s` is `null`:
|
|
127
|
+
// unknown, written blank, and never written over a recorded value
|
|
128
|
+
// (mergeRows); `stats.workSUnknown` names the cause. A transcript with no
|
|
129
|
+
// timestamped entry reads `""`, blank because there is nothing to time.
|
|
130
|
+
export const HALT_MS = 60 * 60 * 1000;
|
|
131
|
+
|
|
132
|
+
// The controller transcript's entry instants, in epoch ms, or a string naming
|
|
133
|
+
// why they cannot be had. It is not a member transcript, so a line is read for
|
|
134
|
+
// its timestamp alone. An unparseable LAST line is a write in progress and is
|
|
135
|
+
// skipped, as the member fold skips it; an unparseable line before it is lost
|
|
136
|
+
// data that may have been the entry breaking a stretch, so a halt there could
|
|
137
|
+
// not be told from the run writing, and the instants are unknown.
|
|
138
|
+
function controllerStamps(sessionDir) {
|
|
139
|
+
let text;
|
|
140
|
+
try { text = readFileSync(join(dirname(sessionDir), `${basename(sessionDir)}.jsonl`), "utf8"); }
|
|
141
|
+
catch (e) { return e?.code === "ENOENT" ? "no controller transcript" : `unreadable controller transcript: ${e?.code ?? e}`; }
|
|
142
|
+
const stamps = [];
|
|
143
|
+
const lines = text.split("\n");
|
|
144
|
+
for (let i = 0; i < lines.length; i++) {
|
|
145
|
+
if (!lines[i].trim()) continue;
|
|
146
|
+
let d;
|
|
147
|
+
try { d = JSON.parse(lines[i]); }
|
|
148
|
+
catch { if (i !== lines.length - 1) return "an unparseable controller transcript line"; continue; }
|
|
149
|
+
const ms = typeof d?.timestamp === "string" ? Date.parse(d.timestamp) : NaN;
|
|
150
|
+
if (Number.isFinite(ms)) stamps.push(ms);
|
|
151
|
+
}
|
|
152
|
+
return stamps;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Every gap over HALT_MS between consecutive instants, as [from, to].
|
|
156
|
+
export function haltsOf(stamps) {
|
|
157
|
+
const sorted = [...stamps].sort((a, b) => a - b);
|
|
158
|
+
const halts = [];
|
|
159
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
160
|
+
if (sorted[i] - sorted[i - 1] > HALT_MS) halts.push([sorted[i - 1], sorted[i]]);
|
|
161
|
+
}
|
|
162
|
+
return halts;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The parts' summed length less each halt's share inside them, in whole seconds.
|
|
166
|
+
export function workSeconds(parts, halts) {
|
|
167
|
+
let ms = 0;
|
|
168
|
+
for (const [from, to] of parts) {
|
|
169
|
+
if (!(to > from)) continue;
|
|
170
|
+
ms += to - from;
|
|
171
|
+
for (const [h0, h1] of halts) ms -= Math.max(0, Math.min(to, h1) - Math.max(from, h0));
|
|
172
|
+
}
|
|
173
|
+
return Math.round(ms / 1000);
|
|
174
|
+
}
|
|
175
|
+
|
|
99
176
|
// The `dispatch_model` column: the model fixed at dispatch, `session_init`'s
|
|
100
177
|
// own model (the record's `resolvedModelIdentity`), with its provider prefix
|
|
101
178
|
// and `:<level>` suffix stripped. `model` is the member's last turn, which a
|
|
@@ -120,7 +197,7 @@ export const COLUMNS = [
|
|
|
120
197
|
"session", "run_date", "role", "member", "model", "effort", "ticket", "pr",
|
|
121
198
|
"tokens_cache_create", "tokens_out", "wall_s", "turns", "agent", "harness",
|
|
122
199
|
"subagent_type", "report", "tokens_in", "tokens_cache_read", "tokens_cache_write_1h", "cost",
|
|
123
|
-
"ending", "dispatch_model",
|
|
200
|
+
"ending", "dispatch_model", "work_s",
|
|
124
201
|
];
|
|
125
202
|
|
|
126
203
|
// `report`: what reportStatus() says of the row's member under `scratch` — a
|
|
@@ -235,7 +312,7 @@ function verifyMainWithGit(sha) {
|
|
|
235
312
|
const FIELD = {
|
|
236
313
|
tokens_cache_create: "tokensCacheCreate", tokens_out: "tokensOut", wall_s: "wallS",
|
|
237
314
|
subagent_type: "subagentType", tokens_in: "tokensIn", tokens_cache_read: "tokensCacheRead",
|
|
238
|
-
tokens_cache_write_1h: "tokensCacheWrite1h", dispatch_model: "dispatchModel",
|
|
315
|
+
tokens_cache_write_1h: "tokensCacheWrite1h", dispatch_model: "dispatchModel", work_s: "workS",
|
|
239
316
|
};
|
|
240
317
|
const field = (c) => FIELD[c] ?? c;
|
|
241
318
|
// Keyed on the transcript's path-relative stem, not the member name: `member`
|
|
@@ -249,20 +326,30 @@ const key = (r) => `${r.session}\0${r.agent}`;
|
|
|
249
326
|
// regeneration both safe, and it is the reason no backfill-only code path is
|
|
250
327
|
// needed: the idempotency backfill wants is the idempotency a re-run wants.
|
|
251
328
|
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
329
|
+
// Two cells are the exception, each keeping what the replaced row recorded
|
|
330
|
+
// where this scrape could not say. An incoming `unknown` ending keeps the
|
|
331
|
+
// recorded ending: `unknown` is this scrape failing to say (a verifier that is
|
|
332
|
+
// offline, a transcript gone unreadable), not a reading, so it never
|
|
255
333
|
// overwrites one: a recorded `bail:fixed` would otherwise flip to `unknown`
|
|
256
|
-
// on any re-scrape run without network. `stats.endingsKept` counts them.
|
|
334
|
+
// on any re-scrape run without network. `stats.endingsKept` counts them. An
|
|
335
|
+
// incoming `null` work_s (halts that could not be found) keeps a
|
|
336
|
+
// recorded `work_s` the same way; `stats.workSKept` counts those.
|
|
257
337
|
export function mergeRows(existing, incoming, stats = {}) {
|
|
258
338
|
const by = new Map((existing ?? []).map((r) => [key(r), r]));
|
|
259
339
|
stats.endingsKept = 0;
|
|
340
|
+
stats.workSKept = 0;
|
|
260
341
|
for (const r of incoming ?? []) {
|
|
261
342
|
const old = by.get(key(r));
|
|
343
|
+
let row = r;
|
|
262
344
|
if (r.ending === "unknown" && old?.ending && old.ending !== "unknown") {
|
|
263
|
-
|
|
345
|
+
row = { ...row, ending: old.ending };
|
|
264
346
|
stats.endingsKept++;
|
|
265
|
-
}
|
|
347
|
+
}
|
|
348
|
+
if (r.workS === null && (old?.workS ?? "") !== "") {
|
|
349
|
+
row = { ...row, workS: old.workS };
|
|
350
|
+
stats.workSKept++;
|
|
351
|
+
}
|
|
352
|
+
by.set(key(r), row);
|
|
266
353
|
}
|
|
267
354
|
return [...by.values()].sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
|
|
268
355
|
}
|
|
@@ -406,8 +493,12 @@ if (isCLI(import.meta.url)) {
|
|
|
406
493
|
// a healthy one — 0-of-2 was indistinguishable from full success.
|
|
407
494
|
const torn = stats.torn ? `, ${stats.torn} torn mid-write` : "";
|
|
408
495
|
const kept = stats.endingsKept ? `, ${stats.endingsKept} read unknown and kept the ending already recorded` : "";
|
|
496
|
+
// Whatever left every row's `work_s` unknown, and how many rows kept the
|
|
497
|
+
// value already recorded, after the yield rather than inside it.
|
|
498
|
+
const workKept = stats.workSKept ? `, ${stats.workSKept} kept the work_s already recorded` : "";
|
|
499
|
+
const work = stats.workSUnknown ? `; work_s unknown (${stats.workSUnknown})${workKept}` : "";
|
|
409
500
|
process.stderr.write(
|
|
410
501
|
`${NAME}: scraped ${stats.seen - stats.dropped} of ${stats.seen} members ` +
|
|
411
|
-
`(${stats.dropped} dropped${torn}${kept}); ${file} now holds ${merged.length} rows\n`,
|
|
502
|
+
`(${stats.dropped} dropped${torn}${kept}); ${file} now holds ${merged.length} rows${work}\n`,
|
|
412
503
|
);
|
|
413
504
|
}
|
|
@@ -358,6 +358,13 @@ function createdPrNumbers(text) {
|
|
|
358
358
|
export function foldOmpTranscript(jsonlText, filePath) {
|
|
359
359
|
let model = null, thinking = null, task = null, resolvedModelIdentity = null, agent = null;
|
|
360
360
|
let firstTs = null, lastTs = null;
|
|
361
|
+
// For the `work_s` column (member-outcomes.mjs): every timestamped entry's
|
|
362
|
+
// instant, and the member's working parts — each run of timestamped entries
|
|
363
|
+
// between two `session_exit` entries, as [first, last] in epoch ms. The
|
|
364
|
+
// `session_exit` entry itself closes a part and never extends one, so the
|
|
365
|
+
// idle wait before it and the wait until the next entry are in no part.
|
|
366
|
+
const stamps = [], workParts = [];
|
|
367
|
+
let partFirst = null, partLast = null;
|
|
361
368
|
let input = 0, cacheWrite = 0, cacheWrite1h = 0, cacheRead = 0, output = 0, cost = 0, turns = 0;
|
|
362
369
|
let sawCost = false, unsplit = false, unpriced = false;
|
|
363
370
|
let malformedNonLastLines = 0;
|
|
@@ -383,10 +390,16 @@ export function foldOmpTranscript(jsonlText, filePath) {
|
|
|
383
390
|
try { d = JSON.parse(raw); }
|
|
384
391
|
catch { if (i !== lines.length - 1) malformedNonLastLines++; continue; }
|
|
385
392
|
assertOmpShaped(d, filePath);
|
|
386
|
-
|
|
393
|
+
let ms = NaN;
|
|
394
|
+
if (typeof d.timestamp === "string") { firstTs ??= d.timestamp; lastTs = d.timestamp; ms = Date.parse(d.timestamp); }
|
|
395
|
+
if (Number.isFinite(ms)) stamps.push(ms);
|
|
396
|
+
if (d.type === "custom" && d.customType === "session_exit") {
|
|
397
|
+
sessionExit = true;
|
|
398
|
+
if (partFirst !== null) workParts.push([partFirst, partLast]);
|
|
399
|
+
partFirst = partLast = null;
|
|
400
|
+
} else if (Number.isFinite(ms)) { partFirst ??= ms; partLast = ms; }
|
|
387
401
|
if (d.type === "thinking_level_change" && typeof d.thinkingLevel === "string") thinking = d.thinkingLevel;
|
|
388
402
|
if (d.type === "model_change" && d.resolvedModelIsFallback === true) providerFallback = true;
|
|
389
|
-
if (d.type === "custom" && d.customType === "session_exit") sessionExit = true;
|
|
390
403
|
if (d.type === "session_init") {
|
|
391
404
|
if (typeof d.task === "string") task = d.task;
|
|
392
405
|
if (typeof d.resolvedModelIdentity === "string") resolvedModelIdentity = d.resolvedModelIdentity;
|
|
@@ -433,6 +446,7 @@ export function foldOmpTranscript(jsonlText, filePath) {
|
|
|
433
446
|
}
|
|
434
447
|
if (d.type === "message" && m?.role === "user" && messageText(m).trimStart().startsWith("[budget notice]")) budgetNotice = true;
|
|
435
448
|
}
|
|
449
|
+
if (partFirst !== null) workParts.push([partFirst, partLast]);
|
|
436
450
|
const span = firstTs && lastTs ? (Date.parse(lastTs) - Date.parse(firstTs)) / 1000 : 0;
|
|
437
451
|
return {
|
|
438
452
|
model, thinking, task, resolvedModelIdentity, agent,
|
|
@@ -453,6 +467,7 @@ export function foldOmpTranscript(jsonlText, filePath) {
|
|
|
453
467
|
malformedNonLastLines,
|
|
454
468
|
openedPrs: [...openedPrs],
|
|
455
469
|
startedAt: firstTs,
|
|
470
|
+
stamps, workParts,
|
|
456
471
|
reportWrites,
|
|
457
472
|
lastStopReason, providerFallback, sessionExit, budgetNotice,
|
|
458
473
|
};
|
|
@@ -867,7 +882,13 @@ export function ompSessionTranscripts(sessionDir) {
|
|
|
867
882
|
// wrong-shape refusal (assertOmpShaped, inside foldOmpTranscript) must
|
|
868
883
|
// propagate all the way out of readMembers, uncaught. Only the directory
|
|
869
884
|
// listing and the raw file read are given the ordinary per-member tolerance.
|
|
870
|
-
|
|
885
|
+
//
|
|
886
|
+
// `activity`, when given, is `{ stamps: [], unread: 0 }`: `stamps` collects the
|
|
887
|
+
// entry instants of EVERY transcript read here, including one that yields no
|
|
888
|
+
// record, and `unread` counts the transcripts that could not be read at all,
|
|
889
|
+
// whose instants are missing from `stamps`. It is the session-wide view
|
|
890
|
+
// member-outcomes.mjs reads halts off.
|
|
891
|
+
export function readOmpSession(sessionDir, activity = null) {
|
|
871
892
|
let transcripts;
|
|
872
893
|
try { transcripts = ompSessionTranscripts(sessionDir); }
|
|
873
894
|
catch { return []; }
|
|
@@ -876,19 +897,21 @@ export function readOmpSession(sessionDir) {
|
|
|
876
897
|
for (const { file, agent, spawnDepth } of transcripts) {
|
|
877
898
|
let text;
|
|
878
899
|
try { text = readFileSync(file, "utf8"); }
|
|
879
|
-
catch { continue; }
|
|
900
|
+
catch { if (activity) activity.unread++; continue; }
|
|
880
901
|
const folded = foldOmpTranscript(text, file); // may throw — see comment above
|
|
902
|
+
if (activity) for (const ms of folded.stamps) activity.stamps.push(ms);
|
|
881
903
|
const rec = ompMemberRecord(folded, agent, spawnDepth);
|
|
882
904
|
if (!rec) continue;
|
|
883
905
|
// How the transcript ENDED, for the `ending` column (member-outcomes.mjs):
|
|
884
906
|
// the fold's end-of-transcript signals, and whether the member wrote its
|
|
885
|
-
// yield sidecar beside the transcript
|
|
907
|
+
// yield sidecar beside the transcript; and its working parts, for `work_s`.
|
|
886
908
|
rows.push({ ...rec, session, agent, transcript: {
|
|
887
909
|
startedAt: folded.startedAt, reportWrites: folded.reportWrites, openedPrs: folded.openedPrs,
|
|
888
910
|
lastStopReason: folded.lastStopReason, providerFallback: folded.providerFallback,
|
|
889
911
|
sessionExit: folded.sessionExit, budgetNotice: folded.budgetNotice,
|
|
890
912
|
malformedNonLastLines: folded.malformedNonLastLines,
|
|
891
913
|
hasYield: existsSync(file.replace(/\.jsonl$/, ".md")),
|
|
914
|
+
timed: folded.stamps.length > 0, workParts: folded.workParts,
|
|
892
915
|
} });
|
|
893
916
|
}
|
|
894
917
|
return rows;
|