@feigi/fleet-ctl 3.96.0 → 3.97.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.96.0",
3
+ "version": "3.97.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": {
@@ -252,6 +252,19 @@ export function endingOf({ member, pr, signals = null, earlierPrs = new Set(), v
252
252
 
253
253
  // The member's last report, when the last one it wrote is a valid impl report
254
254
  // naming this member; null when it wrote none, or the last one is malformed.
255
+ // "Valid" is `reportViolation("impl", …)` as it stands now, so a last report
256
+ // that fails an impl rule is no report and the member reads the stop its
257
+ // transcript actually had (`provider`, `budget`, `abort`, `crash`, `hollow`).
258
+ // That includes a `bailed` report that names a `pr` or a `head`, and a
259
+ // `blocked` one that does: a bail is the pre-code outcome and a block reports
260
+ // no PR, so neither reads `bail:<cause>` or `blocked`. A `covered` bail names
261
+ // its PR in `bailDetail` with `pr: null`, and stays a bail.
262
+ // A transcript written before that rule existed is read under the current
263
+ // rule, so a re-scrape can turn a recorded `bail:<cause>` into a non-bail
264
+ // ending. When this was written the one recorded case was `impl-3655`,
265
+ // `bail:hands` with `pr: null` and a `head`, which reads `hollow` once
266
+ // re-scraped. That flip is accepted: it matches the glossary's "valid bail",
267
+ // and `mergeRows()` keeps no earlier `bail:*` row against it.
255
268
  function lastReport(reportWrites, leaf) {
256
269
  const last = reportWrites.filter((r) => r.file === `${leaf}.json`).at(-1);
257
270
  if (!last) return null;
@@ -31,13 +31,14 @@
31
31
  // their nested members, and its merge through its Ruling — merged when that
32
32
  // PR's state is MERGED. A Pull is counted, or excluded (no Admissible row),
33
33
  // pending (its PR still open, or not in the list), left out (never merged, no
34
- // `fixed` bail, and every ending the ticket's or neither's) or unpriced (a
34
+ // `fixed` bail, and every ending the ticket's or neither's), unpriced (a
35
35
  // booked row with a blank `cost`, or a Ruling whose PR none of its
36
- // implementers carries); only counted Pulls enter a cell's figures. A `cost`
37
- // that is neither blank nor a number is an input error (exit 2), as is a
38
- // ticket-features `run_date` that is neither blank nor YYYY-MM-DD, or a ruling
39
- // of a windowed Pull's ticket whose run_date is not YYYY-MM-DD or whose verdict
40
- // is not yes or no.
36
+ // implementers carries) or untimed (a booked row whose `agent` names no
37
+ // parent with a blank `work_s`); only counted Pulls enter a cell's figures. A
38
+ // `cost` or `work_s` that is neither blank nor a number is an input error
39
+ // (exit 2), as is a ticket-features `run_date` that is neither blank nor
40
+ // YYYY-MM-DD, or a ruling of a windowed Pull's ticket whose run_date is not
41
+ // YYYY-MM-DD or whose verdict is not yes or no.
41
42
  //
42
43
  // PER CELL GENERATION (pull-attribution.mjs's GENERATION), over its counted
43
44
  // Pulls: `generation`, the dispatch model; `n_pulls`; `n_merged`; `n_claims`
@@ -49,12 +50,12 @@
49
50
  // merged Pulls alone, descriptive; `router_usd`, the Pulls' own routing cost,
50
51
  // summed and never in their spend. `usd_diff_ci95` is a bootstrap 95% interval
51
52
  // on `mean_usd` minus the baseline's — printed to be read, never judged on.
52
- // Then the `left_out` and `unpriced` Pull counts, and the cell's `excluded`
53
- // count, which no generation owns. The baseline, the guard file's cells and
54
- // every trip are each cell's newest generation alone, so a cell whose model
55
- // changed is judged afresh once its new generation reaches MIN_N merged PRs.
56
- // Each older generation prints on its own line beside the newest, with no
57
- // verdict and no interval.
53
+ // Then the `left_out`, `unpriced` and `untimed` Pull counts, and the cell's
54
+ // `excluded` count, which no generation owns. The baseline, the guard file's
55
+ // cells and every trip are each cell's newest generation alone, so a cell
56
+ // whose model changed is judged afresh once its new generation reaches MIN_N
57
+ // merged PRs. Each older generation prints on its own line beside the newest,
58
+ // with no verdict and no interval.
58
59
 
59
60
  import { execFileSync } from "node:child_process";
60
61
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -120,6 +121,7 @@ export function computeReport({ members, tiers, features, prs, routerTable = nul
120
121
  usd_diff_ci95: !newest || cell === BASELINE_CELL || !base ? null : bootstrapDiffCI(bootUnits(g), bootUnits(base)),
121
122
  left_out: g.left_out,
122
123
  unpriced: g.unpriced,
124
+ untimed: g.untimed,
123
125
  });
124
126
  const figures = [...tallies.values()].map((t) => ({
125
127
  ...figuresOf(t.cell, t, true),
@@ -212,7 +214,7 @@ export function crossCheck(rows, pricing) {
212
214
 
213
215
  const TSV_COLUMNS = [
214
216
  "cell", "generation", "n_pulls", "n_merged", "n_claims", "n_failed", "fail_rate", "mean_usd", "median_usd", "router_usd", "usd_diff_ci95",
215
- "excluded", "left_out", "unpriced", "guard",
217
+ "excluded", "left_out", "unpriced", "untimed", "guard",
216
218
  ];
217
219
 
218
220
  // One line per cell's newest generation, carrying the guard's verdict on it,
@@ -247,7 +249,7 @@ export function guardFile(report, computedAt) {
247
249
  computed_at: computedAt,
248
250
  window_start: report.window_start,
249
251
  baseline: report.baseline,
250
- cells: report.cells.map((f) => ({ cell: f.cell, generation: f.generation, n: f.n_merged, mean_usd: f.mean_usd, fail_rate: f.fail_rate })),
252
+ cells: report.cells.map((f) => ({ cell: f.cell, generation: f.generation, n: f.n_merged, mean_usd: f.mean_usd, fail_rate: f.fail_rate, untimed: f.untimed })),
251
253
  tripped: report.tripped,
252
254
  verdict: report.verdict,
253
255
  retire: report.retire,
@@ -36,6 +36,7 @@
36
36
  // implementer ending is the ticket's or neither's;
37
37
  // unpriced a booked row's `cost` is blank, or the Pull took its Ruling
38
38
  // without carrying its PR;
39
+ // untimed a booked row whose `agent` names no parent has a blank `work_s`;
39
40
  // counted every other Pull: the only ones in a cell's figures.
40
41
  //
41
42
  // ENDINGS come from member-outcomes' `ending` column. The cell's: `hollow`,
@@ -50,6 +51,12 @@
50
51
  // SPEND is the recorded `cost` of every row booked to the Pull. A `cost` that
51
52
  // is neither blank nor a number is refused, naming the row.
52
53
  //
54
+ // WORK TIME is the summed `work_s` of the rows booked to the Pull whose
55
+ // `agent` names no parent: every implementer, rescues included, and the PR's
56
+ // review, fix and finisher members. A nested member adds nothing, since its
57
+ // time lies inside its parent's. A `work_s` that is neither blank nor a number
58
+ // is refused, naming the row.
59
+ //
53
60
  // GENERATION. A cell's figures are per Cell generation: the cell on one
54
61
  // dispatch model, member-outcomes' `dispatch_model`. A Pull's generation is
55
62
  // the dispatch model of its first Admissible implementer row in dispatch
@@ -242,23 +249,26 @@ export function pullGenerations(pulls, members) {
242
249
  return new Map(pulls.map((p) => [p, generationOf(impls.get(p), p.chosen_cell)]));
243
250
  }
244
251
 
245
- function costOf(row) {
246
- const c = String(row.cost ?? "");
252
+ // A booked row's recorded number in `column` (`field` on the parsed row; `column` defaults to `field` where the two names agree): null when blank; refused, naming the row, when neither blank nor a number.
253
+ function numberOf(row, field, column = field) {
254
+ const c = String(row[field] ?? "");
247
255
  if (c === "") return null;
248
256
  const n = Number(c);
249
- if (c.trim() === "" || !Number.isFinite(n)) throw new AttributionError(`member-outcomes row session ${row.session} agent ${row.agent}: cost '${c}' is not a number`);
257
+ if (c.trim() === "" || !Number.isFinite(n)) throw new AttributionError(`member-outcomes row session ${row.session} agent ${row.agent}: ${column} '${c}' is not a number`);
250
258
  return n;
251
259
  }
252
260
 
253
261
  /**
254
262
  * One unit per ticket-features row dated on or after `windowStart` (every dated
255
263
  * row when null), in file order: `{ pull, cell, stratum, generation, status,
256
- * merged, pr, ruling, pendingPr, claims, falseClaims, rows, spend }`,
257
- * `generation` null exactly when the Pull is excluded. `prs` is `gh pr list`'s
264
+ * merged, pr, ruling, pendingPr, claims, falseClaims, rows, spend, workS }`,
265
+ * `generation` null exactly when the Pull is excluded. `spend` is null when a
266
+ * booked `cost` is blank, and `workS`, its work time in seconds, when a booked
267
+ * `work_s` of a row naming no parent is. `prs` is `gh pr list`'s
258
268
  * `{ number, state }` rows, or null when PR state is unknown (the fit). `stratum`
259
269
  * maps a features row to its stratum. Throws `AttributionError` on a features
260
- * row it cannot date or a booked `cost` it cannot read, and `RulingError` as
261
- * `rulingFor` does.
270
+ * row it cannot date or a booked `cost` or `work_s` it cannot read, and
271
+ * `RulingError` as `rulingFor` does.
262
272
  */
263
273
  export function attribute({ features, members, tiers, prs = null, windowStart = null, stratum = () => null }) {
264
274
  const pulls = datedRows(features);
@@ -326,11 +336,15 @@ export function attribute({ features, members, tiers, prs = null, windowStart =
326
336
  if (m.ending === "bail:fixed" || m.ending === "bail:fixed-refuted") claims++;
327
337
  if (m.ending === "bail:fixed-refuted") falseClaims++;
328
338
  }
329
- let spend = 0, blank = false;
339
+ let spend = 0, blank = false, workS = 0, untimed = false;
330
340
  for (const m of booked) {
331
- const c = costOf(m);
341
+ const c = numberOf(m, "cost");
332
342
  if (c === null) blank = true;
333
343
  else spend += c;
344
+ const w = numberOf(m, "workS", "work_s");
345
+ if (String(m.agent).includes("/")) continue;
346
+ if (w === null) untimed = true;
347
+ else workS += w;
334
348
  }
335
349
  const generation = generationOf(own, pull.chosen_cell);
336
350
  let status = "counted";
@@ -338,9 +352,10 @@ export function attribute({ features, members, tiers, prs = null, windowStart =
338
352
  else if (pendingPr !== null) status = "pending";
339
353
  else if (claims === 0 && own.every((m) => ticketsEnding(m.ending) || NEITHER_ENDINGS.has(m.ending))) status = "left-out";
340
354
  else if (blank || (r && !r.carried)) status = "unpriced";
355
+ else if (untimed) status = "untimed";
341
356
  return {
342
357
  pull, cell: pull.chosen_cell, stratum: stratum(pull), generation, status, merged, pr: r?.pr ?? null, ruling: r?.ruling ?? null,
343
- pendingPr, claims, falseClaims, rows: booked, spend: blank ? null : spend,
358
+ pendingPr, claims, falseClaims, rows: booked, spend: blank ? null : spend, workS: untimed ? null : workS,
344
359
  };
345
360
  });
346
361
  }
@@ -365,11 +380,12 @@ export function generations(units) {
365
380
 
366
381
  /**
367
382
  * Per cell, in cell-name order: `{ cell, generation, n, merged, claims, failed,
368
- * spend, pulls, excluded, left_out, unpriced, pending, older }`. `generation`
369
- * is the cell's newest (null when no Pull of it has one), and the figures
370
- * beside it are that generation's alone: `n`, `merged`, `claims`, `failed` and
371
- * `spend` over its counted Pulls, `pulls` those counted units themselves in
372
- * the order of `units`, then its left-out, unpriced and pending Pull counts.
383
+ * spend, workS, pulls, excluded, left_out, unpriced, untimed, pending, older }`.
384
+ * `generation` is the cell's newest (null when no Pull of it has one), and the
385
+ * figures beside it are that generation's alone: `n`, `merged`, `claims`,
386
+ * `failed`, `spend` and `workS` (work time, in seconds) over its counted
387
+ * Pulls, `pulls` those counted units themselves in the order of `units`, then
388
+ * its left-out, unpriced, untimed and pending Pull counts.
373
389
  * `excluded` counts the cell's excluded Pulls, which have no generation.
374
390
  * `older` is an array, newest first, of every other generation's figures in
375
391
  * the same shape, each carrying its `generation`. `order` is `generations()`
@@ -380,7 +396,8 @@ export function generations(units) {
380
396
  export function tally(units, order = generations(units)) {
381
397
  // `n` is read off `pulls`, so the count and the list cannot drift apart.
382
398
  const figures = (generation) => ({
383
- generation, get n() { return this.pulls.length; }, merged: 0, claims: 0, failed: 0, spend: 0, pulls: [], left_out: 0, unpriced: 0, pending: 0,
399
+ generation, get n() { return this.pulls.length; }, merged: 0, claims: 0, failed: 0, spend: 0, workS: 0, pulls: [],
400
+ left_out: 0, unpriced: 0, untimed: 0, pending: 0,
384
401
  });
385
402
  const cells = new Map();
386
403
  for (const u of units) {
@@ -396,6 +413,7 @@ export function tally(units, order = generations(units)) {
396
413
  f.claims += u.claims;
397
414
  f.failed += u.falseClaims;
398
415
  f.spend += u.spend;
416
+ f.workS += u.workS;
399
417
  f.pulls.push(u);
400
418
  } else f[u.status.replace("-", "_")]++;
401
419
  }
@@ -347,8 +347,10 @@ function fitPulls({ features, members, verdicts, window }) {
347
347
  }
348
348
 
349
349
  // Per stratum and `*`, per cell, its newest generation's figures: `n` counted
350
- // Pulls, `merged`, $ per merged PR, false claims over claims, fix rounds per
351
- // merged PR, and its left-out and unpriced Pull counts. Beside them the cell's
350
+ // Pulls, `merged`, $ per merged PR, minutes of work time per merged PR, false
351
+ // claims over claims, fix rounds per merged PR, and its left-out, unpriced and
352
+ // untimed Pull counts. Both per merged PR figures take every counted Pull,
353
+ // merged or not, over the merged ones. Beside them the cell's
352
354
  // excluded Pull count, `generation` naming the newest generation's model, and
353
355
  // `older`, each older generation's model to its figures, newest first. Beside
354
356
  // `estimates`, `pulls`: per stratum and `*`, per cell, the newest generation's
@@ -370,11 +372,13 @@ function estimate(units, order) {
370
372
  claims: g.claims,
371
373
  failed: g.failed,
372
374
  usd_per_merged: g.merged ? round4(g.spend / g.merged) : null,
375
+ minutes_per_merged: g.merged ? round4(g.workS / 60 / g.merged) : null,
373
376
  fail_rate: g.claims ? round4(g.failed / g.claims) : null,
374
377
  fix_rounds: g.merged ? round4(fix / g.merged) : null,
375
378
  review_findings: null,
376
379
  left_out: g.left_out,
377
380
  unpriced: g.unpriced,
381
+ untimed: g.untimed,
378
382
  };
379
383
  };
380
384
  estimates[s][t.cell] = {
@@ -532,11 +536,11 @@ export function mergedSince({ table, features, members, verdicts, fitted }) {
532
536
  // Each cell's Pull counts over every stratum, as the fit prints them: its
533
537
  // newest generation, then each older one.
534
538
  function cellCounts(estimates) {
535
- const usd = (x) => (x === null ? "-" : x.toFixed(2));
536
- const counts = (e) => `n=${e.n} merged=${e.merged} failed=${e.failed} usd_per_merged=${usd(e.usd_per_merged)}`;
539
+ const two = (x) => (x === null ? "-" : x.toFixed(2));
540
+ const counts = (e) => `n=${e.n} merged=${e.merged} failed=${e.failed} usd_per_merged=${two(e.usd_per_merged)} minutes_per_merged=${two(e.minutes_per_merged)}`;
537
541
  return Object.entries(estimates["*"] ?? {})
538
542
  .map(([c, e]) => [
539
- `${c} ${e.generation ?? "-"} ${counts(e)} excluded=${e.excluded} left_out=${e.left_out} unpriced=${e.unpriced}`,
543
+ `${c} ${e.generation ?? "-"} ${counts(e)} excluded=${e.excluded} left_out=${e.left_out} unpriced=${e.unpriced} untimed=${e.untimed}`,
540
544
  ...Object.entries(e.older).map(([g, o]) => `${c} ${g} (older) ${counts(o)}`),
541
545
  ].join(", "))
542
546
  .join(", ") || "none";
@@ -684,7 +688,7 @@ function main() {
684
688
  }
685
689
  return sha;
686
690
  };
687
- // The join throws on a features row it cannot date, a booked member row whose `cost` is not a number, and a
691
+ // The join throws on a features row it cannot date, a booked member row whose `cost` or `work_s` is not a number, and a
688
692
  // ruling of a windowed Pull's ticket it cannot place — for `fit` and `--check` too on a Pull the fit then skips
689
693
  // as a live B classifier's, since every windowed Pull is booked before that skip. Anything else it throws is a
690
694
  // bug, never bad input, so it surfaces with its stack.