@feigi/fleet-ctl 3.93.6 → 3.94.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.
@@ -240,6 +240,10 @@ including if the `Sizing:` line was written before the run and corrected after;
240
240
  `sizing.verdict` and `sizing.signal` are that line's verdict and signal.
241
241
  Nothing in the PR body separates a measured verdict from one typed in early;
242
242
  that field is the only thing that does, and an unreported ordering costs the
243
- covariate. A bail is `outcome` `bailed`, its cause in `bailCause`. Then send
243
+ covariate. A bail is `outcome` `bailed`, its cause in `bailCause`: the
244
+ pre-code outcome, before you touch code. A block or ambiguity you find after
245
+ implementation began — the work cannot finish without a ruling or human hands
246
+ you do not have — is `outcome` `blocked` instead, with `pr`, `head` and `bailCause` `null` and
247
+ the reason in `bailDetail`; report it rather than ending with no report. Then send
244
248
  the one line your brief gives, `<your name>: <outcome> <path>`, and exit. Never
245
249
  apply `ready-to-merge`, never merge.
@@ -240,6 +240,10 @@ including if the `Sizing:` line was written before the run and corrected after;
240
240
  `sizing.verdict` and `sizing.signal` are that line's verdict and signal.
241
241
  Nothing in the PR body separates a measured verdict from one typed in early;
242
242
  that field is the only thing that does, and an unreported ordering costs the
243
- covariate. A bail is `outcome` `bailed`, its cause in `bailCause`. Then send
243
+ covariate. A bail is `outcome` `bailed`, its cause in `bailCause`: the
244
+ pre-code outcome, before you touch code. A block or ambiguity you find after
245
+ implementation began — the work cannot finish without a ruling or human hands
246
+ you do not have — is `outcome` `blocked` instead, with `pr`, `head` and `bailCause` `null` and
247
+ the reason in `bailDetail`; report it rather than ending with no report. Then send
244
248
  the one line your brief gives, `<your name>: <outcome> <path>`, and exit. Never
245
249
  apply `ready-to-merge`, never merge.
@@ -240,6 +240,10 @@ including if the `Sizing:` line was written before the run and corrected after;
240
240
  `sizing.verdict` and `sizing.signal` are that line's verdict and signal.
241
241
  Nothing in the PR body separates a measured verdict from one typed in early;
242
242
  that field is the only thing that does, and an unreported ordering costs the
243
- covariate. A bail is `outcome` `bailed`, its cause in `bailCause`. Then send
243
+ covariate. A bail is `outcome` `bailed`, its cause in `bailCause`: the
244
+ pre-code outcome, before you touch code. A block or ambiguity you find after
245
+ implementation began — the work cannot finish without a ruling or human hands
246
+ you do not have — is `outcome` `blocked` instead, with `pr`, `head` and `bailCause` `null` and
247
+ the reason in `bailDetail`; report it rather than ending with no report. Then send
244
248
  the one line your brief gives, `<your name>: <outcome> <path>`, and exit. Never
245
249
  apply `ready-to-merge`, never merge.
@@ -240,6 +240,10 @@ including if the `Sizing:` line was written before the run and corrected after;
240
240
  `sizing.verdict` and `sizing.signal` are that line's verdict and signal.
241
241
  Nothing in the PR body separates a measured verdict from one typed in early;
242
242
  that field is the only thing that does, and an unreported ordering costs the
243
- covariate. A bail is `outcome` `bailed`, its cause in `bailCause`. Then send
243
+ covariate. A bail is `outcome` `bailed`, its cause in `bailCause`: the
244
+ pre-code outcome, before you touch code. A block or ambiguity you find after
245
+ implementation began — the work cannot finish without a ruling or human hands
246
+ you do not have — is `outcome` `blocked` instead, with `pr`, `head` and `bailCause` `null` and
247
+ the reason in `bailDetail`; report it rather than ending with no report. Then send
244
248
  the one line your brief gives, `<your name>: <outcome> <path>`, and exit. Never
245
249
  apply `ready-to-merge`, never merge.
@@ -240,6 +240,10 @@ including if the `Sizing:` line was written before the run and corrected after;
240
240
  `sizing.verdict` and `sizing.signal` are that line's verdict and signal.
241
241
  Nothing in the PR body separates a measured verdict from one typed in early;
242
242
  that field is the only thing that does, and an unreported ordering costs the
243
- covariate. A bail is `outcome` `bailed`, its cause in `bailCause`. Then send
243
+ covariate. A bail is `outcome` `bailed`, its cause in `bailCause`: the
244
+ pre-code outcome, before you touch code. A block or ambiguity you find after
245
+ implementation began — the work cannot finish without a ruling or human hands
246
+ you do not have — is `outcome` `blocked` instead, with `pr`, `head` and `bailCause` `null` and
247
+ the reason in `bailDetail`; report it rather than ending with no report. Then send
244
248
  the one line your brief gives, `<your name>: <outcome> <path>`, and exit. Never
245
249
  apply `ready-to-merge`, never merge.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.93.6",
3
+ "version": "3.94.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": {
@@ -14,7 +14,7 @@
14
14
  // ADMISSIBLE ROW. A Pull counts only when its member row exists, was
15
15
  // dispatched as the drawn cell's own definition (`subagent_type` =
16
16
  // `fleet-implementer-<chosen_cell>`), ran at the cell's level (`effort` = the
17
- // cell's `<level>`) and has a resolved `model` on record. So a generic `task`
17
+ // cell's `<level>`) and has its `dispatch_model` on record. So a generic `task`
18
18
  // dispatch, a clamped effort and every pre-cutover `fleet-implementer` /
19
19
  // `fleet-implementer-alt` row are never admissible. Whether the resolved model
20
20
  // was the role's target at dispatch is recorded in neither file; that half of
@@ -22,7 +22,7 @@
22
22
  //
23
23
  // COMPARISON. For a cell X other than slow-high, a comparison is one session
24
24
  // holding at least one admissible X row and at least one admissible slow-high
25
- // row whose resolved (`model`, `effort`) differ from that X row's. A session
25
+ // row whose (`dispatch_model`, `effort`) differ from that X row's. A session
26
26
  // counts once however many rows it holds, and its date is its member-outcomes
27
27
  // `run_date`; a blank `run_date` means unknown, so that session is still a
28
28
  // comparison but adds no date to the distinct-date count.
@@ -33,12 +33,13 @@
33
33
  //
34
34
  // <cell> <comparisons> <run_dates> <resolved models>
35
35
  //
36
- // `<resolved models>` is the model of every admissible row at the cell — one
37
- // model by name, or `mixed (<model-a> n=…, <model-b> n=…)` when they span more
38
- // than one, because a role's target is operator config that can move under a
39
- // cell's history. A cell below the gate prints nothing on stdout and one
40
- // `below the gate` count on stderr. Neither the gate line nor that count names
41
- // a session, ticket or member: the gate forbids reading a comparison early.
36
+ // `<resolved models>` is the `dispatch_model` of every admissible row at the
37
+ // cell — one model by name, or `mixed (<model-a> n=…, <model-b> n=…)` when
38
+ // they span more than one, because a role's target is operator config that
39
+ // can move under a cell's history. A cell below the gate prints nothing on
40
+ // stdout and one `below the gate` count on stderr. Neither the gate line nor
41
+ // that count names a session, ticket or member: the gate forbids reading a
42
+ // comparison early.
42
43
  // Only the refusal of conflicting ticket-features rows names a session and an
43
44
  // agent, and it prints nothing on stdout.
44
45
  //
@@ -49,7 +50,11 @@
49
50
  // the ticket's last Pull in file order, at any cell, slow-high included,
50
51
  // dated on or before the ruling. Dates only set that floor: a ruling counts
51
52
  // for a Pull only when dated on or after it, and neither pick is the
52
- // latest-dated row. A Pull at X is
53
+ // latest-dated row. A Pull's GENERATION is pull-attribution.mjs's
54
+ // (`pullGenerations`): the `dispatch_model` of its first Admissible
55
+ // implementer row in dispatch order, over every implementer row booked to
56
+ // the Pull, its retries included, so the guard and this rule never name two
57
+ // generations for one Pull. A Pull is admissible here when it has one. A Pull at X is
53
58
  // COUNTABLE when X is a cell other than slow-high whose definition is live
54
59
  // and the Pull is admissible and dated on or after the day X's
55
60
  // `fleet-implementer-<cell>` definition was most recently added. The ruling
@@ -67,8 +72,12 @@
67
72
  // could move the verdict to another cell or uncount it. So is an admissible
68
73
  // Pull at X whose `run_date` is not `YYYY-MM-DD`: it cannot be placed against
69
74
  // the day X's definition was added, and dropping it would uncount its ruling.
70
- // X is to be withdrawn once it has at least STOP.verdicts verdicts and
71
- // floor failures ÷ verdicts is at least STOP.failRate.
75
+ // X is to be withdrawn once its newest generation has at least STOP.verdicts
76
+ // verdicts and floor failures ÷ verdicts is at least STOP.failRate. A ruling
77
+ // counts for its carrier's generation; a cell's newest generation is that of
78
+ // its latest admissible Pull, by session id, then file order. An older
79
+ // generation's verdicts are returned beside the newest's and never withdraw
80
+ // the cell.
72
81
  //
73
82
  // Exit 0 whatever the counts; 2 on a usage error or an unreadable or
74
83
  // malformed input, with nothing on stdout.
@@ -78,7 +87,7 @@ import { makeDie, defineFlags } from "./arg.mjs";
78
87
  import { isCLI } from "./is-cli.mjs";
79
88
  import { POLICY_CELL } from "./ledger-grammar.mjs";
80
89
  import { formatTsv as formatMemberTsv, parseTsv as parseMemberTsv } from "./member-outcomes.mjs";
81
- import { admissibleRow, parseFeatures } from "./pull-attribution.mjs";
90
+ import { admissibleRow, generations, parseFeatures, pullGenerations } from "./pull-attribution.mjs";
82
91
  import { DATE, rulingFor, rulingsByTicket } from "./tier-outcomes.mjs";
83
92
 
84
93
  const NAME = "cell-readout";
@@ -128,7 +137,7 @@ function indexMembers(members) {
128
137
  * `cells`: one entry per cell other than the policy cell that has a
129
138
  * ticket-features row, `{ cell, comparisons, runDates, dates, models, gated }`,
130
139
  * sorted by cell. `dates` is the comparisons' distinct run_dates, sorted;
131
- * `models` maps each resolved model of the cell's admissible rows to its row
140
+ * `models` maps each `dispatch_model` of the cell's admissible rows to its row
132
141
  * count, largest first. `unjoined` counts Pulls with no member row. Throws on
133
142
  * two ticket-features rows of one session+agent naming different cells, and on
134
143
  * a member-outcomes session+agent repeated with different fields.
@@ -144,9 +153,9 @@ export function readout({ features, members }) {
144
153
  if (!byKey.has(key(p.session, p.agent))) unjoined++;
145
154
  const m = admissibleMember(p, byKey);
146
155
  if (!m) continue;
147
- if (p.chosen_cell !== POLICY_CELL) cells.get(p.chosen_cell).set(m.model, (cells.get(p.chosen_cell).get(m.model) ?? 0) + 1);
156
+ if (p.chosen_cell !== POLICY_CELL) cells.get(p.chosen_cell).set(m.dispatchModel, (cells.get(p.chosen_cell).get(m.dispatchModel) ?? 0) + 1);
148
157
  if (!sessions.has(p.session)) sessions.set(p.session, { runDate: m.run_date, rows: [] });
149
- sessions.get(p.session).rows.push({ cell: p.chosen_cell, model: m.model, effort: m.effort });
158
+ sessions.get(p.session).rows.push({ cell: p.chosen_cell, model: m.dispatchModel, effort: m.effort });
150
159
  }
151
160
 
152
161
  const out = [...cells].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([cell, modelCounts]) => {
@@ -169,15 +178,22 @@ export function readout({ features, members }) {
169
178
  /**
170
179
  * One entry per cell named in `added` (cell → the `YYYY-MM-DD` its definition
171
180
  * was most recently added) other than the policy cell, sorted by cell:
172
- * `{ cell, since, verdicts, failures, stop }`. `verdicts` is
181
+ * `{ cell, since, generation, verdicts, failures, stop, older }`. `verdicts` is
173
182
  * `[{ ticket, pr, run_date, closed_own_ticket, minted_false_claim, failed }]`
174
183
  * sorted by ticket, `run_date` the ruling's; `stop` is whether the stopping
175
184
  * rule withdraws the cell. `verdicts` are parsed tier-outcomes.tsv rows.
176
185
  * A ticket's ruling is its last ruling row in file order, charged to the cell
177
186
  * of its carrier, the ticket's last Pull in file order at any cell dated on or
178
187
  * before the ruling, and only when that Pull is
179
- * countable: admissible, at a cell named in `added` other than the policy
188
+ * countable: admissible (it has a generation, pull-attribution.mjs's
189
+ * `pullGenerations`), at a cell named in `added` other than the policy
180
190
  * cell, and dated on or after the day that cell's definition was added.
191
+ * A ruling is charged to its carrier's generation too, and `generation`,
192
+ * `verdicts`, `failures` and `stop` are the cell's newest generation's alone
193
+ * (pull-attribution.mjs's `generations`, over the cell's admissible Pulls;
194
+ * null when it has none). `older` holds `{ generation, verdicts, failures }`
195
+ * for each other generation with at least one verdict, newest first, and
196
+ * never a stop.
181
197
  * Throws on a non-blank ruling row of a ticket with a countable Pull whose
182
198
  * `closed_own_ticket` or `minted_false_claim` is not `yes` or `no` or whose
183
199
  * `run_date` is not `YYYY-MM-DD`,
@@ -187,19 +203,25 @@ export function readout({ features, members }) {
187
203
  * repeated with different fields.
188
204
  */
189
205
  export function stoppingRule({ features, members, verdicts, added }) {
190
- const byKey = indexMembers(members);
206
+ indexMembers(members); // refuses a session+agent repeated with different fields
191
207
  const rulings = rulingsByTicket(verdicts);
192
- const charged = new Map(Object.keys(added).filter((c) => c !== POLICY_CELL).map((c) => [c, []]));
208
+ const charged = new Map(Object.keys(added).filter((c) => c !== POLICY_CELL).map((c) => [c, new Map()]));
193
209
  const dated = (p) => {
194
210
  if (!DATE.test(p.run_date)) throw new Error(`ticket #${p.ticket} (Pull ${p.agent} at ${p.chosen_cell}): run_date is '${p.run_date}', expected YYYY-MM-DD`);
195
211
  return p.run_date;
196
212
  };
197
- const countable = new Set();
213
+ // A Pull's generation is pull-attribution's, read over all of its implementer
214
+ // rows; a Pull with none is not admitted.
215
+ const generationOf = pullGenerations(features, members);
216
+ const countable = new Map(); // Pull -> its generation
217
+ const admitted = []; // every Pull with a generation at a judged cell, as generations() reads one
198
218
  const pullsByTicket = new Map();
199
219
  for (const p of features) {
200
220
  (pullsByTicket.get(p.ticket) ?? pullsByTicket.set(p.ticket, []).get(p.ticket)).push(p);
201
- if (!charged.has(p.chosen_cell) || !admissibleMember(p, byKey)) continue;
202
- if (dated(p) >= added[p.chosen_cell]) countable.add(p);
221
+ const g = charged.has(p.chosen_cell) ? generationOf.get(p) : null;
222
+ if (!g) continue;
223
+ admitted.push({ cell: p.chosen_cell, generation: g, pull: p });
224
+ if (dated(p) >= added[p.chosen_cell]) countable.set(p, g);
203
225
  }
204
226
  for (const [ticket, pulls] of pullsByTicket) {
205
227
  if (!pulls.some((p) => countable.has(p))) continue;
@@ -210,17 +232,25 @@ export function stoppingRule({ features, members, verdicts, added }) {
210
232
  let carrier = null;
211
233
  for (const p of pulls) if (dated(p) <= v.run_date) carrier = p;
212
234
  if (!carrier || !countable.has(carrier)) continue;
213
- charged.get(carrier.chosen_cell).push({
235
+ const byGeneration = charged.get(carrier.chosen_cell);
236
+ const g = countable.get(carrier);
237
+ (byGeneration.get(g) ?? byGeneration.set(g, []).get(g)).push({
214
238
  ticket: carrier.ticket, pr: v.pr, run_date: v.run_date,
215
239
  closed_own_ticket: v.closed_own_ticket, minted_false_claim: v.minted_false_claim,
216
240
  failed: v.minted_false_claim === "yes" || v.closed_own_ticket === "no",
217
241
  });
218
242
  }
219
- return [...charged].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([cell, list]) => {
243
+ const order = generations(admitted);
244
+ const tallied = (list = []) => {
220
245
  list.sort((a, b) => Number(a.ticket) - Number(b.ticket));
221
- const failures = list.filter((v) => v.failed).length;
246
+ return { verdicts: list, failures: list.filter((v) => v.failed).length };
247
+ };
248
+ return [...charged].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([cell, byGeneration]) => {
249
+ const [generation = null, ...rest] = order.get(cell) ?? [];
250
+ const { verdicts: list, failures } = tallied(byGeneration.get(generation));
222
251
  const stop = list.length >= STOP.verdicts && failures / list.length >= STOP.failRate;
223
- return { cell, since: added[cell], verdicts: list, failures, stop };
252
+ const older = rest.filter((g) => byGeneration.has(g)).map((g) => ({ generation: g, ...tallied(byGeneration.get(g)) }));
253
+ return { cell, since: added[cell], generation, verdicts: list, failures, stop, older };
224
254
  });
225
255
  }
226
256
 
@@ -50,6 +50,7 @@
50
50
  // opens with no sha, is a NEED and N is left untouched, claim and all.
51
51
  // The PR a `covered` detail names is never written to.
52
52
  // A `PR#M` outcome also sets the claim branch's upstream when it has none: `reap.sh` keeps a branch with no upstream, and only an upstream's `[gone]` state makes it reapable. It writes `branch.<b>.remote` and `branch.<b>.merge` only while the merge key is unset, and prints `NEED upstream <b> — <why>` when a write fails.
53
+ // A `blocked` outcome takes no action beyond the settle: no label, comment, release or upstream, so the ticket keeps `in-progress`.
53
54
  // fix-report <fix-pr-M[-x]>
54
55
  // `settle --from-report`, which records the filings and rulings the
55
56
  // report carries. Settled `applied:`/`no-op`, on a PR whose rows carry
@@ -2508,6 +2508,9 @@ function recordFiled(id, subject) {
2508
2508
  // own repository name N;
2509
2509
  // the Check-step record (below) holds for <head>
2510
2510
  // impl-N=bailed bailCause is not null; the receipt names it
2511
+ // impl-N=blocked nothing to check: report-schema.mjs already
2512
+ // refuses a block that names a bailCause, a pr or
2513
+ // a head, or gives no reason in bailDetail
2511
2514
  // fix-pr-M=applied:<head> origin's refs/heads/<branch> is <head>, fetched
2512
2515
  // by verify-sha.sh; the Check-step record (below)
2513
2516
  // holds for <head>
@@ -2670,6 +2673,8 @@ async function runSettleFromReport() {
2670
2673
  const { view: pr, numbers } = prViewClosing(report.pr, "state,closingIssuesReferences,url");
2671
2674
  check(`PR #${report.pr} reads OPEN`, pr.state === "OPEN");
2672
2675
  check(`PR #${report.pr} closes #${live.number}`, numbers.includes(live.number));
2676
+ } else if (report.outcome === "blocked") {
2677
+ outcome = "blocked";
2673
2678
  } else {
2674
2679
  outcome = "bailed";
2675
2680
  check(`bailCause: ${report.bailCause}`, report.bailCause !== null);
@@ -13,6 +13,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
13
13
  import { join, basename } from "node:path";
14
14
 
15
15
  import { readOmpSession, normalizeModel, parseMemberName, isOmpSessionDirName } from "./member-record.mjs";
16
+ import { stripLevel } from "./tier-roles.mjs";
16
17
  import { reportFamily, reportStatus, defaultScratch } from "./report-files.mjs";
17
18
  import { FleetDirUnresolvable } from "./fleet-dir.mjs";
18
19
  import { spawnSync } from "node:child_process";
@@ -91,9 +92,20 @@ export function rowsForSession(sessionDir, stats = {}, { verifyMain = verifyMain
91
92
  tokensIn: r.tokens_in, tokensCacheRead: r.tokens_cache_read,
92
93
  tokensCacheWrite1h: r.tokens_cache_write_1h ?? "", cost: r.cost == null ? "" : Number(r.cost.toFixed(6)),
93
94
  ending: endings[i],
95
+ dispatchModel: dispatchModel(r.resolvedModelIdentity),
94
96
  }));
95
97
  }
96
98
 
99
+ // The `dispatch_model` column: the model fixed at dispatch, `session_init`'s
100
+ // own model (the record's `resolvedModelIdentity`), with its provider prefix
101
+ // and `:<level>` suffix stripped. `model` is the member's last turn, which a
102
+ // provider fallback moves; this one does not move. Blank when the transcript
103
+ // carries no `session_init` model: blank means unknown. Operator config is
104
+ // never read for it.
105
+ export function dispatchModel(identity) {
106
+ return stripLevel(String(identity ?? "").trim()).split("/").at(-1);
107
+ }
108
+
97
109
  // A column is APPENDED, never inserted, as a rule: a read-out that indexes by
98
110
  // position (`$5`, in the file's header) silently repoints when one is added
99
111
  // ahead of it. `report` is placed after `subagent_type` regardless, which
@@ -108,7 +120,7 @@ export const COLUMNS = [
108
120
  "session", "run_date", "role", "member", "model", "effort", "ticket", "pr",
109
121
  "tokens_cache_create", "tokens_out", "wall_s", "turns", "agent", "harness",
110
122
  "subagent_type", "report", "tokens_in", "tokens_cache_read", "tokens_cache_write_1h", "cost",
111
- "ending",
123
+ "ending", "dispatch_model",
112
124
  ];
113
125
 
114
126
  // `report`: what reportStatus() says of the row's member under `scratch` — a
@@ -134,6 +146,7 @@ export function withReports(rows, scratch) {
134
146
  // opened one (more than one leaves the pr column blank), or
135
147
  // the last report claims a PR an earlier same-ticket
136
148
  // implementer opened in this session;
149
+ // blocked the last report blocks mid-implementation;
137
150
  // bail:<cause> the last report bails (`fixed` is checked against main);
138
151
  // provider a model fell back, or the last turn errored;
139
152
  // budget a budget notice reached the member;
@@ -150,6 +163,7 @@ export function endingOf({ member, pr, signals = null, earlierPrs = new Set(), v
150
163
  if (signals.openedPrs.length > 0) return "pr";
151
164
  const last = lastReport(signals.reportWrites, member.split("/").pop());
152
165
  if (last?.outcome === "pr" && earlierPrs.has(String(last.pr))) return "pr";
166
+ if (last?.outcome === "blocked") return "blocked";
153
167
  // lastReport passed the impl schema, whose bailCause is a BAILS key or null.
154
168
  if (last?.bailCause) return bailEnding(last, verifyMain);
155
169
  if (signals.providerFallback || signals.lastStopReason === "error") return "provider";
@@ -221,7 +235,7 @@ function verifyMainWithGit(sha) {
221
235
  const FIELD = {
222
236
  tokens_cache_create: "tokensCacheCreate", tokens_out: "tokensOut", wall_s: "wallS",
223
237
  subagent_type: "subagentType", tokens_in: "tokensIn", tokens_cache_read: "tokensCacheRead",
224
- tokens_cache_write_1h: "tokensCacheWrite1h",
238
+ tokens_cache_write_1h: "tokensCacheWrite1h", dispatch_model: "dispatchModel",
225
239
  };
226
240
  const field = (c) => FIELD[c] ?? c;
227
241
  // Keyed on the transcript's path-relative stem, not the member name: `member`
@@ -39,16 +39,22 @@
39
39
  // of a windowed Pull's ticket whose run_date is not YYYY-MM-DD or whose verdict
40
40
  // is not yes or no.
41
41
  //
42
- // PER CELL, over its counted Pulls: `n_pulls`; `n_merged`; `n_claims` (merged
43
- // PRs plus every `bail:fixed` or `bail:fixed-refuted` ending); `n_failed`
44
- // (merged PRs ruled `minted_false_claim=yes` or `closed_own_ticket=no`, plus
45
- // every `bail:fixed-refuted`); `fail_rate` = failed / claims; `mean_usd` = the
46
- // Pulls' spend — merged or not — / merged, so a cell that abandons work carries
47
- // that spend; `median_usd` over the merged Pulls alone, descriptive;
48
- // `router_usd`, the Pulls' own routing cost, summed and never in their spend.
49
- // `usd_diff_ci95` is a bootstrap 95% interval on `mean_usd` minus the
50
- // baseline's — printed to be read, never judged on. Then the cell's
51
- // `excluded`, `left_out` and `unpriced` Pull counts.
42
+ // PER CELL GENERATION (pull-attribution.mjs's GENERATION), over its counted
43
+ // Pulls: `generation`, the dispatch model; `n_pulls`; `n_merged`; `n_claims`
44
+ // (merged PRs plus every `bail:fixed` or `bail:fixed-refuted` ending);
45
+ // `n_failed` (merged PRs ruled `minted_false_claim=yes` or
46
+ // `closed_own_ticket=no`, plus every `bail:fixed-refuted`); `fail_rate` =
47
+ // failed / claims; `mean_usd` = the Pulls' spend — merged or not — / merged,
48
+ // so a cell that abandons work carries that spend; `median_usd` over the
49
+ // merged Pulls alone, descriptive; `router_usd`, the Pulls' own routing cost,
50
+ // summed and never in their spend. `usd_diff_ci95` is a bootstrap 95% interval
51
+ // 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.
52
58
 
53
59
  import { execFileSync } from "node:child_process";
54
60
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -135,29 +141,39 @@ export function computeReport({ members, tiers, features, prs, routerTable = nul
135
141
  const units = attribute({ features, members, tiers, prs, windowStart });
136
142
  const counted = units.filter((u) => u.status === "counted");
137
143
  const tallies = new Map(tally(units).map((t) => [t.cell, t]));
138
- const countedIn = (cell) => counted.filter((u) => u.cell === cell);
139
- const bootUnits = (cell) => countedIn(cell).map((u) => ({ usd: u.spend, merged: u.merged ? 1 : 0 }));
144
+ const countedIn = (cell, generation) => counted.filter((u) => u.cell === cell && u.generation === generation);
145
+ const bootUnits = (us) => us.map((u) => ({ usd: u.spend, merged: u.merged ? 1 : 0 }));
140
146
  const meanUsd = (t) => (t.merged ? t.spend / t.merged : null);
141
147
  const base = tallies.get(BASELINE_CELL) ?? null;
148
+ // One generation's figures. The interval is the newest generation's against
149
+ // the baseline's newest; an older generation is printed, never compared.
150
+ const figuresOf = (cell, g, newest) => {
151
+ const mine = countedIn(cell, g.generation);
152
+ return {
153
+ cell,
154
+ generation: g.generation,
155
+ n_pulls: g.n,
156
+ n_merged: g.merged,
157
+ n_claims: g.claims,
158
+ n_failed: g.failed,
159
+ fail_rate: g.claims ? round(g.failed / g.claims) : null,
160
+ mean_usd: round(meanUsd(g), 2),
161
+ median_usd: round(median(mine.filter((u) => u.merged).map((u) => u.spend)), 2),
162
+ router_usd: round(mine.reduce((s, u) => s + routerUsd(u.pull), 0), 4),
163
+ usd_diff_ci95: !newest || cell === BASELINE_CELL || !base ? null : bootstrapDiffCI(bootUnits(mine), bootUnits(countedIn(BASELINE_CELL, base.generation))),
164
+ left_out: g.left_out,
165
+ unpriced: g.unpriced,
166
+ };
167
+ };
142
168
  const figures = [...tallies.values()].map((t) => ({
143
- cell: t.cell,
144
- n_pulls: t.n,
145
- n_merged: t.merged,
146
- n_claims: t.claims,
147
- n_failed: t.failed,
148
- fail_rate: t.claims ? round(t.failed / t.claims) : null,
149
- mean_usd: round(meanUsd(t), 2),
150
- median_usd: round(median(countedIn(t.cell).filter((u) => u.merged).map((u) => u.spend)), 2),
151
- router_usd: round(countedIn(t.cell).reduce((s, u) => s + routerUsd(u.pull), 0), 4),
152
- usd_diff_ci95: t.cell === BASELINE_CELL || !base ? null : bootstrapDiffCI(bootUnits(t.cell), bootUnits(BASELINE_CELL)),
169
+ ...figuresOf(t.cell, t, true),
153
170
  excluded: t.excluded,
154
- left_out: t.left_out,
155
- unpriced: t.unpriced,
171
+ older: t.older.map((o) => figuresOf(t.cell, o, false)),
156
172
  }));
157
173
 
158
- // The guard.
159
- const b = figures.find((f) => f.cell === BASELINE_CELL) ?? { n_merged: 0, mean_usd: null, fail_rate: null };
160
- const baseline = { cell: BASELINE_CELL, n: b.n_merged, mean_usd: b.mean_usd, fail_rate: b.fail_rate };
174
+ // The guard, over each cell's newest generation alone.
175
+ const b = figures.find((f) => f.cell === BASELINE_CELL) ?? { generation: null, n_merged: 0, mean_usd: null, fail_rate: null };
176
+ const baseline = { cell: BASELINE_CELL, generation: b.generation, n: b.n_merged, mean_usd: b.mean_usd, fail_rate: b.fail_rate };
161
177
  // The $ leg takes the unrounded means: the cent-rounded figures are for
162
178
  // display, and a trip is sticky, so a cell cheaper by under half a cent
163
179
  // must not trip on rounding alone. The fail leg reads the claims.
@@ -239,10 +255,12 @@ export function crossCheck(rows, pricing) {
239
255
  }
240
256
 
241
257
  const TSV_COLUMNS = [
242
- "cell", "n_pulls", "n_merged", "n_claims", "n_failed", "fail_rate", "mean_usd", "median_usd", "router_usd", "usd_diff_ci95",
258
+ "cell", "generation", "n_pulls", "n_merged", "n_claims", "n_failed", "fail_rate", "mean_usd", "median_usd", "router_usd", "usd_diff_ci95",
243
259
  "excluded", "left_out", "unpriced", "guard",
244
260
  ];
245
261
 
262
+ // One line per cell's newest generation, carrying the guard's verdict on it,
263
+ // then one per older generation of that cell, which carries none.
246
264
  export function formatReport(report) {
247
265
  const guardOf = (f) => {
248
266
  if (f.cell === BASELINE_CELL) return "baseline";
@@ -250,8 +268,12 @@ export function formatReport(report) {
250
268
  return f.n_merged >= MIN_N && report.verdict !== "none" ? "ok" : `n<${MIN_N}`;
251
269
  };
252
270
  const cell = (v) => (v === null || v === undefined ? "" : Array.isArray(v) ? `[${v.join(", ")}]` : String(v));
271
+ const line = (f, guard) => TSV_COLUMNS.map((c) => (c === "guard" ? guard : cell(f[c]))).join("\t");
253
272
  const lines = [TSV_COLUMNS.join("\t")];
254
- for (const f of report.cells) lines.push(TSV_COLUMNS.map((c) => (c === "guard" ? guardOf(f) : cell(f[c]))).join("\t"));
273
+ for (const f of report.cells) {
274
+ lines.push(line(f, guardOf(f)));
275
+ for (const o of f.older) lines.push(line(o, "older"));
276
+ }
255
277
  const b = report.baseline;
256
278
  lines.push(`# verdict: ${report.verdict} (baseline ${b.cell} n=${b.n}/${MIN_N}${report.tripped.length ? `; tripped ${report.tripped.join(" ")}` : ""})${report.retire ? " — retire: every non-default stage-1 cell tripped" : ""}`);
257
279
  lines.push(`# window_start ${report.window_start}; pending PRs: ${report.pending.length ? report.pending.join(" ") : "none"}`);
@@ -269,7 +291,7 @@ export function guardFile(report, computedAt) {
269
291
  computed_at: computedAt,
270
292
  window_start: report.window_start,
271
293
  baseline: report.baseline,
272
- cells: report.cells.map((f) => ({ cell: f.cell, n: f.n_merged, mean_usd: f.mean_usd, fail_rate: f.fail_rate })),
294
+ 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 })),
273
295
  tripped: report.tripped,
274
296
  verdict: report.verdict,
275
297
  retire: report.retire,
@@ -38,8 +38,8 @@
38
38
  // counted every other Pull: the only ones in a cell's figures.
39
39
  //
40
40
  // ENDINGS come from member-outcomes' `ending` column. The cell's: `hollow`,
41
- // `budget`, `bail:fixed-refuted`, `unknown`. The ticket's: every other
42
- // `bail:<cause>`. Neither's: `provider`, `crash`, `abort`.
41
+ // `budget`, `bail:fixed-refuted`, `unknown`. The ticket's: `blocked` and every
42
+ // other `bail:<cause>`. Neither's: `provider`, `crash`, `abort`.
43
43
  //
44
44
  // CLAIMS. A merged Pull is one claim, false when its Ruling fails the floor
45
45
  // (`minted_false_claim=yes` or `closed_own_ticket=no`). Each `bail:fixed` or
@@ -48,6 +48,17 @@
48
48
  //
49
49
  // SPEND is the recorded `cost` of every row booked to the Pull. A `cost` that
50
50
  // is neither blank nor a number is refused, naming the row.
51
+ //
52
+ // GENERATION. A cell's figures are per Cell generation: the cell on one
53
+ // dispatch model, member-outcomes' `dispatch_model`. A Pull's generation is
54
+ // the dispatch model of its first Admissible implementer row in dispatch
55
+ // order: the session, then `impl-<N>` before its retries `-b`, `-c`, ….
56
+ // A row at another cell (a rescue) is booked to the Pull and never changes
57
+ // its generation; an excluded Pull has none. A cell's NEWEST generation is
58
+ // that of its latest Pull that has one, by session id, then file order.
59
+ // Generations are told apart by model alone, so a cell that returns to an
60
+ // earlier model rejoins that generation's Pulls. Only the newest generation's
61
+ // figures are judged; older ones are reported beside them, never pooled.
51
62
 
52
63
  import { isDigits } from "./arg.mjs";
53
64
  import { CELL } from "./ledger-grammar.mjs";
@@ -87,10 +98,11 @@ export const EXCLUDED_MEMBER = /^(?:merge-?bot|memory$|__advisor$)/i;
87
98
  export const lastSegment = (agent) => String(agent).split(/[/.]/).at(-1);
88
99
 
89
100
  // An Admissible row for `cell`: dispatched as the cell's own definition, run at
90
- // the cell's level, with a resolved model on record.
101
+ // the cell's level, with its dispatch model on record. The last turn's `model`
102
+ // is not read: a member that fell back to another provider is still admitted.
91
103
  export function admissibleRow(row, cell) {
92
104
  const level = CELL.exec(cell)?.[2] ?? null;
93
- return level !== null && row.subagentType === `fleet-implementer-${cell}` && row.effort === level && !!row.model;
105
+ return level !== null && row.subagentType === `fleet-implementer-${cell}` && row.effort === level && !!row.dispatchModel;
94
106
  }
95
107
 
96
108
  // A bad input row, as opposed to a bug: a caller that reports bad input catches this class alone.
@@ -113,11 +125,66 @@ function datedRows(features) {
113
125
 
114
126
  const CELL_ENDINGS = new Set(["hollow", "budget", "bail:fixed-refuted", "unknown"]);
115
127
  const NEITHER_ENDINGS = new Set(["provider", "crash", "abort"]);
116
- const ticketsEnding = (e) => e.startsWith("bail:") && !CELL_ENDINGS.has(e);
128
+ const ticketsEnding = (e) => e === "blocked" || (e.startsWith("bail:") && !CELL_ENDINGS.has(e));
117
129
  const failsFloor = (ruling) => ruling.minted_false_claim === "yes" || ruling.closed_own_ticket === "no";
118
130
  // An omp session id opens with its ISO start time, so two of them sort in time order.
119
131
  const STARTED = /^\d{4}-\d{2}-\d{2}T/;
120
132
  const startedBefore = (pull, row) => (STARTED.test(pull.session) && STARTED.test(row.session) ? pull.session < row.session : pull.run_date <= row.run_date);
133
+ // An implementer leaf's retry suffix: "" for `impl-<N>`, "b" for `impl-<N>-b`.
134
+ const retryOf = (row) => /^impl-\d+(?:-([a-z0-9]+))?$/.exec(String(row.member).split("/").pop())?.[1] ?? "";
135
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
136
+ // Implementer rows in dispatch order: by session, then the bare name before
137
+ // its retries, a shorter suffix before a longer one (`-z` before `-aa`).
138
+ const dispatchOrder = (rows) => [...rows].sort((a, b) => {
139
+ const [x, y] = [retryOf(a), retryOf(b)];
140
+ return cmp(a.session, b.session) || x.length - y.length || cmp(x, y);
141
+ });
142
+ const key = (session, agent) => `${session}\0${agent}`;
143
+ // The omp rows a Pull can book: every member but the merge bot, memory and the advisor.
144
+ const ompRows = (members) => members.filter((r) => r.harness === "omp" && !EXCLUDED_MEMBER.test(lastSegment(r.member)));
145
+
146
+ /**
147
+ * Each implementer row of `omp` to its Pull among `pulls`: the same session's
148
+ * Pull of the row's ticket and agent, else that session's last Pull of the
149
+ * ticket, else the ticket's last Pull started before the row (a retry in a
150
+ * later session). `impls` maps every Pull to its rows, `implPull` each booked
151
+ * row's session+agent to its Pull. A row with no ticket, or no such Pull, is
152
+ * booked to none.
153
+ */
154
+ function bookImplementers(pulls, omp) {
155
+ const bySessionTicket = new Map(), byTicket = new Map();
156
+ for (const p of pulls) {
157
+ const k = key(p.session, p.ticket);
158
+ (bySessionTicket.get(k) ?? bySessionTicket.set(k, []).get(k)).push(p);
159
+ (byTicket.get(p.ticket) ?? byTicket.set(p.ticket, []).get(p.ticket)).push(p);
160
+ }
161
+ const implPull = new Map();
162
+ const impls = new Map(pulls.map((p) => [p, []]));
163
+ for (const r of omp) {
164
+ if (!isImplementer(r.member) || !r.ticket) continue;
165
+ const own = bySessionTicket.get(key(r.session, r.ticket));
166
+ const pull = own ? (own.find((p) => p.agent === r.agent) ?? own.at(-1)) : byTicket.get(r.ticket)?.findLast((p) => startedBefore(p, r));
167
+ if (!pull) continue;
168
+ implPull.set(key(r.session, r.agent), pull);
169
+ impls.get(pull).push(r);
170
+ }
171
+ return { impls, implPull };
172
+ }
173
+
174
+ // A Pull's generation from its implementer rows: its first Admissible row's dispatch model, else null.
175
+ const generationOf = (own, cell) => dispatchOrder(own).find((m) => admissibleRow(m, cell))?.dispatchModel ?? null;
176
+
177
+ /**
178
+ * Each of `pulls` (ticket-features rows) to its generation, `attribute`'s
179
+ * rule over `members`: the dispatch model of the Pull's first Admissible
180
+ * implementer row in dispatch order, or null when it has none. Every caller
181
+ * that names a Pull's generation reads it here, so the guard, the fit and the
182
+ * stopping rule never derive two generations for one Pull.
183
+ */
184
+ export function pullGenerations(pulls, members) {
185
+ const { impls } = bookImplementers(pulls, ompRows(members));
186
+ return new Map(pulls.map((p) => [p, generationOf(impls.get(p), p.chosen_cell)]));
187
+ }
121
188
 
122
189
  function costOf(row) {
123
190
  const c = String(row.cost ?? "");
@@ -129,39 +196,24 @@ function costOf(row) {
129
196
 
130
197
  /**
131
198
  * One unit per ticket-features row dated on or after `windowStart` (every dated
132
- * row when null), in file order: `{ pull, cell, stratum, status, merged, pr,
133
- * ruling, pendingPr, claims, falseClaims, rows, spend }`. `prs` is `gh pr list`'s
199
+ * row when null), in file order: `{ pull, cell, stratum, generation, status,
200
+ * merged, pr, ruling, pendingPr, claims, falseClaims, rows, spend }`,
201
+ * `generation` null exactly when the Pull is excluded. `prs` is `gh pr list`'s
134
202
  * `{ number, state }` rows, or null when PR state is unknown (the fit). `stratum`
135
203
  * maps a features row to its stratum. Throws `AttributionError` on a features
136
204
  * row it cannot date or a booked `cost` it cannot read, and `RulingError` as
137
205
  * `rulingFor` does.
138
206
  */
139
207
  export function attribute({ features, members, tiers, prs = null, windowStart = null, stratum = () => null }) {
140
- const key = (session, agent) => `${session}\0${agent}`;
141
208
  const pulls = datedRows(features);
142
- const bySessionTicket = new Map(), byTicket = new Map();
143
- for (const p of pulls) {
144
- const k = key(p.session, p.ticket);
145
- (bySessionTicket.get(k) ?? bySessionTicket.set(k, []).get(k)).push(p);
146
- (byTicket.get(p.ticket) ?? byTicket.set(p.ticket, []).get(p.ticket)).push(p);
147
- }
148
-
149
- const omp = members.filter((r) => r.harness === "omp" && !EXCLUDED_MEMBER.test(lastSegment(r.member)));
209
+ const omp = ompRows(members);
150
210
  // Implementer rows to their Pull, and a PR-named member's key to its PR.
151
- const implPull = new Map(), prOfKey = new Map();
152
- const impls = new Map(pulls.map((p) => [p, []]));
211
+ const { impls, implPull } = bookImplementers(pulls, omp);
212
+ const prOfKey = new Map();
153
213
  for (const r of omp) {
154
- if (isImplementer(r.member)) {
155
- if (!r.ticket) continue;
156
- const own = bySessionTicket.get(key(r.session, r.ticket));
157
- const pull = own ? (own.find((p) => p.agent === r.agent) ?? own.at(-1)) : byTicket.get(r.ticket)?.findLast((p) => startedBefore(p, r));
158
- if (!pull) continue;
159
- implPull.set(key(r.session, r.agent), pull);
160
- impls.get(pull).push(r);
161
- } else {
162
- const { pr } = parseMemberName(r.member);
163
- if (pr) prOfKey.set(key(r.session, r.agent), pr);
164
- }
214
+ if (isImplementer(r.member)) continue;
215
+ const { pr } = parseMemberName(r.member);
216
+ if (pr) prOfKey.set(key(r.session, r.agent), pr);
165
217
  }
166
218
  // Each PR to the last Pull, in file order, whose implementer rows carry it.
167
219
  const carrier = new Map();
@@ -224,37 +276,74 @@ export function attribute({ features, members, tiers, prs = null, windowStart =
224
276
  if (c === null) blank = true;
225
277
  else spend += c;
226
278
  }
279
+ const generation = generationOf(own, pull.chosen_cell);
227
280
  let status = "counted";
228
- if (!own.some((m) => admissibleRow(m, pull.chosen_cell))) status = "excluded";
281
+ if (generation === null) status = "excluded";
229
282
  else if (pendingPr !== null) status = "pending";
230
283
  else if (claims === 0 && own.every((m) => ticketsEnding(m.ending) || NEITHER_ENDINGS.has(m.ending))) status = "left-out";
231
284
  else if (blank || (r && !r.carried)) status = "unpriced";
232
285
  return {
233
- pull, cell: pull.chosen_cell, stratum: stratum(pull), status, merged, pr: r?.pr ?? null, ruling: r?.ruling ?? null,
286
+ pull, cell: pull.chosen_cell, stratum: stratum(pull), generation, status, merged, pr: r?.pr ?? null, ruling: r?.ruling ?? null,
234
287
  pendingPr, claims, falseClaims, rows: booked, spend: blank ? null : spend,
235
288
  };
236
289
  });
237
290
  }
238
291
 
239
292
  /**
240
- * Per cell, in cell-name order: `{ cell, n, merged, claims, failed, spend,
241
- * excluded, left_out, unpriced, pending }`. `n`, `merged`, `claims`, `failed`
242
- * and `spend` are over counted Pulls alone; the last four count the Pulls of
243
- * each other status.
293
+ * Each cell's generations, newest first: cell -> [model, …]. A generation's
294
+ * place is that of its latest Pull, by session id (an omp session id opens
295
+ * with its start time), then by file order, the order of `units`.
296
+ */
297
+ export function generations(units) {
298
+ const latest = new Map(); // cell -> Map(generation -> { session, at })
299
+ units.forEach((u, at) => {
300
+ if (!u.generation) return;
301
+ const gens = latest.get(u.cell) ?? latest.set(u.cell, new Map()).get(u.cell);
302
+ const held = gens.get(u.generation);
303
+ if (!held || u.pull.session >= held.session) gens.set(u.generation, { session: u.pull.session, at });
304
+ });
305
+ return new Map([...latest].map(([cell, gens]) => [cell, [...gens]
306
+ .sort(([, a], [, b]) => cmp(b.session, a.session) || b.at - a.at)
307
+ .map(([g]) => g)]));
308
+ }
309
+
310
+ /**
311
+ * Per cell, in cell-name order: `{ cell, generation, n, merged, claims, failed,
312
+ * spend, excluded, left_out, unpriced, pending, older }`. `generation` is the
313
+ * cell's newest (null when no Pull of it has one), and the figures beside it
314
+ * are that generation's alone: `n`, `merged`, `claims`, `failed` and `spend`
315
+ * over its counted Pulls, then its left-out, unpriced and pending Pull counts.
316
+ * `excluded` counts the cell's excluded Pulls, which have no generation.
317
+ * `older` is an array, newest first, of every other generation's figures in
318
+ * the same shape, each carrying its `generation`. `order` is `generations()`
319
+ * of the units that place each cell's generations; by default these same
320
+ * units, but a caller tallying a subset passes the whole set's, so a subset
321
+ * never moves a cell's newest generation.
244
322
  */
245
- export function tally(units) {
323
+ export function tally(units, order = generations(units)) {
324
+ const figures = (generation) => ({ generation, n: 0, merged: 0, claims: 0, failed: 0, spend: 0, left_out: 0, unpriced: 0, pending: 0 });
246
325
  const cells = new Map();
247
326
  for (const u of units) {
248
- const c = cells.get(u.cell) ?? cells.set(u.cell, {
249
- cell: u.cell, n: 0, merged: 0, claims: 0, failed: 0, spend: 0, excluded: 0, left_out: 0, unpriced: 0, pending: 0,
250
- }).get(u.cell);
327
+ const c = cells.get(u.cell) ?? cells.set(u.cell, { excluded: 0, gens: new Map() }).get(u.cell);
328
+ if (u.status === "excluded") {
329
+ c.excluded++;
330
+ continue;
331
+ }
332
+ const g = u.generation ?? null;
333
+ const f = c.gens.get(g) ?? c.gens.set(g, figures(g)).get(g);
251
334
  if (u.status === "counted") {
252
- c.n++;
253
- if (u.merged) c.merged++;
254
- c.claims += u.claims;
255
- c.failed += u.falseClaims;
256
- c.spend += u.spend;
257
- } else c[u.status.replace("-", "_")]++;
335
+ f.n++;
336
+ if (u.merged) f.merged++;
337
+ f.claims += u.claims;
338
+ f.failed += u.falseClaims;
339
+ f.spend += u.spend;
340
+ } else f[u.status.replace("-", "_")]++;
258
341
  }
259
- return [...cells.values()].sort((a, b) => (a.cell < b.cell ? -1 : a.cell > b.cell ? 1 : 0));
342
+ return [...cells].sort(([a], [b]) => cmp(a, b)).map(([cell, c]) => {
343
+ const ranked = order.get(cell) ?? [];
344
+ const newest = ranked[0] ?? null;
345
+ const rank = (g) => (ranked.includes(g) ? ranked.indexOf(g) : ranked.length);
346
+ const older = [...c.gens.values()].filter((f) => f.generation !== newest).sort((a, b) => rank(a.generation) - rank(b.generation));
347
+ return { cell, ...(c.gens.get(newest) ?? figures(newest)), excluded: c.excluded, older };
348
+ });
260
349
  }
@@ -6,7 +6,7 @@
6
6
  // (flat: no per-run directory), `<member>` being its ledger name (`impl-412`,
7
7
  // `impl-412-b`, `fix-pr-88`, `finisher-pr-88`, `merge-bot-3`). The file is
8
8
  // checked against the schema of the member's family, report-schema.mjs's
9
- // SCHEMAS, and the rule reportViolation() adds to it. The review runner and
9
+ // SCHEMAS, and the rules reportViolation() adds to them. The review runner and
10
10
  // every member outside those four families have no schema, so no file to
11
11
  // look up.
12
12
  //
@@ -18,9 +18,14 @@
18
18
  // 0 print: the schema is on stdout. validate: `valid` on stdout
19
19
  // 1 validate: `invalid: <violation>` on stdout, naming the FIRST violation;
20
20
  // a file that is not JSON is invalid too, as `$: not JSON (...)`, and so
21
- // is one that repeats a key, as `$.merged: duplicate key "merged"`, and
22
- // a merge-bot report giving one PR two verdicts, as
23
- // `$.refused[0].pr: PR #<pr> already has a verdict at $.merged[0].pr`
21
+ // is one that repeats a key, as `$.merged: duplicate key "merged"`, a
22
+ // merge-bot report giving one PR two verdicts, as
23
+ // `$.refused[0].pr: PR #<pr> already has a verdict at $.merged[0].pr`,
24
+ // and an impl report whose `blocked` outcome names a `bailCause`, as
25
+ // `$.bailCause: "<cause>" is not null, with outcome "blocked" — a block
26
+ // is no bail`, gives no reason in `bailDetail`, or names a `pr` or a
27
+ // `head`, as `$.pr: <pr> is not null, with outcome "blocked" — a block
28
+ // reports no PR`
24
29
  // 2 usage: no or unknown subcommand, a wrong argument count, an unknown
25
30
  // family, any flag; or <file> could not be read; or print could not
26
31
  // write the schema to stdout. Refused on stderr, nothing on stdout
@@ -112,7 +117,9 @@ const issues = listOf({ issue: id, subject: str });
112
117
  export const SCHEMAS = {
113
118
  impl: report({
114
119
  member: str,
115
- outcome: { type: "string", enum: ["pr", "bailed"] },
120
+ // `bailed` is the pre-code outcome; `blocked` a block or ambiguity found
121
+ // after implementation began, its reason in `bailDetail`.
122
+ outcome: { type: "string", enum: ["pr", "bailed", "blocked"] },
116
123
  pr: { ...id, type: ["integer", "null"] },
117
124
  head: { type: ["string", "null"], pattern: HEX },
118
125
  branch: str,
@@ -174,14 +181,25 @@ export function mergeBotVerdicts(report) {
174
181
  ];
175
182
  }
176
183
 
177
- // The first violation of `family`'s schema by `value`, or null; for a
178
- // merge-bot report then the rule no schema keyword states: each PR gets one
179
- // verdict — merged, held, conflict-held or refused — the last it reached in
180
- // the pass, so a PR named twice, in one list or in two, is a violation at
181
- // its second entry.
184
+ // The first violation of `family`'s schema by `value`, or null; then the rules
185
+ // no schema keyword states. For an impl report: a `blocked` outcome names no
186
+ // `bailCause`, since a block is no bail, gives its reason in `bailDetail`,
187
+ // text that is not only whitespace, and names no `pr` or `head`, since a block
188
+ // reports no PR — a PR it opened is outcome `pr`, which settle checks. For a
189
+ // merge-bot report: each PR gets one verdict — merged, held, conflict-held or
190
+ // refused — the last it reached in the pass, so a PR named twice, in one list
191
+ // or in two, is a violation at its second entry.
182
192
  export function reportViolation(family, value) {
183
193
  const found = validate(SCHEMAS[family], value);
184
- if (found !== null || family !== "merge-bot") return found;
194
+ if (found !== null) return found;
195
+ if (family === "impl" && value.outcome === "blocked") {
196
+ if (value.bailCause !== null) return `$.bailCause: ${show(value.bailCause)} is not null, with outcome "blocked" — a block is no bail`;
197
+ if (!/\S/.test(value.bailDetail ?? "")) return `$.bailDetail: ${show(value.bailDetail)} is not a reason, with outcome "blocked"`;
198
+ for (const field of ["pr", "head"]) {
199
+ if (value[field] !== null) return `$.${field}: ${show(value[field])} is not null, with outcome "blocked" — a block reports no PR`;
200
+ }
201
+ }
202
+ if (family !== "merge-bot") return null;
185
203
  const first = new Map();
186
204
  for (const { pr, path } of mergeBotVerdicts(value)) {
187
205
  if (first.has(pr)) return `${path}: PR #${pr} already has a verdict at ${first.get(pr)}`;
@@ -47,7 +47,7 @@ import { makeDie, defineFlags } from "./arg.mjs";
47
47
  import { CELL, POLICY_CELL, drawCell, parseMember } from "./ledger-grammar.mjs";
48
48
  import { sessionFromReceipt } from "./phase0-receipt.mjs";
49
49
  import { parseTsv as parseMemberTsv } from "./member-outcomes.mjs";
50
- import { AttributionError, FEATURE_COLUMNS, attribute, parseFeatures, tally } from "./pull-attribution.mjs";
50
+ import { AttributionError, FEATURE_COLUMNS, attribute, generations, parseFeatures, tally } from "./pull-attribution.mjs";
51
51
  import { RulingError, parseTierOutcomes } from "./tier-outcomes.mjs";
52
52
 
53
53
  const NAME = "ticket-router";
@@ -333,36 +333,47 @@ const round4 = (x) => Math.round(x * 1e4) / 1e4;
333
333
  * the A/B's test set, not the fit's. The fit sees no PR state, so a Pull with
334
334
  * a Ruling is merged. A fit reads everything in the commit it is given: no row
335
335
  * is cut by date, because `--check` re-reads that same commit rather than
336
- * re-cutting the working tree's files.
336
+ * re-cutting the working tree's files. `order` ranks each cell's generations
337
+ * over every Pull in the window, the restriction aside, so the fit names the
338
+ * same newest generation the guard does.
337
339
  */
338
340
  function fitPulls({ features, members, verdicts, window }) {
339
- return attribute({ features, members, tiers: verdicts, windowStart: window || null, stratum: ruleStratum })
340
- .filter((u) => u.pull.exploration_draw !== "" || u.pull.sizing_src === "rule");
341
+ const all = attribute({ features, members, tiers: verdicts, windowStart: window || null, stratum: ruleStratum });
342
+ return { units: all.filter((u) => u.pull.exploration_draw !== "" || u.pull.sizing_src === "rule"), order: generations(all) };
341
343
  }
342
344
 
343
- // Per stratum and `*`, per cell: `n` counted Pulls, `merged`, $ per merged PR,
344
- // false claims over claims, fix rounds per merged PR, and the cell's excluded,
345
- // left-out and unpriced Pull counts.
346
- function estimate(units) {
345
+ // Per stratum and `*`, per cell, its newest generation's figures: `n` counted
346
+ // Pulls, `merged`, $ per merged PR, false claims over claims, fix rounds per
347
+ // merged PR, and its left-out and unpriced Pull counts. Beside them the cell's
348
+ // excluded Pull count, `generation` naming the newest generation's model, and
349
+ // `older`, each older generation's model to its figures, newest first.
350
+ function estimate(units, order) {
347
351
  const out = {};
348
352
  for (const s of units.length ? [...new Set([...units.map((u) => u.stratum), "*"])].sort() : []) {
349
353
  const mine = s === "*" ? units : units.filter((u) => u.stratum === s);
350
354
  out[s] = {};
351
- for (const t of tally(mine)) {
352
- const fix = mine.filter((u) => u.cell === t.cell && u.status === "counted" && u.merged)
353
- .reduce((n, u) => n + u.rows.filter((r) => parseMember(r.member)?.family === "fix-pr").length, 0);
355
+ for (const t of tally(mine, order)) {
356
+ const figures = (g) => {
357
+ const fix = mine.filter((u) => u.cell === t.cell && u.generation === g.generation && u.status === "counted" && u.merged)
358
+ .reduce((n, u) => n + u.rows.filter((r) => parseMember(r.member)?.family === "fix-pr").length, 0);
359
+ return {
360
+ n: g.n,
361
+ merged: g.merged,
362
+ claims: g.claims,
363
+ failed: g.failed,
364
+ usd_per_merged: g.merged ? round4(g.spend / g.merged) : null,
365
+ fail_rate: g.claims ? round4(g.failed / g.claims) : null,
366
+ fix_rounds: g.merged ? round4(fix / g.merged) : null,
367
+ review_findings: null,
368
+ left_out: g.left_out,
369
+ unpriced: g.unpriced,
370
+ };
371
+ };
354
372
  out[s][t.cell] = {
355
- n: t.n,
356
- merged: t.merged,
357
- claims: t.claims,
358
- failed: t.failed,
359
- usd_per_merged: t.merged ? round4(t.spend / t.merged) : null,
360
- fail_rate: t.claims ? round4(t.failed / t.claims) : null,
361
- fix_rounds: t.merged ? round4(fix / t.merged) : null,
362
- review_findings: null,
373
+ generation: t.generation,
374
+ ...figures(t),
363
375
  excluded: t.excluded,
364
- left_out: t.left_out,
365
- unpriced: t.unpriced,
376
+ older: Object.fromEntries(t.older.map((o) => [o.generation, figures(o)])),
366
377
  };
367
378
  }
368
379
  }
@@ -371,8 +382,9 @@ function estimate(units) {
371
382
 
372
383
  // Among cells with n >= MIN_N in a stratum and not tripped, the one with the
373
384
  // lowest $ per merged PR — adopted only at or under ADOPT_RATIO of the
374
- // default cell's. The quality floor is the guard's to enforce, never the
375
- // fit's. null when the stratum cannot decide (no default cell at n, or no
385
+ // default cell's. Every figure read is the cell's newest generation's, the
386
+ // default cell's included. The quality floor is the guard's to enforce, never
387
+ // the fit's. null when the stratum cannot decide (no default cell at n, or no
376
388
  // candidate at n).
377
389
  function adopt(est, cells, tripped) {
378
390
  const base = est?.[POLICY_CELL];
@@ -393,8 +405,8 @@ function adopt(est, cells, tripped) {
393
405
  */
394
406
  export function fitTable({ prior, features, members, verdicts, guard, commit = null }) {
395
407
  const window = guard.window_start ?? prior.window_start ?? null;
396
- const units = fitPulls({ features, members, verdicts, window });
397
- const estimates = estimate(units);
408
+ const { units, order } = fitPulls({ features, members, verdicts, window });
409
+ const estimates = estimate(units, order);
398
410
  const tripped = new Set(guard.tripped);
399
411
 
400
412
  let { stage, cells } = prior;
@@ -437,10 +449,16 @@ export function mergedSince({ table, features, members, verdicts, fitted }) {
437
449
  .filter((u) => u.status === "counted" && u.merged && !read.has(`${u.pull.session}\0${u.pull.agent}`)).length;
438
450
  }
439
451
 
440
- // Each cell's Pull counts over every stratum, as the fit prints them.
452
+ // Each cell's Pull counts over every stratum, as the fit prints them: its
453
+ // newest generation, then each older one.
441
454
  function cellCounts(estimates) {
455
+ const usd = (x) => (x === null ? "-" : x.toFixed(2));
456
+ const counts = (e) => `n=${e.n} merged=${e.merged} failed=${e.failed} usd_per_merged=${usd(e.usd_per_merged)}`;
442
457
  return Object.entries(estimates["*"] ?? {})
443
- .map(([c, e]) => `${c} n=${e.n} merged=${e.merged} failed=${e.failed} excluded=${e.excluded} left_out=${e.left_out} unpriced=${e.unpriced}`)
458
+ .map(([c, e]) => [
459
+ `${c} ${e.generation ?? "-"} ${counts(e)} excluded=${e.excluded} left_out=${e.left_out} unpriced=${e.unpriced}`,
460
+ ...Object.entries(e.older).map(([g, o]) => `${c} ${g} (older) ${counts(o)}`),
461
+ ].join(", "))
444
462
  .join(", ") || "none";
445
463
  }
446
464
 
@@ -792,7 +792,7 @@ Match the failure to its row in the table below and run that response; where the
792
792
  |---|---|
793
793
  | SHA not on expected branch | Flag, do not enqueue, report |
794
794
  | Implementer bails before implementing | Act by the report's `bailCause` as the **Relabel causes** table says, then refill (**What a wake owes beyond its call**) |
795
- | Implementer blocked or ambiguous *mid-implementation* | Free the slot, `ledger.mjs settle impl-<N> blocked`, leave `in-progress`, report — the ticket is not In flight and waits for a human; the row above is the pre-code bail, not this one |
795
+ | Implementer blocked or ambiguous *mid-implementation* | Free the slot. A `blocked` report settles on the **Implementer report** wake, no label moved; with no report, `ledger.mjs settle impl-<N> blocked`, its Ending still `hollow`. Leave `in-progress`, report — the ticket is not In flight and waits for a human; the row above is the pre-code bail |
796
796
  | `review-pr` workflow throws or returns no tree | Retry once under the next letter (`review-pr-<pr#>-b`); still failing, report and leave the PR at review-due. Never enqueue the PR as reviewed — the workflow is not a member, so no other row here covers it |
797
797
  | Reviewer, fix-applier or red-check fixer cannot reach green | Report, leave the PR unlabeled, free the slot — a fix-applier or red-check fixer settles `failed` |
798
798
  | Merge bot hits the hold rule | Report `held-behind-#<lower>`, PR stays queued |
@@ -841,9 +841,7 @@ enqueue, do not return the ticket to the shortlist until the maintainer rules.
841
841
  escalate if it repeats, and never read it as `not reachable` or record it as a
842
842
  stray commit. See references/failure-handling.md.
843
843
 
844
- **Never `--delete-branch` — the merge bot deletes the head branch itself.**
845
- `gh pr merge <n> --merge` alone: the flag's local cleanup errors on a `main`
846
- held by another worktree, or strands the feature worktree on `main`.
844
+ **Never `--delete-branch` — the merge bot deletes the head branch itself.** See references/failure-handling.md.
847
845
 
848
846
  **Never force a rebase to start.** No `git clean`, `git checkout .`,
849
847
  `git reset --hard`, `git stash`. Non-empty `git status --porcelain -unormal` →
@@ -66,6 +66,13 @@ escalate if it repeats. Never read exit 2 as `not reachable`, and never record i
66
66
  as a stray commit — the flag and the maintainer's ruling above are what exit 1
67
67
  earns, and spending them on a probe that never answered costs a member its
68
68
  ticket over a failure a re-run would have cleared.
69
+
70
+ ## Never `--delete-branch`
71
+
72
+ **Never `--delete-branch` — the merge bot deletes the head branch itself.**
73
+ `gh pr merge <n> --merge` alone: the flag's local cleanup errors on a `main`
74
+ held by another worktree, or strands the feature worktree on `main`.
75
+
69
76
  ## Never force a rebase to start
70
77
 
71
78
  **Never force a rebase to start.** No `git clean`, `git checkout .`,