@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 +1 -1
- package/scripts/member-outcomes.mjs +13 -0
- package/scripts/pr-cost.mjs +16 -14
- package/scripts/pull-attribution.mjs +34 -16
- package/scripts/ticket-router.mjs +10 -6
package/package.json
CHANGED
|
@@ -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;
|
package/scripts/pr-cost.mjs
CHANGED
|
@@ -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)
|
|
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)
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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 `
|
|
53
|
-
// count, which no generation owns. The baseline, the guard file's
|
|
54
|
-
// every trip are each cell's newest generation alone, so a cell
|
|
55
|
-
// changed is judged afresh once its new generation reaches MIN_N
|
|
56
|
-
// Each older generation prints on its own line beside the newest,
|
|
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
|
-
|
|
246
|
-
|
|
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}:
|
|
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. `
|
|
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
|
|
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 =
|
|
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 }`.
|
|
369
|
-
* is the cell's newest (null when no Pull of it has one), and the
|
|
370
|
-
* beside it are that generation's alone: `n`, `merged`, `claims`,
|
|
371
|
-
* `spend`
|
|
372
|
-
* the order of `units`, then
|
|
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,
|
|
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,
|
|
351
|
-
// merged PR, and its left-out
|
|
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
|
|
536
|
-
const counts = (e) => `n=${e.n} merged=${e.merged} failed=${e.failed} 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.
|