@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.
- package/agents/fleet-implementer-slow-high.agent.md +5 -1
- package/agents/fleet-implementer-slow-medium.agent.md +5 -1
- package/agents/fleet-implementer-smol-high.agent.md +5 -1
- package/agents/fleet-implementer-task-high.agent.md +5 -1
- package/agents/fleet-implementer-task-max.agent.md +5 -1
- package/package.json +1 -1
- package/scripts/cell-readout.mjs +56 -26
- package/scripts/fleet-event.mjs +1 -0
- package/scripts/ledger.mjs +5 -0
- package/scripts/member-outcomes.mjs +16 -2
- package/scripts/pr-cost.mjs +52 -30
- package/scripts/pull-attribution.mjs +135 -46
- package/scripts/report-files.mjs +1 -1
- package/scripts/report-schema.mjs +28 -10
- package/scripts/ticket-router.mjs +45 -27
- package/skills/run-team/SKILL.md +2 -4
- package/skills/run-team/references/failure-handling.md +7 -0
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
package/scripts/cell-readout.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
37
|
-
// model by name, or `mixed (<model-a> n=…, <model-b> n=…)` when
|
|
38
|
-
// than one, because a role's target is operator config that
|
|
39
|
-
// cell's history. A cell below the gate prints nothing on
|
|
40
|
-
// `below the gate` count on stderr. Neither the gate line nor
|
|
41
|
-
// a session, ticket or member: the gate forbids reading a
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
if (
|
|
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)
|
|
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
|
-
|
|
243
|
+
const order = generations(admitted);
|
|
244
|
+
const tallied = (list = []) => {
|
|
220
245
|
list.sort((a, b) => Number(a.ticket) - Number(b.ticket));
|
|
221
|
-
|
|
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
|
-
|
|
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
|
|
package/scripts/fleet-event.mjs
CHANGED
|
@@ -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
|
package/scripts/ledger.mjs
CHANGED
|
@@ -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`
|
package/scripts/pr-cost.mjs
CHANGED
|
@@ -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
|
|
43
|
-
//
|
|
44
|
-
// (merged PRs
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
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 = (
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
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.
|
|
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,
|
|
133
|
-
* ruling, pendingPr, claims, falseClaims, rows, spend }
|
|
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
|
|
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 =
|
|
152
|
-
const
|
|
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
|
-
|
|
156
|
-
|
|
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 (
|
|
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
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
253
|
-
if (u.merged)
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
} else
|
|
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
|
|
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
|
}
|
package/scripts/report-files.mjs
CHANGED
|
@@ -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
|
|
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"`,
|
|
22
|
-
//
|
|
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
|
|
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;
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
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
|
|
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
|
-
|
|
340
|
-
|
|
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
|
|
344
|
-
//
|
|
345
|
-
// left-out and unpriced Pull counts.
|
|
346
|
-
|
|
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
|
|
353
|
-
.
|
|
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
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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.
|
|
375
|
-
//
|
|
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]) =>
|
|
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
|
|
package/skills/run-team/SKILL.md
CHANGED
|
@@ -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`,
|
|
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 .`,
|