@feigi/fleet-ctl 3.98.2 → 3.99.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/metrics-refresh.mjs +20 -4
- package/scripts/phase0-receipt.mjs +21 -1
- package/scripts/phase0.mjs +27 -6
- package/scripts/pr-cost.mjs +4 -3
- package/scripts/pull-attribution.mjs +18 -10
- package/scripts/pull.mjs +4 -3
- package/scripts/router-table.json +21 -6
- package/scripts/ticket-router.mjs +180 -87
- package/scripts/tier-roles.mjs +16 -13
- package/skills/run-team/SKILL.md +7 -7
- package/skills/run-team/references/phase-0.md +7 -0
- package/skills/run-team/references/pull.md +6 -3
package/package.json
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// `docs/metrics/ticket-features.tsv`, deduped on `session` + `agent`, and
|
|
15
15
|
// deletes the pending file. A crash between the write and the delete leaves
|
|
16
16
|
// rows the next drain finds already there, so a second run changes nothing.
|
|
17
|
+
// A corpus written before a column the pending file carries — one the row
|
|
18
|
+
// grammar names — gains that column, blank on every row it already holds.
|
|
17
19
|
// A pending file that is empty, or blank lines only, is removed with its own
|
|
18
20
|
// line: an append created it and never wrote.
|
|
19
21
|
// The drain takes no lock by design: it assumes only the run's controller
|
|
@@ -59,6 +61,17 @@ const say = (line) => process.stdout.write(`${NAME}: ${line}\n`);
|
|
|
59
61
|
const headerOf = (text) => String(text).split("\n").find((l) => l.trim() && !l.startsWith("#")).split("\t");
|
|
60
62
|
const pullKey = (r) => `${r.session}\0${r.agent}`;
|
|
61
63
|
|
|
64
|
+
// `text`, a features TSV, with `columns` appended: named on its header line, blank on each row.
|
|
65
|
+
function widen(text, columns) {
|
|
66
|
+
let header = true;
|
|
67
|
+
return text.split("\n").map((l) => {
|
|
68
|
+
if (!l.trim() || l.startsWith("#")) return l;
|
|
69
|
+
const added = header ? columns : columns.map(() => "");
|
|
70
|
+
header = false;
|
|
71
|
+
return [l, ...added].join("\t");
|
|
72
|
+
}).join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
62
75
|
function drain(repo) {
|
|
63
76
|
const pending = join(repo, PENDING);
|
|
64
77
|
if (!existsSync(pending)) return say(`drain: nothing pending in ${PENDING}`);
|
|
@@ -76,19 +89,22 @@ function drain(repo) {
|
|
|
76
89
|
const target = join(repo, FEATURES);
|
|
77
90
|
let text = existsSync(target) ? readFileSync(target, "utf8") : `${FEATURE_COLUMNS.join("\t")}\n`;
|
|
78
91
|
const seen = new Set(parseFeatures(text).map(pullKey));
|
|
79
|
-
const
|
|
80
|
-
const
|
|
92
|
+
const had = headerOf(text);
|
|
93
|
+
const newer = headerOf(pendingText).filter((c) => !had.includes(c));
|
|
94
|
+
const unknown = newer.filter((c) => !FEATURE_COLUMNS.includes(c));
|
|
81
95
|
if (unknown.length) throw new Error(`${PENDING} carries ${unknown.join(", ")}, which ${FEATURES} has no column for`);
|
|
96
|
+
if (newer.length) text = widen(text, newer);
|
|
97
|
+
const header = [...had, ...newer];
|
|
82
98
|
const added = [];
|
|
83
99
|
for (const r of incoming) {
|
|
84
100
|
if (seen.has(pullKey(r))) continue;
|
|
85
101
|
seen.add(pullKey(r));
|
|
86
102
|
added.push(header.map((c) => r[c] ?? "").join("\t"));
|
|
87
103
|
}
|
|
88
|
-
if (added.length) {
|
|
104
|
+
if (added.length || newer.length) {
|
|
89
105
|
if (!text.endsWith("\n")) text += "\n";
|
|
90
106
|
mkdirSync(dirname(target), { recursive: true });
|
|
91
|
-
writeFileSync(`${target}.tmp`, `${text}${added.
|
|
107
|
+
writeFileSync(`${target}.tmp`, `${text}${added.map((l) => `${l}\n`).join("")}`);
|
|
92
108
|
renameSync(`${target}.tmp`, target);
|
|
93
109
|
}
|
|
94
110
|
unlinkSync(pending);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// The phase-0 receipt, read. `.fleet/phase0.json` is written by phase0.mjs
|
|
2
2
|
// after every step (its header gives the shape); this module is its readers'
|
|
3
3
|
// half: the tick's dispatch gate, the session directory tier-check.mjs
|
|
4
|
-
// and ticket-router.mjs take when their own flag is omitted,
|
|
4
|
+
// and ticket-router.mjs take when their own flag is omitted, the Objective
|
|
5
|
+
// ticket-router.mjs takes likewise, with its default, and the In-flight
|
|
5
6
|
// bound the drivers hand the tick, with its default. One reading of the file,
|
|
6
7
|
// so the gate and the session readers cannot disagree about what a receipt is.
|
|
7
8
|
//
|
|
@@ -145,6 +146,25 @@ export function sessionFromReceipt(opts) {
|
|
|
145
146
|
throw new Error(`no session given and none in the phase-0 receipt ${path} (${why}) — give one, or run ${phase0Command(null)} first`);
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
// What a run asks the router to minimise, and the one it asks when nobody
|
|
150
|
+
// names one. phase0.mjs records the run's in its receipt; a receipt written
|
|
151
|
+
// before it recorded one is read as the default when phase 0 resumes it.
|
|
152
|
+
export const OBJECTIVES = Object.freeze(["cost", "speed"]);
|
|
153
|
+
export const OBJECTIVE_DEFAULT = "cost";
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The Objective the receipt records, for a reader whose own flag was omitted.
|
|
157
|
+
* Throws an Error naming the receipt when it has none to give.
|
|
158
|
+
*/
|
|
159
|
+
export function objectiveFromReceipt(opts) {
|
|
160
|
+
const { path, read } = readRunReceipt(opts);
|
|
161
|
+
if (read.state === "ok" && OBJECTIVES.includes(read.receipt.objective)) return read.receipt.objective;
|
|
162
|
+
const why = read.state === "missing" ? "no receipt"
|
|
163
|
+
: read.state === "unreadable" ? `unreadable: ${read.why}`
|
|
164
|
+
: "it records no objective";
|
|
165
|
+
throw new Error(`no objective given and none in the phase-0 receipt ${path} (${why}) — give one (${OBJECTIVES.join(" or ")}), or run ${phase0Command(null)} first`);
|
|
166
|
+
}
|
|
167
|
+
|
|
148
168
|
// The In-flight bound a run takes when nobody gives one: the most tickets
|
|
149
169
|
// between Claim and merge at once. Its one definition — phase0.mjs and the
|
|
150
170
|
// tick's own flag default both read it from here.
|
package/scripts/phase0.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Phase 0 as one command: the run's start sequence, in order, with a receipt.
|
|
3
3
|
//
|
|
4
|
-
// phase0.mjs [--session <dir>] [--in-flight n] [--
|
|
5
|
-
// [--spend-dir <dir>]
|
|
4
|
+
// phase0.mjs [--session <dir>] [--in-flight n] [--objective cost|speed]
|
|
5
|
+
// [--plugin-checkout <dir>] [--spend-dir <dir>]
|
|
6
6
|
// [--workflow <name> | --declare-no-ci]
|
|
7
7
|
//
|
|
8
8
|
// The session directory is `--session`, else `PI_ARTIFACTS_DIR`, which omp
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
// THE RECEIPT is `.fleet/phase0.json` (resolved through fleet-dir.mjs, so a
|
|
75
75
|
// worktree and the main checkout name one file):
|
|
76
76
|
//
|
|
77
|
-
// { run, session, scratch, inFlight,
|
|
77
|
+
// { run, session, scratch, inFlight, objective,
|
|
78
78
|
// steps: { <name>: { status, at, output } } }
|
|
79
79
|
//
|
|
80
80
|
// `status` is ok, pending, failed, drift or unanswered; `at` an ISO time;
|
|
@@ -85,6 +85,12 @@
|
|
|
85
85
|
// run. `session` is the session directory, from `--session` or
|
|
86
86
|
// `PI_ARTIFACTS_DIR`. `scratch` is what `scratch-root.mjs` prints. `inFlight`
|
|
87
87
|
// is the In-flight bound this run used — `--in-flight`, or its default.
|
|
88
|
+
// `objective` is the Objective the router routes this run's Pulls by:
|
|
89
|
+
// `--objective`, or its default (OBJECTIVE_DEFAULT in phase0-receipt.mjs).
|
|
90
|
+
// It is fixed for the run: a resumed receipt keeps the one it records — the
|
|
91
|
+
// default when it records none — and a resume whose `--objective` names the
|
|
92
|
+
// other one exits 2 naming both, leaving the receipt as it was. A resumed
|
|
93
|
+
// receipt recording a value that is not an Objective exits 3 naming it.
|
|
88
94
|
//
|
|
89
95
|
// RESUMABLE. A re-run reads the receipt. It continues it — skipping every `ok`
|
|
90
96
|
// step but `alarm` and re-running the rest — only when its `run` equals the
|
|
@@ -148,7 +154,7 @@ import { gitEnv } from "./git-env.mjs";
|
|
|
148
154
|
import { isCLI } from "./is-cli.mjs";
|
|
149
155
|
import { RECEIPT, RECEIPT_HELD, archiveStamp, receiptArchiveName, rowNums } from "./ledger-grammar.mjs";
|
|
150
156
|
import { ancestry, controllerOf, processSource, ProcUnreadable } from "./proc.mjs";
|
|
151
|
-
import { IN_FLIGHT_DEFAULT } from "./phase0-receipt.mjs";
|
|
157
|
+
import { IN_FLIGHT_DEFAULT, OBJECTIVES, OBJECTIVE_DEFAULT } from "./phase0-receipt.mjs";
|
|
152
158
|
|
|
153
159
|
const NAME = "phase0";
|
|
154
160
|
const SCRIPTS = dirname(fileURLToPath(import.meta.url));
|
|
@@ -525,7 +531,7 @@ function main() {
|
|
|
525
531
|
const die = makeDie(NAME);
|
|
526
532
|
const F = defineFlags(die, {
|
|
527
533
|
flags: {
|
|
528
|
-
session: "value", "in-flight": "value", "plugin-checkout": "value",
|
|
534
|
+
session: "value", "in-flight": "value", objective: "value", "plugin-checkout": "value",
|
|
529
535
|
"spend-dir": "value", workflow: "value", "declare-no-ci": "bool",
|
|
530
536
|
},
|
|
531
537
|
});
|
|
@@ -563,6 +569,8 @@ function main() {
|
|
|
563
569
|
return n;
|
|
564
570
|
};
|
|
565
571
|
const inFlight = positive("in-flight") ?? IN_FLIGHT_DEFAULT;
|
|
572
|
+
const named = F.arg("objective");
|
|
573
|
+
if (named !== null && !OBJECTIVES.includes(named)) die(`--objective needs ${OBJECTIVES.join(" or ")}, got ${named}`);
|
|
566
574
|
if (F.has("declare-no-ci") && !absent("workflow")) die("--workflow and --declare-no-ci are alternatives, not a pair");
|
|
567
575
|
const ci = F.has("declare-no-ci") ? "CI: declared absent (--declare-no-ci)" : absent("workflow") ? null : `CI: workflow ${F.arg("workflow")} (--workflow)`;
|
|
568
576
|
const pathOf = (name) => (absent(name) ? null : resolve(F.arg(name)));
|
|
@@ -617,6 +625,19 @@ function main() {
|
|
|
617
625
|
// the `at` it names and the session is the same.
|
|
618
626
|
const rotated = previous !== null && (previous.run !== null || previous.steps.rotate?.status === "ok");
|
|
619
627
|
const resumed = rotated && previous.run === (record?.at ?? null) && previous.session === session;
|
|
628
|
+
// The Objective is fixed for the run: a resumed receipt's stands, and one
|
|
629
|
+
// written before receipts recorded it resumes as the default. A receipt
|
|
630
|
+
// recording anything else is refused, never read as an Objective.
|
|
631
|
+
if (resumed && previous.objective !== undefined && !OBJECTIVES.includes(previous.objective)) {
|
|
632
|
+
process.stderr.write(`${NAME}: this run's receipt ${receiptPath} records the objective ${JSON.stringify(previous.objective)}, which is not ${OBJECTIVES.join(" or ")} — set it to the objective this run began with and re-run\n`);
|
|
633
|
+
process.exitCode = 3;
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const kept = resumed ? previous.objective ?? OBJECTIVE_DEFAULT : null;
|
|
637
|
+
if (kept !== null && named !== null && named !== kept) {
|
|
638
|
+
die(`--objective ${named}: this run's receipt ${receiptPath} records the objective ${kept}, fixed for the run — run phase 0 again without --objective, or with --objective ${kept}`);
|
|
639
|
+
}
|
|
640
|
+
const objective = kept ?? named ?? OBJECTIVE_DEFAULT;
|
|
620
641
|
if (rotated && !resumed) {
|
|
621
642
|
if (existsSync(held)) {
|
|
622
643
|
process.stderr.write(`${NAME}: cannot hold the previous run's receipt for the rotation: ${held} already holds another\n`);
|
|
@@ -626,7 +647,7 @@ function main() {
|
|
|
626
647
|
renameSync(receiptPath, held);
|
|
627
648
|
}
|
|
628
649
|
|
|
629
|
-
const receipt = { run: resumed ? previous.run : null, session, scratch, inFlight, steps: {} };
|
|
650
|
+
const receipt = { run: resumed ? previous.run : null, session, scratch, inFlight, objective, steps: {} };
|
|
630
651
|
for (const name of STEP_NAMES) {
|
|
631
652
|
// `alarm` checks the omp running this invocation, so its `ok` is never carried.
|
|
632
653
|
const was = resumed && name !== "alarm" ? previous.steps[name] : undefined;
|
package/scripts/pr-cost.mjs
CHANGED
|
@@ -102,7 +102,7 @@ export function trips(cell, base) {
|
|
|
102
102
|
export function computeReport({ members, tiers, features, prs, routerTable = null, pricing = null, windowStart = WINDOW_START }) {
|
|
103
103
|
const units = attribute({ features, members, tiers, prs, windowStart });
|
|
104
104
|
const tallies = new Map(tally(units).map((t) => [t.cell, t]));
|
|
105
|
-
const bootUnits = (g) => g.pulls.map(bootUnit);
|
|
105
|
+
const bootUnits = (g) => g.pulls.map((u) => bootUnit(u));
|
|
106
106
|
const meanUsd = (t) => (t.merged ? t.spend / t.merged : null);
|
|
107
107
|
const base = tallies.get(BASELINE_CELL) ?? null;
|
|
108
108
|
// One generation's figures. The interval is the newest generation's against
|
|
@@ -143,8 +143,9 @@ export function computeReport({ members, tiers, features, prs, routerTable = nul
|
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
const verdict = baseline.n < MIN_N ? "none" : tripped.length ? "tripped" : "ok";
|
|
146
|
-
// Retire: every non-default stage-1 cell tripped before any
|
|
147
|
-
|
|
146
|
+
// Retire: every non-default stage-1 cell tripped before any row of either
|
|
147
|
+
// objective's set adopted one.
|
|
148
|
+
const adopted = Object.values(routerTable?.rows ?? {}).some((set) => Object.values(set).some((c) => c !== BASELINE_CELL));
|
|
148
149
|
const retire = !adopted && STAGE_1.filter((c) => c !== BASELINE_CELL).every((c) => tripped.includes(c));
|
|
149
150
|
|
|
150
151
|
return {
|
|
@@ -78,12 +78,14 @@ import { parseMemberName } from "./member-record.mjs";
|
|
|
78
78
|
import { isImplementer } from "./member-outcomes.mjs";
|
|
79
79
|
import { DATE, rulingFor, rulingsByTicket } from "./tier-outcomes.mjs";
|
|
80
80
|
|
|
81
|
-
// The ticket-features row, one per Pull, in this order.
|
|
81
|
+
// The ticket-features row, one per Pull, in this order. `objective` came last
|
|
82
|
+
// and is optional on read: a file whose header lacks it reads it blank.
|
|
82
83
|
export const FEATURE_COLUMNS = Object.freeze([
|
|
83
84
|
"run_date", "session", "agent", "ticket", "policy_cell", "chosen_cell", "exploration_draw",
|
|
84
85
|
"sizing_src", "sizing_pre", "router_usd", "brief_chars", "criteria", "comments", "age_days",
|
|
85
|
-
"paths", "test_paths", "xrefs", "kind",
|
|
86
|
+
"paths", "test_paths", "xrefs", "kind", "objective",
|
|
86
87
|
]);
|
|
88
|
+
const OPTIONAL_COLUMNS = new Set(["objective"]);
|
|
87
89
|
|
|
88
90
|
// The quality floor's margin, in points of fail rate.
|
|
89
91
|
export const TRIP_POINTS = 15;
|
|
@@ -108,10 +110,12 @@ function mulberry32(seed) {
|
|
|
108
110
|
};
|
|
109
111
|
}
|
|
110
112
|
|
|
111
|
-
// The bootstrap 95% range of (cell − base)
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
113
|
+
// The bootstrap 95% range of (cell − base) per merged PR, its ends rounded to
|
|
114
|
+
// two places. Each unit is one counted Pull, `bootUnit`'s shape:
|
|
115
|
+
// `{ value, merged }`, `value` the measure `bootUnit` was asked for and
|
|
116
|
+
// merged 0 or 1. The figure per merged PR is Σvalue / Σmerged. null when
|
|
117
|
+
// either side has no unit, or when under half the resamples have a merged PR
|
|
118
|
+
// on both sides.
|
|
115
119
|
export function bootstrapDiffCI(cellUnits, baseUnits, { iterations = 2000, seed = 1 } = {}) {
|
|
116
120
|
if (!cellUnits.length || !baseUnits.length) return null;
|
|
117
121
|
const rand = mulberry32(seed);
|
|
@@ -119,7 +123,7 @@ export function bootstrapDiffCI(cellUnits, baseUnits, { iterations = 2000, seed
|
|
|
119
123
|
let u = 0, m = 0;
|
|
120
124
|
for (let i = 0; i < units.length; i++) {
|
|
121
125
|
const x = units[Math.floor(rand() * units.length)];
|
|
122
|
-
u += x.
|
|
126
|
+
u += x.value; m += x.merged;
|
|
123
127
|
}
|
|
124
128
|
return m ? u / m : null;
|
|
125
129
|
};
|
|
@@ -133,8 +137,11 @@ export function bootstrapDiffCI(cellUnits, baseUnits, { iterations = 2000, seed
|
|
|
133
137
|
return [round2(diffs[Math.floor(0.025 * (diffs.length - 1))]), round2(diffs[Math.ceil(0.975 * (diffs.length - 1))])];
|
|
134
138
|
}
|
|
135
139
|
|
|
136
|
-
//
|
|
137
|
-
|
|
140
|
+
// What a bootstrap unit measures: `usd`, its spend, or `minutes`, its work time.
|
|
141
|
+
const METRIC = Object.freeze({ usd: (u) => u.spend, minutes: (u) => u.workS / 60 });
|
|
142
|
+
|
|
143
|
+
// A counted Pull as a bootstrap unit, its `value` the Pull's `metric`.
|
|
144
|
+
export const bootUnit = (u, metric = "usd") => ({ value: METRIC[metric](u), merged: u.merged ? 1 : 0 });
|
|
138
145
|
|
|
139
146
|
// ticket-features.tsv: one bare header line naming the columns, then rows.
|
|
140
147
|
// Read by header name; a row whose width differs from the header's is refused.
|
|
@@ -142,7 +149,7 @@ export function parseFeatures(text) {
|
|
|
142
149
|
const lines = String(text ?? "").split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
|
143
150
|
if (lines.length === 0) throw new Error("ticket-features.tsv has no header line");
|
|
144
151
|
const header = lines[0].split("\t");
|
|
145
|
-
const missing = FEATURE_COLUMNS.filter((c) => !header.includes(c));
|
|
152
|
+
const missing = FEATURE_COLUMNS.filter((c) => !header.includes(c) && !OPTIONAL_COLUMNS.has(c));
|
|
146
153
|
if (missing.length) throw new Error(`ticket-features.tsv header lacks ${missing.join(", ")}`);
|
|
147
154
|
return lines.slice(1).map((l, i) => {
|
|
148
155
|
const cells = l.split("\t");
|
|
@@ -150,6 +157,7 @@ export function parseFeatures(text) {
|
|
|
150
157
|
throw new Error(`ticket-features.tsv row ${i + 1}: ${cells.length} fields, expected ${header.length} — ${l.slice(0, 60)}`);
|
|
151
158
|
}
|
|
152
159
|
const row = Object.fromEntries(header.map((c, j) => [c, cells[j]]));
|
|
160
|
+
for (const c of OPTIONAL_COLUMNS) row[c] ??= "";
|
|
153
161
|
if (!isDigits(row.ticket)) throw new Error(`ticket-features.tsv row ${i + 1}: ticket ${JSON.stringify(row.ticket)} is not a number`);
|
|
154
162
|
if (!CELL.test(row.chosen_cell)) throw new Error(`ticket-features.tsv row ${i + 1}: chosen_cell ${JSON.stringify(row.chosen_cell)} is not a cell`);
|
|
155
163
|
return row;
|
package/scripts/pull.mjs
CHANGED
|
@@ -104,7 +104,8 @@
|
|
|
104
104
|
// `behind-issue:#M`, or `paths <p>…`, which scans again and
|
|
105
105
|
// asks again. An exclusion is a ledger row.
|
|
106
106
|
// 6 claim `claim-ticket.sh <N> impl-<N> implementer --apply`.
|
|
107
|
-
// 7 route `ticket-router.mjs route
|
|
107
|
+
// 7 route `ticket-router.mjs route …`, which routes by the Objective
|
|
108
|
+
// the phase-0 receipt records; a router that refuses releases
|
|
108
109
|
// the claim, and a release that fails is exit 2 with the claim
|
|
109
110
|
// still standing. `--chain-head` is given when a ledger row reads
|
|
110
111
|
// `excluded · behind-issue:#<N>`: another open ticket
|
|
@@ -957,7 +958,7 @@ class Pull {
|
|
|
957
958
|
"--impl-row", String(k), ...(head ? ["--chain-head"] : []), "--issue", this.p.issue, "--guard", this.p.guard,
|
|
958
959
|
];
|
|
959
960
|
const r = this.script("route", "ticket-router.mjs", args);
|
|
960
|
-
const m = r.status === 0 ? /^POLICY=(\S+) CELL=(\S+) DRAW=(\S+) STRATUM=(\S+) REASON=(\S+)$/m.exec(r.stdout) : null;
|
|
961
|
+
const m = r.status === 0 ? /^POLICY=(\S+) CELL=(\S+) DRAW=(\S+) STRATUM=(\S+) OBJECTIVE=(\S+) REASON=(\S+)$/m.exec(r.stdout) : null;
|
|
961
962
|
if (m === null) {
|
|
962
963
|
const said = r.status === 0 ? `printed no routing line: ${r.stdout}` : withText(`refused (${r.how})`, r);
|
|
963
964
|
const rel = this.script("release", "release-ticket.sh", [String(st.ticket), st.member, "implementer", "--apply"]);
|
|
@@ -969,7 +970,7 @@ class Pull {
|
|
|
969
970
|
st.outcome = { exit: 2, reason: `router ${said}`, at: now() };
|
|
970
971
|
return this.stop(`ticket-router.mjs ${said}\nits claim is released`);
|
|
971
972
|
}
|
|
972
|
-
st.route = { line: m[0], cell: m[2], draw: m[3], reason: m[
|
|
973
|
+
st.route = { line: m[0], cell: m[2], draw: m[3], reason: m[6], implRow: k, chainHead: head };
|
|
973
974
|
st.stage = "row";
|
|
974
975
|
return null;
|
|
975
976
|
}
|
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
],
|
|
11
11
|
"burn_in": true,
|
|
12
12
|
"rows": {
|
|
13
|
-
"
|
|
13
|
+
"cost": {
|
|
14
|
+
"*": "slow-high"
|
|
15
|
+
},
|
|
16
|
+
"speed": {
|
|
17
|
+
"*": "slow-high"
|
|
18
|
+
}
|
|
14
19
|
},
|
|
15
20
|
"classifier": {
|
|
16
21
|
"b": null,
|
|
@@ -18,11 +23,21 @@
|
|
|
18
23
|
},
|
|
19
24
|
"estimates": {},
|
|
20
25
|
"reasons": {
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
"cost": {
|
|
27
|
+
"*": {
|
|
28
|
+
"cell": "slow-high",
|
|
29
|
+
"reason": "unjudged",
|
|
30
|
+
"cells": {},
|
|
31
|
+
"pairs": []
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"speed": {
|
|
35
|
+
"*": {
|
|
36
|
+
"cell": "slow-high",
|
|
37
|
+
"reason": "unjudged",
|
|
38
|
+
"cells": {},
|
|
39
|
+
"pairs": []
|
|
40
|
+
}
|
|
26
41
|
}
|
|
27
42
|
},
|
|
28
43
|
"guard": {
|
|
@@ -3,26 +3,28 @@
|
|
|
3
3
|
// for dispatch and for the offline fit, because the stratum rule is the one
|
|
4
4
|
// function that must never read differently between the two.
|
|
5
5
|
//
|
|
6
|
-
// route [--session <dir|id>] --ticket <N> --arm A|B
|
|
7
|
-
// --issue <issue.json> --guard <cost-guard.json>
|
|
6
|
+
// route [--session <dir|id>] [--objective cost|speed] --ticket <N> --arm A|B
|
|
7
|
+
// --impl-row <k> --issue <issue.json> --guard <cost-guard.json>
|
|
8
8
|
// [--sizing <sizing.json>] [--table <router-table.json>]
|
|
9
9
|
// [--chain-head] [--pending <tsv>]
|
|
10
10
|
// Prints one line, `POLICY=<cell> CELL=<cell> DRAW=<k/K|->
|
|
11
|
-
// STRATUM=<light|heavy|unknown> REASON=<reason>`,
|
|
11
|
+
// STRATUM=<light|heavy|unknown> OBJECTIVE=<cost|speed> REASON=<reason>`,
|
|
12
|
+
// routing by the objective's row set, and appends the Pull's
|
|
12
13
|
// ticket-features row to the pending TSV (by default
|
|
13
14
|
// `ticket-features.pending.tsv` beside `--guard`, in the run's `.fleet/`). Exit 0 on
|
|
14
15
|
// every data degradation — a missing or unreadable issue, sizing or guard
|
|
15
16
|
// file degrades the line, never the exit. Exit 2 only on a usage error or
|
|
16
17
|
// an unreadable/invalid table: the caller does not dispatch then. With no
|
|
17
18
|
// `--session`, the session is the one the phase-0 receipt
|
|
18
|
-
// `.fleet/phase0.json` records
|
|
19
|
+
// `.fleet/phase0.json` records, and with no `--objective` the objective
|
|
20
|
+
// is; a receipt that records none is exit 2.
|
|
19
21
|
// fit --table <json> --features <tsv> --members <tsv> --verdicts <tsv>
|
|
20
22
|
// --guard <cost-guard.json> --commit <rev> [--due]
|
|
21
23
|
// Re-fits the table from the three TSVs as committed at `--commit` (read
|
|
22
24
|
// with `git show`, never from the working tree) and the guard, and
|
|
23
25
|
// rewrites `--table`, recording the commit's sha as `fitted_commit`, then
|
|
24
|
-
// prints the fit and one line per row
|
|
25
|
-
// candidate's verdict and range. The TSV flags name paths inside the
|
|
26
|
+
// prints the fit and one line per row of each objective's set: its cell
|
|
27
|
+
// and the reason, then each candidate's verdict and range. The TSV flags name paths inside the
|
|
26
28
|
// repository that holds them. With
|
|
27
29
|
// `--due`, writes nothing and prints `DUE=yes|no MERGED=<n>/<N>`: whether
|
|
28
30
|
// N merged PRs have landed since `fitted_commit`, counting the features
|
|
@@ -47,7 +49,7 @@ import { isCLI } from "./is-cli.mjs";
|
|
|
47
49
|
import { gitEnv } from "./git-env.mjs";
|
|
48
50
|
import { makeDie, defineFlags } from "./arg.mjs";
|
|
49
51
|
import { CELL, POLICY_CELL, drawCell, parseMember } from "./ledger-grammar.mjs";
|
|
50
|
-
import { sessionFromReceipt } from "./phase0-receipt.mjs";
|
|
52
|
+
import { OBJECTIVES, objectiveFromReceipt, sessionFromReceipt } from "./phase0-receipt.mjs";
|
|
51
53
|
import { parseTsv as parseMemberTsv } from "./member-outcomes.mjs";
|
|
52
54
|
import {
|
|
53
55
|
AttributionError, FEATURE_COLUMNS, TRIP_POINTS, attribute, bootUnit, bootstrapDiffCI, generations, parseFeatures, tally, underFloor,
|
|
@@ -181,15 +183,29 @@ export function ruleStratum({ brief_chars, criteria }) {
|
|
|
181
183
|
/** A parsed table, or an Error saying why it is not one. */
|
|
182
184
|
export function validateTable(t) {
|
|
183
185
|
const bad = (why) => new Error(`router table: ${why}`);
|
|
184
|
-
|
|
186
|
+
const isObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
|
|
187
|
+
if (!isObject(t)) return bad("not a JSON object");
|
|
185
188
|
if (!Array.isArray(t.cells) || t.cells.length === 0) return bad("`cells` is not a non-empty array");
|
|
186
189
|
for (const c of t.cells) if (typeof c !== "string" || !CELL.test(c)) return bad(`\`cells\` holds ${JSON.stringify(c)}, not a cell token`);
|
|
187
190
|
if (new Set(t.cells).size !== t.cells.length) return bad("`cells` repeats a cell");
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (
|
|
191
|
+
// `rows` and `reasons` are each keyed by objective, then by `*` or a stratum.
|
|
192
|
+
for (const field of ["rows", "reasons"]) {
|
|
193
|
+
if (!isObject(t[field])) return bad(`\`${field}\` is not an object`);
|
|
194
|
+
const stray = Object.keys(t[field]).find((o) => !OBJECTIVES.includes(o));
|
|
195
|
+
if (stray !== undefined) return bad(`\`${field}\` key ${JSON.stringify(stray)} is not an objective (${OBJECTIVES.join(", ")})`);
|
|
196
|
+
for (const o of OBJECTIVES) {
|
|
197
|
+
const set = t[field][o];
|
|
198
|
+
if (!isObject(set)) return bad(`\`${field}.${o}\` is not an object`);
|
|
199
|
+
for (const k of Object.keys(set)) {
|
|
200
|
+
if (k !== "*" && !STRATA.includes(k)) return bad(`\`${field}.${o}\` key ${JSON.stringify(k)} is not \`*\` or a stratum`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
for (const o of OBJECTIVES) {
|
|
205
|
+
if (!Object.hasOwn(t.rows[o], "*")) return bad(`\`rows.${o}\` has no \`*\` row`);
|
|
206
|
+
for (const [k, v] of Object.entries(t.rows[o])) {
|
|
207
|
+
if (typeof v !== "string" || !CELL.test(v)) return bad(`\`rows.${o}.${k}\` is ${JSON.stringify(v)}, not a cell token`);
|
|
208
|
+
}
|
|
193
209
|
}
|
|
194
210
|
if (typeof t.burn_in !== "boolean") return bad("`burn_in` is not a boolean");
|
|
195
211
|
if (!Number.isInteger(t.stage) || t.stage < 1) return bad("`stage` is not a positive integer");
|
|
@@ -204,9 +220,13 @@ export function validateTable(t) {
|
|
|
204
220
|
return t;
|
|
205
221
|
}
|
|
206
222
|
|
|
207
|
-
/**
|
|
208
|
-
|
|
209
|
-
|
|
223
|
+
/**
|
|
224
|
+
* Arm B runs only once a classifier has passed its gate and some row of the
|
|
225
|
+
* run's own objective's set is not the default: it decides a paid classifier
|
|
226
|
+
* call for one Pull, which the other set's rows never route.
|
|
227
|
+
*/
|
|
228
|
+
export function bArmLive(table, objective) {
|
|
229
|
+
return table.classifier.b !== null && Object.values(table.rows[objective]).some((c) => c !== POLICY_CELL);
|
|
210
230
|
}
|
|
211
231
|
|
|
212
232
|
const rowFor = (rows, stratum) => rows[stratum] ?? rows["*"];
|
|
@@ -253,10 +273,14 @@ export function sessionDate(session) {
|
|
|
253
273
|
}
|
|
254
274
|
|
|
255
275
|
/**
|
|
256
|
-
* One Pull's routing. Pure: every input is already
|
|
276
|
+
* One Pull's routing, by `objective`'s row set. Pure: every input is already
|
|
277
|
+
* read. The exploration draw ignores the objective: it leaves out the cell
|
|
278
|
+
* the `cost` set routes the Pull to — by the sizing when arm B is live for
|
|
279
|
+
* the `cost` set, whichever set routes the Pull — so a Pull draws the same
|
|
280
|
+
* cell under both.
|
|
257
281
|
* @returns {{ line: string, row: Record<string,string> }}
|
|
258
282
|
*/
|
|
259
|
-
export function route({ session, ticket, arm, implRow, chainHead = false, issue, sizing = null, guard, table, now = Date.now() }) {
|
|
283
|
+
export function route({ session, ticket, arm, implRow, chainHead = false, issue, sizing = null, guard, table, objective, now = Date.now() }) {
|
|
260
284
|
let reason = null;
|
|
261
285
|
const degrade = (r) => { reason ??= r; };
|
|
262
286
|
|
|
@@ -270,9 +294,10 @@ export function route({ session, ticket, arm, implRow, chainHead = false, issue,
|
|
|
270
294
|
let sizingSrc = "rule";
|
|
271
295
|
let sizingPre = "";
|
|
272
296
|
let routerUsd = "";
|
|
273
|
-
const
|
|
297
|
+
const sized = arm === "B" ? readSizing(sizing, table.classifier) : null;
|
|
298
|
+
const b = arm === "B" && bArmLive(table, objective);
|
|
274
299
|
if (b) {
|
|
275
|
-
const s =
|
|
300
|
+
const s = sized;
|
|
276
301
|
if (s === null) {
|
|
277
302
|
degrade("no-sizing");
|
|
278
303
|
stratum = "unknown";
|
|
@@ -282,8 +307,11 @@ export function route({ session, ticket, arm, implRow, chainHead = false, issue,
|
|
|
282
307
|
}
|
|
283
308
|
}
|
|
284
309
|
|
|
285
|
-
|
|
286
|
-
let
|
|
310
|
+
const rows = table.rows[objective];
|
|
311
|
+
let policy = rowFor(rows, ruleS);
|
|
312
|
+
let base = b ? rowFor(rows, stratum) : policy;
|
|
313
|
+
const costB = arm === "B" && bArmLive(table, "cost");
|
|
314
|
+
let drawBase = rowFor(table.rows.cost, costB ? sized?.stratum ?? "unknown" : ruleS);
|
|
287
315
|
let cell;
|
|
288
316
|
let draw = "-";
|
|
289
317
|
if (guard === null) {
|
|
@@ -298,10 +326,11 @@ export function route({ session, ticket, arm, implRow, chainHead = false, issue,
|
|
|
298
326
|
if (tripped.has(policy)) policy = POLICY_CELL;
|
|
299
327
|
if (tripped.has(base)) base = POLICY_CELL;
|
|
300
328
|
}
|
|
329
|
+
if (tripped.has(drawBase)) drawBase = POLICY_CELL;
|
|
301
330
|
cell = base;
|
|
302
331
|
const exploring = table.burn_in || (implRow % EXPLORE_EVERY === 0 && !chainHead);
|
|
303
332
|
if (exploring) {
|
|
304
|
-
const policyCell = table.burn_in ? null :
|
|
333
|
+
const policyCell = table.burn_in ? null : drawBase;
|
|
305
334
|
if (table.cells.some((c) => c !== policyCell && tripped.has(c))) degrade("guard-tripped");
|
|
306
335
|
const d = drawCell({ session, ticket, policyCell, cells: table.cells.filter((c) => !tripped.has(c)) });
|
|
307
336
|
if (d.K > 0) {
|
|
@@ -315,9 +344,9 @@ export function route({ session, ticket, arm, implRow, chainHead = false, issue,
|
|
|
315
344
|
const row = {
|
|
316
345
|
run_date: sessionDate(session), session, agent: `impl-${ticket}`, ticket: String(ticket),
|
|
317
346
|
policy_cell: policy, chosen_cell: cell, exploration_draw: draw === "-" ? "" : draw,
|
|
318
|
-
sizing_src: sizingSrc, sizing_pre: sizingPre, router_usd: routerUsd, ...m,
|
|
347
|
+
sizing_src: sizingSrc, sizing_pre: sizingPre, router_usd: routerUsd, ...m, objective,
|
|
319
348
|
};
|
|
320
|
-
return { line: `POLICY=${policy} CELL=${cell} DRAW=${draw} STRATUM=${stratum} REASON=${reason}`, row };
|
|
349
|
+
return { line: `POLICY=${policy} CELL=${cell} DRAW=${draw} STRATUM=${stratum} OBJECTIVE=${objective} REASON=${reason}`, row };
|
|
321
350
|
}
|
|
322
351
|
|
|
323
352
|
export function formatFeatureRow(row) {
|
|
@@ -393,95 +422,140 @@ function estimate(units, order) {
|
|
|
393
422
|
return { estimates, pulls };
|
|
394
423
|
}
|
|
395
424
|
|
|
425
|
+
// What each objective's judge reads and says: the figure per merged PR it
|
|
426
|
+
// minimises (`estimate()`'s name and its bootstrap metric), its verdict for a
|
|
427
|
+
// candidate past the margin and for one with no reliable difference, the key
|
|
428
|
+
// a pair names its better side by, and its reasons for a winner every other
|
|
429
|
+
// qualifier was reliably worse than and for one ahead on the figure.
|
|
430
|
+
const JUDGE = Object.freeze({
|
|
431
|
+
cost: {
|
|
432
|
+
figure: "usd_per_merged", metric: "usd", over: "too-dear", unreliable: "not-reliably-cheaper",
|
|
433
|
+
better: "cheaper", reliably: "reliably-cheaper", ahead: "lower-usd",
|
|
434
|
+
},
|
|
435
|
+
speed: {
|
|
436
|
+
figure: "minutes_per_merged", metric: "minutes", over: "too-slow", unreliable: "not-reliably-faster",
|
|
437
|
+
better: "faster", reliably: "reliably-faster", ahead: "fewer-minutes",
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// A row's record with no cell: a fresh object each time, since judge() fills
|
|
442
|
+
// in `cells` and `pairs`.
|
|
443
|
+
const unjudged = () => ({ cell: null, reason: "unjudged", cells: {}, pairs: [] });
|
|
444
|
+
|
|
396
445
|
/**
|
|
397
|
-
* One row's cell — the `*` row's or a stratum's — and
|
|
398
|
-
* is held twice: pooled by the guard, whose trip takes
|
|
399
|
-
* and out of the exploration draw, and per row by the
|
|
400
|
-
* cell under the floor that row alone.
|
|
401
|
-
*
|
|
402
|
-
*
|
|
446
|
+
* One row's cell under one objective — the `*` row's or a stratum's — and
|
|
447
|
+
* why. The quality floor is held twice: pooled by the guard, whose trip takes
|
|
448
|
+
* a cell out of every row and out of the exploration draw, and per row by the
|
|
449
|
+
* fit, which refuses a cell under the floor that row alone. The floor is
|
|
450
|
+
* measured against the default cell under both objectives. Every figure read
|
|
451
|
+
* is the cell's newest generation's, the default cell's included. `est` and
|
|
452
|
+
* `pulls` are the row's `estimate()` figures and counted Pulls; `baseCell` is
|
|
453
|
+
* the cell the row holds unless a candidate qualifies: the default cell under
|
|
454
|
+
* `cost`, the `cost` row's cell under `speed`. The figure is $ per merged PR
|
|
455
|
+
* under `cost` and minutes of work time per merged PR under `speed`.
|
|
403
456
|
*
|
|
404
|
-
* A candidate is a cell of `cells` other than the
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
* `too-
|
|
408
|
-
* `not-reliably-cheaper`, its `range` does not lie
|
|
409
|
-
* upper end, as `bootstrapDiffCI` returns it, is not
|
|
410
|
-
* none; else it `qualifies`. A candidate past
|
|
411
|
-
* `under_floor`, the floor's `bar` (the
|
|
412
|
-
* TRIP_POINTS, rounded as a fail rate is, for
|
|
413
|
-
* counts) and its `range`, the bootstrap 95%
|
|
414
|
-
*
|
|
457
|
+
* A candidate is a cell of `cells` other than the base. Its `verdict`, the
|
|
458
|
+
* first that applies: `tripped` by the guard; `too-few`, under MIN_N merged
|
|
459
|
+
* PRs in the row; `under-floor` against the default cell there; `too-dear`
|
|
460
|
+
* (`cost`) or `too-slow` (`speed`), above ADOPT_RATIO of the base's figure;
|
|
461
|
+
* `not-reliably-cheaper` or `not-reliably-faster`, its `range` does not lie
|
|
462
|
+
* wholly below zero (its upper end, as `bootstrapDiffCI` returns it, is not
|
|
463
|
+
* below zero) or there is none; else it `qualifies`. A candidate past
|
|
464
|
+
* `too-few` records whether it is `under_floor`, the floor's `bar` (the
|
|
465
|
+
* default cell's fail rate plus TRIP_POINTS, rounded as a fail rate is, for
|
|
466
|
+
* reading only: the floor compares counts) and its `range`, the bootstrap 95%
|
|
467
|
+
* range of (candidate − base) figure over those Pulls; the others record null
|
|
468
|
+
* for each.
|
|
415
469
|
*
|
|
416
|
-
* Several qualifiers: each pair records the range of (the
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
470
|
+
* Several qualifiers: each pair records the range of (the better by the
|
|
471
|
+
* figure, else by name − the other) in `pairs`, naming the better side
|
|
472
|
+
* `cheaper` or `faster`, and a qualifier another is reliably better than is
|
|
473
|
+
* dropped. Among the rest the lower fail rate, compared in counts, wins, then
|
|
474
|
+
* the lower figure, then the name.
|
|
420
475
|
*
|
|
421
|
-
* `reason`, why the row holds `cell`: `unjudged` (the default cell
|
|
422
|
-
* untripped candidate, is under MIN_N merged PRs; `cell` is
|
|
423
|
-
*
|
|
424
|
-
*
|
|
476
|
+
* `reason`, why the row holds `cell`: `unjudged` (the default cell or the
|
|
477
|
+
* base, or every untripped candidate, is under MIN_N merged PRs; `cell` is
|
|
478
|
+
* null), `no-qualifier` (the base), or, for the winner, `sole-qualifier`,
|
|
479
|
+
* `reliably-cheaper` or `reliably-faster` (every other qualifier was
|
|
425
480
|
* dropped), or what put it ahead of the next of the rest: `lower-fail-rate`,
|
|
426
|
-
* `lower-usd` or `name`.
|
|
481
|
+
* `lower-usd` or `fewer-minutes`, or `name`.
|
|
427
482
|
*/
|
|
428
|
-
function
|
|
429
|
-
const
|
|
430
|
-
const
|
|
431
|
-
|
|
483
|
+
function judge(objective, est = {}, pulls, cells, tripped, baseCell) {
|
|
484
|
+
const { figure, metric, over, unreliable, better, reliably, ahead } = JUDGE[objective];
|
|
485
|
+
const record = unjudged();
|
|
486
|
+
const floor = est[POLICY_CELL];
|
|
487
|
+
const base = est[baseCell];
|
|
488
|
+
if (!floor || floor.merged < MIN_N || !base || base.merged < MIN_N) return record;
|
|
432
489
|
// JSON writes −0 as 0, so a recorded end is 0 either way; `--check` compares the two.
|
|
433
|
-
const
|
|
490
|
+
const units = (c) => pulls[c].map((u) => bootUnit(u, metric));
|
|
491
|
+
const range = (a, b) => bootstrapDiffCI(units(a), units(b))?.map((x) => x + 0) ?? null;
|
|
434
492
|
const reliablyBelow = (r) => r !== null && r[1] < 0;
|
|
435
|
-
const bar = round4(
|
|
493
|
+
const bar = round4(floor.failed / floor.claims + TRIP_POINTS / 100);
|
|
436
494
|
const qualifiers = [];
|
|
437
495
|
let judged = false;
|
|
438
496
|
for (const c of cells) {
|
|
439
|
-
if (c ===
|
|
497
|
+
if (c === baseCell) continue;
|
|
440
498
|
const e = est[c];
|
|
441
499
|
if (tripped.has(c) || !e || e.merged < MIN_N) {
|
|
442
500
|
record.cells[c] = { verdict: tripped.has(c) ? "tripped" : "too-few", under_floor: null, bar: null, range: null };
|
|
443
501
|
continue;
|
|
444
502
|
}
|
|
445
503
|
judged = true;
|
|
446
|
-
const under = underFloor(e,
|
|
447
|
-
const r = range(c,
|
|
504
|
+
const under = underFloor(e, floor);
|
|
505
|
+
const r = range(c, baseCell);
|
|
448
506
|
const verdict = under ? "under-floor"
|
|
449
|
-
: e
|
|
450
|
-
: reliablyBelow(r) ? "qualifies" :
|
|
507
|
+
: e[figure] > ADOPT_RATIO * base[figure] ? over
|
|
508
|
+
: reliablyBelow(r) ? "qualifies" : unreliable;
|
|
451
509
|
record.cells[c] = { verdict, under_floor: under, bar, range: r };
|
|
452
510
|
if (verdict === "qualifies") qualifiers.push(c);
|
|
453
511
|
}
|
|
454
512
|
if (!judged) return record;
|
|
455
|
-
if (qualifiers.length === 0) return { ...record, cell:
|
|
513
|
+
if (qualifiers.length === 0) return { ...record, cell: baseCell, reason: "no-qualifier" };
|
|
456
514
|
|
|
457
|
-
const
|
|
515
|
+
const of = (c) => est[c][figure];
|
|
458
516
|
const byName = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
459
517
|
const failCmp = (a, b) => est[a].failed * est[b].claims - est[b].failed * est[a].claims;
|
|
460
518
|
const named = [...qualifiers].sort(byName);
|
|
461
519
|
const dropped = new Set();
|
|
462
520
|
for (let i = 0; i < named.length; i++) {
|
|
463
521
|
for (let j = i + 1; j < named.length; j++) {
|
|
464
|
-
const [
|
|
465
|
-
const r = range(
|
|
466
|
-
record.pairs.push({
|
|
522
|
+
const [win, other] = of(named[j]) < of(named[i]) ? [named[j], named[i]] : [named[i], named[j]];
|
|
523
|
+
const r = range(win, other);
|
|
524
|
+
record.pairs.push({ [better]: win, other, range: r });
|
|
467
525
|
if (reliablyBelow(r)) dropped.add(other);
|
|
468
526
|
}
|
|
469
527
|
}
|
|
470
|
-
// The
|
|
471
|
-
const [win, next] = named.filter((c) => !dropped.has(c)).sort((a, b) => failCmp(a, b) ||
|
|
528
|
+
// The best qualifier, by the figure then name, is never dropped, so the rest is never empty.
|
|
529
|
+
const [win, next] = named.filter((c) => !dropped.has(c)).sort((a, b) => failCmp(a, b) || of(a) - of(b) || byName(a, b));
|
|
472
530
|
const reason = qualifiers.length === 1 ? "sole-qualifier"
|
|
473
|
-
: next === undefined ?
|
|
531
|
+
: next === undefined ? reliably
|
|
474
532
|
: failCmp(win, next) !== 0 ? "lower-fail-rate"
|
|
475
|
-
:
|
|
533
|
+
: of(win) !== of(next) ? ahead : "name";
|
|
476
534
|
return { ...record, cell: win, reason };
|
|
477
535
|
}
|
|
478
536
|
|
|
537
|
+
/** A `cost` row: `judge()` from the default cell, by $ per merged PR. */
|
|
538
|
+
function judgeRow(est, pulls, cells, tripped) {
|
|
539
|
+
return judge("cost", est, pulls, cells, tripped, POLICY_CELL);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** A `speed` row: `judge()` from the `cost` row's cell, by minutes of work time per merged PR. */
|
|
543
|
+
function judgeSpeedRow(est, pulls, cells, tripped, costCell) {
|
|
544
|
+
return judge("speed", est, pulls, cells, tripped, costCell);
|
|
545
|
+
}
|
|
546
|
+
|
|
479
547
|
/**
|
|
480
548
|
* The re-fit table. `guard` is `{ tripped, n, window_start }` (readGuard's
|
|
481
549
|
* shape); `commit` is the sha of the commit the inputs were read from, or null
|
|
482
|
-
* for the empty corpus before any fit. `
|
|
483
|
-
* for `*` and for each
|
|
484
|
-
*
|
|
550
|
+
* for the empty corpus before any fit. `rows` and `reasons` hold one set per
|
|
551
|
+
* objective. `reasons.cost` holds `judgeRow()`'s record for `*` and for each
|
|
552
|
+
* stratum with Pulls; a stratum whose record has no cell gets no `cost` row
|
|
553
|
+
* and follows `*`, and an unjudged `*` is the default cell. `reasons.speed`
|
|
554
|
+
* holds `judgeSpeedRow()`'s record for each row with a `cost` row, from that
|
|
555
|
+
* row's cell, and an `unjudged` record with no cell for each stratum with no
|
|
556
|
+
* `cost` row: a row with a `cost` row holds a `speed` row, the base when the
|
|
557
|
+
* record has no cell, and a stratum with no `cost` row has no `speed` row
|
|
558
|
+
* and follows `*`.
|
|
485
559
|
*/
|
|
486
560
|
export function fitTable({ prior, features, members, verdicts, guard, commit = null }) {
|
|
487
561
|
const window = guard.window_start ?? prior.window_start ?? null;
|
|
@@ -492,15 +566,26 @@ export function fitTable({ prior, features, members, verdicts, guard, commit = n
|
|
|
492
566
|
let { stage, cells } = prior;
|
|
493
567
|
cells = [...cells];
|
|
494
568
|
const star = judgeRow(estimates["*"], pulls["*"], cells, tripped);
|
|
495
|
-
const rows = { "*": star.cell ?? POLICY_CELL };
|
|
496
|
-
const reasons = { "*": { ...star, cell: rows["*"] } };
|
|
569
|
+
const rows = { cost: { "*": star.cell ?? POLICY_CELL }, speed: {} };
|
|
570
|
+
const reasons = { cost: { "*": { ...star, cell: rows.cost["*"] } }, speed: {} };
|
|
497
571
|
for (const s of STRATA) {
|
|
498
572
|
if (!estimates[s]) continue;
|
|
499
|
-
reasons[s] = judgeRow(estimates[s], pulls[s], cells, tripped);
|
|
500
|
-
if (reasons[s].cell !== null) rows[s] = reasons[s].cell;
|
|
573
|
+
reasons.cost[s] = judgeRow(estimates[s], pulls[s], cells, tripped);
|
|
574
|
+
if (reasons.cost[s].cell !== null) rows.cost[s] = reasons.cost[s].cell;
|
|
575
|
+
}
|
|
576
|
+
for (const s of Object.keys(reasons.cost)) {
|
|
577
|
+
const costCell = rows.cost[s];
|
|
578
|
+
if (costCell === undefined) {
|
|
579
|
+
reasons.speed[s] = unjudged();
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
const record = judgeSpeedRow(estimates[s], pulls[s], cells, tripped, costCell);
|
|
583
|
+
rows.speed[s] = record.cell ?? costCell;
|
|
584
|
+
reasons.speed[s] = { ...record, cell: rows.speed[s] };
|
|
501
585
|
}
|
|
502
586
|
const atN = (c) => (guard.n[c] ?? 0) >= MIN_N;
|
|
503
|
-
|
|
587
|
+
const adopted = Object.values(rows).some((set) => set["*"] !== POLICY_CELL);
|
|
588
|
+
if (stage === 1 && cells.every(atN) && (adopted || cells.some((c) => tripped.has(c)))) {
|
|
504
589
|
stage = 2;
|
|
505
590
|
for (const { cell, survives } of STAGE2) {
|
|
506
591
|
if (!cells.includes(cell) && cells.includes(survives) && !tripped.has(survives)) cells.push(cell);
|
|
@@ -546,14 +631,15 @@ function cellCounts(estimates) {
|
|
|
546
631
|
.join(", ") || "none";
|
|
547
632
|
}
|
|
548
633
|
|
|
549
|
-
// One line per row the fit judged or skipped: its cell (`-`
|
|
550
|
-
// that follows `*`) and its reason, then each candidate's
|
|
634
|
+
// One line per row the fit judged or skipped, per objective: its cell (`-`
|
|
635
|
+
// for a stratum that follows `*`) and its reason, then each candidate's
|
|
636
|
+
// verdict and range.
|
|
551
637
|
function rowLines(reasons) {
|
|
552
638
|
const range = (r) => (r === null ? "" : ` [${r.map((x) => x.toFixed(2)).join(", ")}]`);
|
|
553
|
-
return Object.entries(reasons).map(([s, r]) => {
|
|
639
|
+
return Object.entries(reasons).flatMap(([o, set]) => Object.entries(set).map(([s, r]) => {
|
|
554
640
|
const cells = Object.entries(r.cells).map(([c, x]) => `${c} ${x.verdict}${range(x.range)}`).join(", ");
|
|
555
|
-
return `${NAME}: row ${s} ${r.cell ?? "-"} ${r.reason}${cells ? ` (${cells})` : ""}\n`;
|
|
556
|
-
}).join("");
|
|
641
|
+
return `${NAME}: row ${o} ${s} ${r.cell ?? "-"} ${r.reason}${cells ? ` (${cells})` : ""}\n`;
|
|
642
|
+
})).join("");
|
|
557
643
|
}
|
|
558
644
|
|
|
559
645
|
// ---------------------------------------------------------------------------
|
|
@@ -598,7 +684,7 @@ function main() {
|
|
|
598
684
|
const die = makeDie(NAME);
|
|
599
685
|
const F = defineFlags(die, {
|
|
600
686
|
flags: {
|
|
601
|
-
session: "value", ticket: "value", arm: "value", "impl-row": "value", issue: "value",
|
|
687
|
+
session: "value", objective: "value", ticket: "value", arm: "value", "impl-row": "value", issue: "value",
|
|
602
688
|
sizing: "value", guard: "value", table: "value", pending: "value", "chain-head": "bool",
|
|
603
689
|
features: "value", members: "value", verdicts: "value", commit: "value", check: "bool", due: "bool",
|
|
604
690
|
},
|
|
@@ -612,7 +698,7 @@ function main() {
|
|
|
612
698
|
die("usage: ticket-router.mjs route … | fit … | --check … — exactly one mode");
|
|
613
699
|
}
|
|
614
700
|
const allowed = {
|
|
615
|
-
route: ["session", "ticket", "arm", "impl-row", "issue", "sizing", "guard", "table", "pending", "chain-head"],
|
|
701
|
+
route: ["session", "objective", "ticket", "arm", "impl-row", "issue", "sizing", "guard", "table", "pending", "chain-head"],
|
|
616
702
|
fit: ["table", "features", "members", "verdicts", "guard", "commit", "due"],
|
|
617
703
|
check: ["check", "table", "features", "members", "verdicts"],
|
|
618
704
|
}[mode];
|
|
@@ -643,9 +729,16 @@ function main() {
|
|
|
643
729
|
try { sessionArg = sessionFromReceipt(); } catch (e) { die(e.message); }
|
|
644
730
|
}
|
|
645
731
|
const session = basename(sessionArg.replace(/\/+$/, ""));
|
|
732
|
+
// `--objective` omitted is the one the receipt records, likewise.
|
|
733
|
+
let objective = F.arg("objective");
|
|
734
|
+
if (objective === null) {
|
|
735
|
+
try { objective = objectiveFromReceipt(); } catch (e) { die(e.message); }
|
|
736
|
+
} else if (!OBJECTIVES.includes(objective)) {
|
|
737
|
+
die(`--objective ${objective} is not ${OBJECTIVES.join(" or ")}`);
|
|
738
|
+
}
|
|
646
739
|
const { line, row } = route({
|
|
647
740
|
session, ticket: Number(ticket), arm, implRow: Number(implRowArg), chainHead: F.has("chain-head"),
|
|
648
|
-
issue: readJson(issuePath), sizing: readJson(F.arg("sizing")), guard: readGuard(readJson(guardPath)), table,
|
|
741
|
+
issue: readJson(issuePath), sizing: readJson(F.arg("sizing")), guard: readGuard(readJson(guardPath)), table, objective,
|
|
649
742
|
});
|
|
650
743
|
const pending = F.arg("pending") ?? join(dirname(guardPath), "ticket-features.pending.tsv");
|
|
651
744
|
// The header decision and the append are not atomic and take no lock. That is
|
package/scripts/tier-roles.mjs
CHANGED
|
@@ -27,12 +27,12 @@
|
|
|
27
27
|
// definition never declared, and every row the definition ran would
|
|
28
28
|
// be inadmissible. The one exception is an implementer definition
|
|
29
29
|
// whose cell the router never selects (not in the router table's
|
|
30
|
-
// `cells`, in
|
|
31
|
-
// cell can be defined before the router may pick it,
|
|
32
|
-
// is a notice. The router table is `router-table.json`
|
|
33
|
-
// script, or the file `--table <path>` names; one that
|
|
34
|
-
// or that lacks a `cells` array or a `rows` object of
|
|
35
|
-
// refuses. A target the catalog does not list cannot be checked, so it
|
|
30
|
+
// `cells`, in no row of either objective's `rows` set, and not the
|
|
31
|
+
// policy cell): a cell can be defined before the router may pick it,
|
|
32
|
+
// so its mismatch is a notice. The router table is `router-table.json`
|
|
33
|
+
// beside this script, or the file `--table <path>` names; one that
|
|
34
|
+
// cannot be read, or that lacks a `cells` array or a `rows` object of
|
|
35
|
+
// row sets of cell names, refuses. A target the catalog does not list cannot be checked, so it
|
|
36
36
|
// fails too;
|
|
37
37
|
// (e) `modelRoles.slow`/`modelRoles.task` resolving to the same model is
|
|
38
38
|
// flagged as a notice — legal, but every `slow-*` cell then measures
|
|
@@ -139,17 +139,20 @@ export function implementerCell(file) {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
// The cells the router can select from a parsed router table: the pool it
|
|
142
|
-
// explores (`cells`), the cell each
|
|
143
|
-
// policy cell, which every Pull falls back to whatever the
|
|
144
|
-
// Error saying why when the table lacks a `cells` array of
|
|
145
|
-
// object of names — a table that cannot be read
|
|
146
|
-
// make every cell non-routable.
|
|
142
|
+
// explores (`cells`), the cell each row of each objective's set names (`rows`
|
|
143
|
+
// values) and the policy cell, which every Pull falls back to whatever the
|
|
144
|
+
// table says. An Error saying why when the table lacks a `cells` array of
|
|
145
|
+
// names or a `rows` object of row sets of names — a table that cannot be read
|
|
146
|
+
// must refuse, never quietly make every cell non-routable.
|
|
147
147
|
export function routableCells(table) {
|
|
148
148
|
const bad = (why) => new Error(`router table: ${why}`);
|
|
149
149
|
if (!isJsonObject(table)) return bad("not a JSON object");
|
|
150
150
|
if (!Array.isArray(table.cells) || !table.cells.every((c) => typeof c === "string")) return bad("`cells` is not an array of cell names");
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
const sets = isJsonObject(table.rows) ? Object.values(table.rows) : null;
|
|
152
|
+
if (sets === null || !sets.every((set) => isJsonObject(set) && Object.values(set).every((c) => typeof c === "string"))) {
|
|
153
|
+
return bad("`rows` is not an object of row sets of cell names");
|
|
154
|
+
}
|
|
155
|
+
return new Set([POLICY_CELL, ...table.cells, ...sets.flatMap((set) => Object.values(set))]);
|
|
153
156
|
}
|
|
154
157
|
|
|
155
158
|
// Tolerates a role value written without its provider prefix — omp's
|
package/skills/run-team/SKILL.md
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: run-team
|
|
3
3
|
description: Run an agent fleet — implementers, reviewers, one merge bot — over the ready-for-agent queue. Invoke-only; the fleet writes to a live repo and must never start unasked.
|
|
4
|
-
argument-hint: "[in-flight]"
|
|
4
|
+
argument-hint: "[in-flight] [cost|speed]"
|
|
5
5
|
disable-model-invocation: true
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
Run `next-ticket`, `review-and-fix`, `run-merge-bot` as one fleet. You are the
|
|
9
9
|
**controller**, in the main thread, never a member.
|
|
10
10
|
|
|
11
|
-
`$ARGUMENTS` = `[in-flight]`, optional
|
|
12
|
-
|
|
11
|
+
`$ARGUMENTS` = `[in-flight] [cost|speed]`, both optional: the In-flight bound,
|
|
12
|
+
a positive integer, default 5, and the Objective, default `cost`.
|
|
13
13
|
Merge bot is at most one, not configurable.
|
|
14
14
|
|
|
15
15
|
The war story behind a rule ending `See references/<file>` lives in that file; load
|
|
@@ -40,10 +40,10 @@ Nothing in this phase, or in any later one, asks the maintainer which tickets to
|
|
|
40
40
|
0. **Once per run, before anything else.** Run phase 0 from a Python eval
|
|
41
41
|
cell in this main checkout, without `--session`:
|
|
42
42
|
`subprocess.run([os.path.expanduser("~/.fleet/bin/fleet-run"),
|
|
43
|
-
"phase0.mjs", …], stderr=subprocess.STDOUT)`. Add `--in-flight <n>`
|
|
44
|
-
`$ARGUMENTS` gave
|
|
45
|
-
no `plugin/…` of its own, `--spend-dir <dir>`
|
|
46
|
-
|
|
43
|
+
"phase0.mjs", …], stderr=subprocess.STDOUT)`. Add `--in-flight <n>` and
|
|
44
|
+
`--objective <o>` as `$ARGUMENTS` gave them, `--plugin-checkout <dir>` when
|
|
45
|
+
this workspace tracks no `plugin/…` of its own, `--spend-dir <dir>` for the
|
|
46
|
+
spend panel, and `--workflow <name>` or `--declare-no-ci`
|
|
47
47
|
when the repository has no workflow named CI. Its steps and receipt: See
|
|
48
48
|
references/phase-0.md. Dispatch nothing before it exits 0. Exit 4 is a
|
|
49
49
|
step that needs you: act on each `NEED` line below, then run it again. Once
|
|
@@ -14,6 +14,13 @@ it — `~/.omp/agent/sessions/<encoded-cwd>`, which holds every session of the
|
|
|
14
14
|
checkout — names no session: phase 0 exits 2 naming the path and where it
|
|
15
15
|
came from, and writes no receipt.
|
|
16
16
|
|
|
17
|
+
`--objective cost|speed` is the run's Objective, which phase 0 records in its
|
|
18
|
+
receipt, `cost` when it is not given; the router routes every Pull of the run
|
|
19
|
+
by it. It is fixed for the run: a resumed receipt keeps the one it records —
|
|
20
|
+
`cost` when it records none — and a resume whose `--objective` names the
|
|
21
|
+
other exits 2 naming both, leaving the receipt as it was. A resumed receipt
|
|
22
|
+
recording anything other than `cost` or `speed` exits 3 naming the value.
|
|
23
|
+
|
|
17
24
|
Exit 3 is a step that failed: its line carries the refusal, so fix the cause
|
|
18
25
|
and run it again.
|
|
19
26
|
|
|
@@ -249,7 +249,7 @@ terminating in exactly one dispatch or an empty shortlist:
|
|
|
249
249
|
7. **Route, row, then dispatch** — `~/.fleet/bin/fleet-run ticket-router.mjs route
|
|
250
250
|
--session <session> --ticket <N> --arm <A|B> --impl-row <k>[ --chain-head] --issue
|
|
251
251
|
<scratch>/impl-<N>/issue.json --guard .fleet/cost-guard.json` prints
|
|
252
|
-
`POLICY=… CELL=… DRAW=… STRATUM=… REASON=…` (the router's arguments paragraph below the list
|
|
252
|
+
`POLICY=… CELL=… DRAW=… STRATUM=… OBJECTIVE=… REASON=…` (the router's arguments paragraph below the list
|
|
253
253
|
says what each argument is); then `ledger.mjs row <N> "impl-<N>[ · tier=<CELL>][ · route=<REASON>]"`,
|
|
254
254
|
`tier=<CELL>` exactly when `DRAW` is not `-`; then the dispatch at once: claim
|
|
255
255
|
what you are about to dispatch; dispatch what you have just claimed. Router
|
|
@@ -263,7 +263,9 @@ row 5k and every row after it carry no `tier=`), that row's number;
|
|
|
263
263
|
`--chain-head` marks a ticket another open ticket sequences after, which is
|
|
264
264
|
what rolls it. `--arm` is `A` for an even ticket number and `B` for an odd
|
|
265
265
|
one; a B Pull routes exactly as an A one until the table opens arm B.
|
|
266
|
-
`--session` is this run's own session directory. The
|
|
266
|
+
`--session` is this run's own session directory. The router routes by the
|
|
267
|
+
row set of the run's Objective, `OBJECTIVE`, which it reads off the phase-0
|
|
268
|
+
receipt. The printed line is the
|
|
267
269
|
whole routing decision — never re-derive `CELL` from the table, the guard or
|
|
268
270
|
this paragraph. A missing or unreadable `.fleet/cost-guard.json` routes the
|
|
269
271
|
default cell only (`REASON=guard-missing`), burn-in included, and `REASON`
|
|
@@ -429,7 +431,8 @@ controller's probe could not look.
|
|
|
429
431
|
rows in `.fleet/ledger.md` at Pull time; the Pull that creates row 5, 10, 15 …
|
|
430
432
|
is an Exploration Pull, and the router draws its cell: `k = 1 +
|
|
431
433
|
(sha256("<session>\t<ticket>")[0:8] mod K)` over `router-table.json`'s
|
|
432
|
-
`cells` minus the policy cell, in token order.
|
|
434
|
+
`cells` minus the policy cell, in token order. Under either Objective that
|
|
435
|
+
policy cell is the `cost` row set's, so the draw is the same under both. While the table's `burn_in` is
|
|
433
436
|
true every Pull draws, over every cell, the policy cell included. An
|
|
434
437
|
Exploration Pull records `tier=<cell>` in the row, and a replacement inherits the row's tier —
|
|
435
438
|
written at the Pull's row step, ahead of `ledger.mjs dispatch`, which reads it
|