@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.95.4",
3
+ "version": "3.96.0",
4
4
  "description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -1,7 +1,9 @@
1
- // Scraper for per-member model/effort facts. Pure over omp's own subagent
2
- // transcripts: no clock, no network, no gh. The one column that looks beside
3
- // them is `report`, which asks whether the member's report file is in the
4
- // scratch root (`--scratch <dir>`, default the root scratch-root.mjs prints).
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 recs = readOmpSession(sessionDir); // may throw — a wrong-root refusal, not a per-file fault
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
- // One cell is the exception: an incoming `unknown` ending keeps the ending the
253
- // replaced row recorded. `unknown` is this scrape failing to say (a verifier
254
- // that is offline, a transcript gone unreadable), not a reading, so it never
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
- by.set(key(r), { ...r, ending: old.ending });
345
+ row = { ...row, ending: old.ending };
264
346
  stats.endingsKept++;
265
- } else by.set(key(r), r);
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
- if (typeof d.timestamp === "string") { firstTs ??= d.timestamp; lastTs = d.timestamp; }
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
- export function readOmpSession(sessionDir) {
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;