@feigi/fleet-ctl 3.91.3 → 3.92.1

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.91.3",
3
+ "version": "3.92.1",
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": {
@@ -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
- return recs.map((r) => ({
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
- export function mergeRows(existing, incoming) {
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
- for (const r of incoming ?? []) by.set(key(r), r);
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 readOmpMember: the
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 rec = readOmpMember(text, file, agent, spawnDepth); // may throw — see comment above
638
+ const folded = foldOmpTranscript(text, file); // may throw — see comment above
639
+ const rec = ompMemberRecord(folded, agent, spawnDepth);
595
640
  if (!rec) continue;
596
- rows.push({ ...rec, session, agent });
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
  }
@@ -210,7 +210,7 @@ Write each state change through `ledger.mjs` before you act on it, never by hand
210
210
 
211
211
  Compaction parks every in-flight member and nothing reports it, so on resume take the registry census first (**Member unaccounted**, in **Failure handling**) before you trust a live token.
212
212
 
213
- `ledger.mjs token <key> <token>…` adds a token and keeps every token already on the row; re-typing the row with `row` drops the tokens you do not restate. Write member tokens and `review=` tokens only through `ledger.mjs`, never by hand.
213
+ `ledger.mjs token <key> <token>…` adds a token and keeps every token already on the row but a `tier=` it replaces; `row` drops the tokens you do not restate. Write member tokens and `review=` tokens only through `ledger.mjs`, never by hand.
214
214
 
215
215
  Never rotate the ledger mid-run: it is phase 0's step alone.
216
216