Public: anyone can read this; the crew can change it.
A pure read says the red-batch rate: of the merge-queue gates finished in the last 6 h, how many went red #6432
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/6427-a-pure-read-says-the-red-batch-rate-of-t → main 1 commit fork
6 files
changed +255
−3
docs/FLOW.md +11
| @@ -46,6 +46,17 @@ terminal's Flow, and `rebases` in the MCP `flow` tool all read | ||
| 46 | 46 | ones still waiting for room, which have no run yet), the bring-up-to-date runs |
| 47 | 47 | and their checks in flight, and the runs the lane has room to start now. |
| 48 | 48 | |
| 49 | ## Red batches | |
| 50 | ||
| 51 | `src/flow/red_batches.ts` `readRedBatches(db, now, publicOnly)` says, of the | |
| 52 | merge-queue batches judged in the last 6 hours, how many went red: | |
| 53 | `{ finished, red, rate }`, `rate` null when none finished. A batch is one | |
| 54 | candidate the queue judged (`checks/queue.ts` calls `onBatch` beside its | |
| 55 | in-memory hourly count): a red batch's bisection prefixes and its head gated | |
| 56 | alone are its search, not batches; re-checks, cut-off gates and pull checks | |
| 57 | are not counted. Each verdict is a row of `queue_batch`, kept a week. The | |
| 58 | `publicOnly` rule is `readVitals`'s. Nothing shows it yet (#5930). | |
| 59 | ||
| 49 | 60 | ## Throughput |
| 50 | 61 | |
| 51 | 62 | `src/flow/throughput.ts` counts what each pipeline stage let out over the last |
src/checks/queue.ts +16 −2
| @@ -142,6 +142,12 @@ export interface QueueOptions { | ||
| 142 | 142 | * checks' reserve already kept back; none when not said. |
| 143 | 143 | */ |
| 144 | 144 | readonly room?: () => number; |
| 145 | /** | |
| 146 | * A batch was judged: called once per candidate verdict the batch rate | |
| 147 | * counts (search prefixes, re-checks and cut-off gates not), so it can be | |
| 148 | * kept beyond this process (#6427). | |
| 149 | */ | |
| 150 | readonly onBatch?: (repoId: string, red: boolean) => void; | |
| 145 | 151 | } |
| 146 | 152 | |
| 147 | 153 | /** |
| @@ -529,7 +535,7 @@ async function drain(line: Line, gate: GateCandidate, options: QueueOptions): Pr | ||
| 529 | 535 | ahead.shift(); |
| 530 | 536 | let left: Waiting[]; |
| 531 | 537 | try { |
| 532 | left = await settleHead({ line, gated, room }, head, verdict, ahead); | |
| 538 | left = await settleHead({ line, gated, room, onBatch: options.onBatch }, head, verdict, ahead); | |
| 533 | 539 | } catch (error) { |
| 534 | 540 | log.warn("merge queue round failed", { error: String(error) }); |
| 535 | 541 | for (const w of head.steps) w.settle(false); |
| @@ -690,7 +696,14 @@ async function settleHead(search: Search, head: Flight, verdict: LandVerdict, ah | ||
| 690 | 696 | } |
| 691 | 697 | candidate = { ...candidate, baseOid: tip }; |
| 692 | 698 | } |
| 693 | if (!head.recheck && !("interrupted" in verdict)) search.line.batches.push({ at: Date.now(), red: !verdict.lands }); | |
| 699 | if (!head.recheck && !("interrupted" in verdict)) { | |
| 700 | search.line.batches.push({ at: Date.now(), red: !verdict.lands }); | |
| 701 | try { | |
| 702 | search.onBatch?.(search.line.repoId, !verdict.lands); | |
| 703 | } catch (error) { | |
| 704 | log.warn("batch verdict not kept", { repo: search.line.repoId, error: String(error) }); | |
| 705 | } | |
| 706 | } | |
| 694 | 707 | if (verdict.lands) { |
| 695 | 708 | if (await land(candidate, head.steps)) return []; |
| 696 | 709 | return discard(ahead); |
| @@ -753,6 +766,7 @@ interface Search { | ||
| 753 | 766 | readonly line: Line; |
| 754 | 767 | readonly gated: Gated; |
| 755 | 768 | readonly room: Room; |
| 769 | readonly onBatch: ((repoId: string, red: boolean) => void) | undefined; | |
| 756 | 770 | } |
| 757 | 771 | |
| 758 | 772 | /** |
src/core/migrations/20261026174505-queue-batch.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | // Each merge-queue batch's verdict (#6427): one row per candidate the queue | |
| 2 | // judged, its bisection prefixes, re-checks and cut-off gates not counted | |
| 3 | // (`checks/queue.ts`), so the red-batch rate outlives the process. Rows past | |
| 4 | // a week are pruned by `keepBatch`. A new table. | |
| 5 | export default ` | |
| 6 | CREATE TABLE IF NOT EXISTS queue_batch ( | |
| 7 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 8 | repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE, | |
| 9 | red INTEGER NOT NULL CHECK (red IN (0, 1)), | |
| 10 | finished_at TEXT NOT NULL | |
| 11 | ); | |
| 12 | CREATE INDEX IF NOT EXISTS queue_batch_finished ON queue_batch (finished_at); | |
| 13 | `; |
src/crew/autonomy.ts +4 −1
| @@ -125,6 +125,7 @@ import { | ||
| 125 | 125 | import { crewOffInPreview } from "./preview.ts"; |
| 126 | 126 | import { approvedOnHead, finishApproved, finishFields } from "./finishing.ts"; |
| 127 | 127 | import { recheckRedApproved } from "./red_approved.ts"; |
| 128 | import { keepBatch } from "../flow/red_batches.ts"; | |
| 128 | 129 | import { readVitals, vitalsMisses, vitalsWords } from "../flow/vitals.ts"; |
| 129 | 130 | import { moveRequested, tallyRequested, tallyWords } from "./requested.ts"; |
| 130 | 131 | import { reviewersOf, sendToReview, sweepReviews } from "./review.ts"; |
| @@ -2018,12 +2019,14 @@ export function regateDue(red: Decision, now: number): boolean { | ||
| 2018 | 2019 | |
| 2019 | 2020 | /** |
| 2020 | 2021 | * How the merge queue may use the forge's check slots (#4053): its depth as |
| 2021 | * an admin set it, and the slots the checks service has spare. | |
| 2022 | * an admin set it, and the slots the checks service has spare. Each judged | |
| 2023 | * batch is kept for the red-batch rate (#6427). | |
| 2022 | 2024 | */ |
| 2023 | 2025 | function queueOptions(app: App): QueueOptions { |
| 2024 | 2026 | return { |
| 2025 | 2027 | depth: queueDepth(app.db), |
| 2026 | 2028 | room: () => checksFor(app)?.spareSlots?.() ?? 0, |
| 2029 | onBatch: (repoId, red) => keepBatch(app.db, repoId, red), | |
| 2027 | 2030 | }; |
| 2028 | 2031 | } |
| 2029 | 2032 |
src/flow/red_batches.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | /** | |
| 2 | * The red-batch rate (#6427, a flow vital of #5930): of the merge-queue | |
| 3 | * batches judged in the last 6 h, how many went red. A rising rate is the | |
| 4 | * first sign of a CI lockup. | |
| 5 | * | |
| 6 | * A batch is one candidate the queue judged (`checks/queue.ts` `onBatch`): | |
| 7 | * a red batch's bisection prefixes and its head gated alone are its search, | |
| 8 | * not batches of their own, and a pull check is never one. Forge-wide for | |
| 9 | * the owner, public repositories alone for an agent — the rule `readVitals` | |
| 10 | * keeps. Nothing reads it yet. | |
| 11 | */ | |
| 12 | ||
| 13 | import type { Database } from "bun:sqlite"; | |
| 14 | ||
| 15 | const HOUR_MS = 3_600_000; | |
| 16 | ||
| 17 | /** The window the rate is read over. */ | |
| 18 | export const RED_BATCH_WINDOW_MS = 6 * HOUR_MS; | |
| 19 | ||
| 20 | /** How long a verdict is kept: a week, well past the window. */ | |
| 21 | const KEEP_MS = 7 * 24 * HOUR_MS; | |
| 22 | ||
| 23 | export interface RedBatches { | |
| 24 | readonly finished: number; | |
| 25 | readonly red: number; | |
| 26 | /** red / finished; null when nothing finished. */ | |
| 27 | readonly rate: number | null; | |
| 28 | } | |
| 29 | ||
| 30 | /** Keep one batch's verdict, and let verdicts past a week go. */ | |
| 31 | export function keepBatch(db: Database, repoId: string, red: boolean, now = new Date()): void { | |
| 32 | db.query(`INSERT INTO queue_batch (repo_id, red, finished_at) VALUES (?1, ?2, ?3)`).run( | |
| 33 | repoId, | |
| 34 | red ? 1 : 0, | |
| 35 | now.toISOString(), | |
| 36 | ); | |
| 37 | db.query(`DELETE FROM queue_batch WHERE finished_at < ?1`).run( | |
| 38 | new Date(now.getTime() - KEEP_MS).toISOString(), | |
| 39 | ); | |
| 40 | } | |
| 41 | ||
| 42 | /** `publicOnly` counts public repositories alone, for a reader who must not learn what private ones hold. */ | |
| 43 | export function readRedBatches(db: Database, now = new Date(), publicOnly = false): RedBatches { | |
| 44 | const row = db | |
| 45 | .query<{ finished: number; red: number | null }, [string, string, number]>( | |
| 46 | `SELECT COUNT(*) AS finished, SUM(b.red) AS red | |
| 47 | FROM queue_batch b JOIN repo r ON r.id = b.repo_id | |
| 48 | WHERE (?3 = 0 OR r.visibility = 'public') AND b.finished_at >= ?1 AND b.finished_at <= ?2`, | |
| 49 | ) | |
| 50 | .get(new Date(now.getTime() - RED_BATCH_WINDOW_MS).toISOString(), now.toISOString(), publicOnly ? 1 : 0); | |
| 51 | const finished = row?.finished ?? 0; | |
| 52 | const red = row?.red ?? 0; | |
| 53 | return { finished, red, rate: finished === 0 ? null : red / finished }; | |
| 54 | } |
test/flow-red-batches.test.ts added +157
| @@ -0,0 +1,157 @@ | ||
| 1 | /** | |
| 2 | * The red-batch rate (#6427): of the merge-queue batches judged in the last | |
| 3 | * 6 h, how many went red. The read is checked on seeded rows; that a red | |
| 4 | * batch's bisection is one batch, and a pull check none, is checked through | |
| 5 | * the real queue with a stand-in gate that goes red on any `red-*` file. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { openPull } from "../src/actions/pulls.ts"; | |
| 13 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { provideChecksForTest, type ChecksHandle } from "../src/checks/service.ts"; | |
| 15 | import { getPull, type Pull } from "../src/core/pulls.ts"; | |
| 16 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 17 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 18 | import { createUser, type User } from "../src/core/users.ts"; | |
| 19 | import { sweepGreen } from "../src/crew/autonomy.ts"; | |
| 20 | import { keepBatch, readRedBatches } from "../src/flow/red_batches.ts"; | |
| 21 | import { repoDir } from "../src/git/repos.ts"; | |
| 22 | ||
| 23 | const NOW = new Date("2026-10-26T12:00:00.000Z"); | |
| 24 | const ago = (h: number) => new Date(NOW.getTime() - h * 3_600_000); | |
| 25 | ||
| 26 | describe("the red-batch rate", () => { | |
| 27 | let dataDir: string; | |
| 28 | let app: App; | |
| 29 | let alice: User; | |
| 30 | let gates = 0; | |
| 31 | ||
| 32 | const git = (repo: Repo, ...args: string[]) => | |
| 33 | Bun.spawnSync(["git", ...args], { cwd: repoDir(app.reposRoot, repo.ownerName, repo.name) }) | |
| 34 | .stdout.toString() | |
| 35 | .trim(); | |
| 36 | ||
| 37 | beforeAll(async () => { | |
| 38 | dataDir = await mkdtemp(join(tmpdir(), "forge-red-batches-")); | |
| 39 | app = createApp({ dataDir, port: 0 }); | |
| 40 | const a = await createUser(app.db, { | |
| 41 | origin: "seed", | |
| 42 | trusted: true, | |
| 43 | username: "alice", | |
| 44 | email: "[email protected]", | |
| 45 | password: "a-good-passphrase", | |
| 46 | adminIfFirst: true, | |
| 47 | }); | |
| 48 | if (!a.ok) throw new Error(a.error); | |
| 49 | alice = a.value; | |
| 50 | provideChecksForTest(app, { | |
| 51 | gate: async (repo: Repo, _pull: Pull, commitOid: string) => { | |
| 52 | gates++; | |
| 53 | const files = git(repo, "ls-tree", "-r", "--name-only", commitOid).split("\n"); | |
| 54 | const ok = !files.some((f) => f.startsWith("red-")); | |
| 55 | return ok | |
| 56 | ? { ok, error: "", lines: [] } | |
| 57 | : { ok, error: "red", lines: ["test/red.test.ts:", "(fail) goes red [1.00ms]"] }; | |
| 58 | }, | |
| 59 | } as unknown as ChecksHandle); | |
| 60 | }); | |
| 61 | ||
| 62 | afterAll(async () => { | |
| 63 | await app.close?.(); | |
| 64 | await rm(dataDir, { recursive: true, force: true }); | |
| 65 | }); | |
| 66 | ||
| 67 | async function repoNamed(name: string, visibility: "public" | "private", autonomous = false): Promise<Repo> { | |
| 68 | const made = await createRepo(app, alice, { name, visibility }); | |
| 69 | if (!made.ok) throw new Error(String(made.error)); | |
| 70 | if (!autonomous) return made.value; | |
| 71 | const seeded = await commitToBranch(app, alice, made.value, { | |
| 72 | message: "Start", | |
| 73 | files: [ | |
| 74 | { path: "README.md", content: `# ${name}\n` }, | |
| 75 | { path: "forge.json", content: JSON.stringify({ check: { run: "true", fast: "true", full: "true" } }) }, | |
| 76 | ], | |
| 77 | }); | |
| 78 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 79 | app.db | |
| 80 | .query("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2 WHERE id = ?1") | |
| 81 | .run(made.value.id, alice.id); | |
| 82 | return getRepoById(app.db, made.value.id)!; | |
| 83 | } | |
| 84 | ||
| 85 | async function greenPull(repo: Repo, path: string): Promise<Pull> { | |
| 86 | const pushed = await commitToBranch(app, alice, repo, { | |
| 87 | branch: `feature/${path}`, | |
| 88 | base: "main", | |
| 89 | message: path, | |
| 90 | files: [{ path, content: `export const n = "${path}";\n` }], | |
| 91 | }); | |
| 92 | if (!pushed.ok) throw new Error(JSON.stringify(pushed.error)); | |
| 93 | const opened = await openPull(app, alice, repo, { title: path, baseBranch: "main", headBranch: `feature/${path}` }); | |
| 94 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 95 | const pull = getPull(app.db, repo.id, opened.value.number)!; | |
| 96 | const check = createRun(app.db, { | |
| 97 | repoId: repo.id, | |
| 98 | kind: "check", | |
| 99 | issueId: pull.id, | |
| 100 | env: `pull-${pull.number}`, | |
| 101 | commitOid: pull.headOid, | |
| 102 | reason: "pull", | |
| 103 | }); | |
| 104 | claimRun(app.db, check.id); | |
| 105 | finishRun(app.db, check.id, { state: "ok", summary: "check passed" }); | |
| 106 | return pull; | |
| 107 | } | |
| 108 | ||
| 109 | const clear = () => app.db.query("DELETE FROM queue_batch").run(); | |
| 110 | ||
| 111 | test("nothing finished: no rate, not a zero one", () => { | |
| 112 | clear(); | |
| 113 | expect(readRedBatches(app.db, NOW)).toEqual({ finished: 0, red: 0, rate: null }); | |
| 114 | }); | |
| 115 | ||
| 116 | test("four batches with one red give 0.25; one older than 6 h is left out", async () => { | |
| 117 | clear(); | |
| 118 | const repo = await repoNamed("seeded", "public"); | |
| 119 | keepBatch(app.db, repo.id, false, ago(5.9)); | |
| 120 | keepBatch(app.db, repo.id, true, ago(3)); | |
| 121 | keepBatch(app.db, repo.id, false, ago(1)); | |
| 122 | keepBatch(app.db, repo.id, false, ago(0)); | |
| 123 | keepBatch(app.db, repo.id, true, ago(6.1)); // past the window | |
| 124 | expect(readRedBatches(app.db, NOW)).toEqual({ finished: 4, red: 1, rate: 0.25 }); | |
| 125 | }); | |
| 126 | ||
| 127 | test("an agent's reading counts public repositories alone", async () => { | |
| 128 | clear(); | |
| 129 | const open = await repoNamed("open-q", "public"); | |
| 130 | const shut = await repoNamed("shut-q", "private"); | |
| 131 | keepBatch(app.db, open.id, false, ago(1)); | |
| 132 | keepBatch(app.db, shut.id, true, ago(1)); | |
| 133 | expect(readRedBatches(app.db, NOW)).toEqual({ finished: 2, red: 1, rate: 0.5 }); | |
| 134 | expect(readRedBatches(app.db, NOW, true)).toEqual({ finished: 1, red: 0, rate: 0 }); | |
| 135 | }); | |
| 136 | ||
| 137 | test("a verdict past a week is let go as the next is kept", async () => { | |
| 138 | clear(); | |
| 139 | const repo = await repoNamed("pruned", "public"); | |
| 140 | keepBatch(app.db, repo.id, true, new Date(NOW.getTime() - 8 * 24 * 3_600_000)); | |
| 141 | keepBatch(app.db, repo.id, false, NOW); | |
| 142 | expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM queue_batch").get()?.n).toBe(1); | |
| 143 | }); | |
| 144 | ||
| 145 | test("a red batch and its bisection are one red batch; a pull check is none", async () => { | |
| 146 | clear(); | |
| 147 | const repo = await repoNamed("bisected", "public", true); | |
| 148 | for (const name of ["a.ts", "b.ts", "c.ts", "red-d.ts"]) await greenPull(repo, name); | |
| 149 | // The four pull checks above finished, and none is a batch. | |
| 150 | expect(readRedBatches(app.db).finished).toBe(0); | |
| 151 | gates = 0; | |
| 152 | expect(await sweepGreen(app, repo)).toBe(3); | |
| 153 | // The whole candidate went red and its search gated more: one batch all the same. | |
| 154 | expect(gates).toBeGreaterThan(1); | |
| 155 | expect(readRedBatches(app.db)).toEqual({ finished: 1, red: 1, rate: 1 }); | |
| 156 | }); | |
| 157 | }); |
new above
offline