Public: anyone can read this; the crew can change it.

Batch doctor: on a red merge batch, find the culprit, tell load reds from real ones, hold with the exact failing test #11254

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7555-batch-doctor-on-a-red-merge-batch-find-t → main 10 commits fork 3 threads · 3 unresolved

  • Merged. scout/7555-batch-doctor-on-a-red-merge-batch-find-t was merged into main as cb97465.

Review

Sign in to review this change.
18 files changed +722 −3
docs/features/batch-doctor.md added +8
@@ -0,0 +1,8 @@
1 # Batch doctor (#7555)
2
3 When the merge queue's re-gate hands a pull back red (the re-gate carries it alone, so a red on top of other pulls is the queue's own search, #4999), `src/checks/doctor.ts` classes the red (`doctor_classify.ts`) and acts, in `crew/autonomy.ts`'s `handBack`:
4
5 - **functional, alone on its merge**: the pull is held "until green" (out of the line), commented with the test, file, error line and `bun test ./file` to reproduce, and assigned back to its builder. A new head that passes its own check lifts the hold (`releaseOnGreen`). A red is held only when it reproduces on the queue's re-gate (the same head and base red again), so a flaky red still lands (#3155). A hold already on the pull (a person's) is kept, never overwritten.
6 - **budget / infra**: recorded as `recheck`, not held. the queue re-gates a red pull alone later (#3155).
7
8 Every act is a `maintenance_run` row (`duty = batch-doctor`): batch (`base:head`), culprit pull, class, action, probe (the first error line); the newest 200 per repository are kept (`RUNS_KEPT`). A sealed pull's number, batch and probe are told by no door. Read them on the flow panel (Batch doctor card), in the terminal Flow dialog, or as `doctor` in MCP `queue_gates` (all `doctorRuns`). A doctor's hold reason begins `batch doctor:` (`HOLD_PREFIX`, `isDoctorHold`). The infra class matches errno codes, `no space left`, `disk quota` and `tar:` lines only.
src/checks/doctor.ts added +184
@@ -0,0 +1,184 @@
1 /**
2 * The batch doctor (#7555): a merge-queue gate found one pull red. It classes
3 * the red (`doctor_classify.ts`), and only a functional red of a pull alone
4 * on its merge is held — out of the line, with a comment naming the exact
5 * test, its file, the error line and the command to reproduce, and the pull
6 * handed back to its builder. The hold is "until green": the first check
7 * that passes on a new head lets it go (`actions/holds.ts` `releaseOnGreen`).
8 * A hold already on the pull — a person's above all — is never overwritten.
9 * Budget and infra reds are recorded and left to the queue's own re-gate.
10 * It is called only on the queue's re-gate, which carries the pull alone.
11 * Every act is one `maintenance_run` row; the newest `RUNS_KEPT` per
12 * repository are kept.
13 */
14
15 import type { Database } from "bun:sqlite";
16 import type { App } from "../app.ts";
17 import { builderOf } from "../actions/give_back.ts";
18 import { addComment } from "../actions/issues.ts";
19 import { getHold, setHold } from "../core/holds.ts";
20 import { getIssueById, setAssignee } from "../core/issues.ts";
21 import type { Pull } from "../core/pulls.ts";
22 import type { Repo } from "../core/repos.ts";
23 import type { User } from "../core/users.ts";
24 import { classify, reproduce, type Diagnosis } from "./doctor_classify.ts";
25 import type { Red } from "./queue.ts";
26
27 export const DUTY = "batch-doctor";
28
29 /** What every doctor hold's reason begins with: how a person and `releaseOnGreen` tell it from the autonomy user's own. */
30 export const HOLD_PREFIX = "batch doctor:";
31
32 export const isDoctorHold = (why: string): boolean => why.startsWith(HOLD_PREFIX);
33
34 export type DoctorAction = "held" | "recheck" | "kept";
35
36 /** The most `maintenance_run` rows the doctor keeps per repository; older ones are pruned on each write. */
37 export const RUNS_KEPT = 200;
38
39 export interface DoctorRun {
40 /** `base:head`, blank for a sealed pull. */
41 readonly batch: string;
42 /** The pull's number, null for a sealed pull. */
43 readonly culprit: number | null;
44 readonly class: string;
45 readonly action: string;
46 readonly probe: string;
47 readonly at: string;
48 /** The culprit is a sealed pull: its number, batch and probe are not told. */
49 readonly sealed: boolean;
50 }
51
52 /** Text a pull's author controls, made safe inside a markdown span: no backtick, `@`, newline or control character. */
53 export function plain(text: string): string {
54 return text.replace(/[`@\u0000-\u001f\u007f]+/g, " ").replace(/ {2,}/g, " ").trim();
55 }
56
57 function cleaned(d: Diagnosis): Diagnosis {
58 return {
59 ...d,
60 probe: plain(d.probe),
61 ...(d.test === undefined
62 ? {}
63 : { test: { ...d.test, name: plain(d.test.name), ...(d.test.file === undefined ? {} : { file: plain(d.test.file) }) } }),
64 };
outdated

Untrusted test name, file and error text go into backtick spans unescaped. Sanitise them (backticks, newlines, @), same for whyLine and the stored hold.why.

65 }
66
67 /** Why a held pull says it is held: one line, the test first. */
68 function whyLine(d: Diagnosis): string {
69 const name = d.test?.name ?? "the check";
70 return `${HOLD_PREFIX} ${name} fails alone`;
71 }
72
73 /** The comment a held pull gets: test, file, error line, command, what lifts it. */
74 export function holdComment(d: Diagnosis, builder: User | undefined): string {
75 const lines = ["The batch doctor held this pull: it is red on its own merge with main, in a functional test."];
76 if (d.test !== undefined) {
77 lines.push("", `- test: \`${d.test.name}\``);
78 if (d.test.file !== undefined) lines.push(`- file: \`${d.test.file}\``);
79 }
80 lines.push(`- error: \`${d.probe}\``);
81 const cmd = reproduce(d.test);
82 if (cmd !== undefined) lines.push(`- reproduce: \`${cmd}\``);
83 lines.push(
84 "",
85 `Rebase on main and fix it, then push: the hold lifts when the new head passes its own check.${builder ? ` Back to @${builder.username}.` : ""}`,
86 );
87 return lines.join("\n");
88 }
89
90 function record(app: App, repo: Repo, pull: Pull, batch: string, d: Diagnosis, action: DoctorAction): void {
91 app.db
92 .query(
93 `INSERT INTO maintenance_run (duty, started, finished, findings, actions, probes, outcome, repo_id, issue_id, batch, culprit, class, action, probe)
94 VALUES (?1, ?2, ?2, ?3, ?4, ?5, 'ok', ?6, ?7, ?8, ?9, ?10, ?11, ?12)`,
95 )
96 .run(
97 DUTY,
98 new Date().toISOString(),
99 JSON.stringify([`${d.class} red on pull #${pull.number}`]),
100 JSON.stringify([action]),
101 JSON.stringify([d.probe]),
102 repo.id,
103 pull.id,
104 batch,
105 pull.number,
106 d.class,
107 action,
108 d.probe,
109 );
110 app.db
111 .query(
112 `DELETE FROM maintenance_run WHERE duty = ?1 AND repo_id = ?2 AND id NOT IN
113 (SELECT id FROM maintenance_run WHERE duty = ?1 AND repo_id = ?2 ORDER BY id DESC LIMIT ?3)`,
114 )
115 .run(DUTY, repo.id, RUNS_KEPT);
116 }
117
118 /** Treat one red the queue's re-gate handed back, the pull alone on its merge. `by` acts. */
119 export function treatRed(app: App, repo: Repo, by: User, pull: Pull, red: Red, baseOid: string): DoctorAction {
120 const batch = `${baseOid.slice(0, 12)}:${pull.headOid.slice(0, 12)}`;
121 const d = cleaned(classify(red.reason, red.tests));
122 let action: DoctorAction;
123 if (d.class !== "functional") action = "recheck";
124 else if (getHold(app.db, pull.id) !== undefined) action = "kept";
125 else {
126 action = "held";
127 const issue = getIssueById(app.db, pull.id);
128 const hold = setHold(app.db, pull.id, by.id, whyLine(d).slice(0, 200), "green");
129 app.bus.append({
130 kind: "pull.held",
131 repoId: repo.id,
132 actorId: by.id,
133 payload: { number: pull.number, why: hold.why },
134 });
135 if (issue !== undefined) {
136 const builder = builderOf(app.db, repo, issue);
137 addComment(app, by, repo, issue, holdComment(d, builder));
138 if (builder && setAssignee(app.db, issue.id, builder.id))
139 app.bus.append({
140 kind: "pull.assigned",
141 repoId: repo.id,
142 actorId: by.id,
143 payload: { number: pull.number, assignee: builder.username, agent: true },
144 });
145 }
146 }
147 record(app, repo, pull, batch, d, action);
148 return action;
149 }
150
151 /** What a reader is told of one run: a sealed pull's number, batch and probe are blanked. */
152 const RUN_COLUMNS = `CASE WHEN i.sealed = 1 THEN '' ELSE m.batch END AS batch,
153 CASE WHEN i.sealed = 1 THEN NULL ELSE m.culprit END AS culprit, m.class, m.action,
154 CASE WHEN i.sealed = 1 THEN '' ELSE m.probe END AS probe, m.started AS at, COALESCE(i.sealed, 0) AS sealed`;
155
156 type RunRow = Omit<DoctorRun, "sealed"> & { sealed: number };
157 const toRun = ({ sealed, ...r }: RunRow): DoctorRun => ({ ...r, sealed: sealed === 1 });
158
159 /** The doctor's last runs on a repository, newest first; a sealed pull is not named. */
160 export function doctorRuns(db: Database, repoId: string, limit = 20): DoctorRun[] {
161 return db
162 .query<RunRow, [string, string, number]>(
163 `SELECT ${RUN_COLUMNS}
164 FROM maintenance_run m LEFT JOIN issue i ON i.id = m.issue_id
165 WHERE m.duty = ?1 AND m.repo_id = ?2 ORDER BY m.id DESC LIMIT ?3`,
166 )
167 .all(DUTY, repoId, limit)
168 .map(toRun);
169 }
170
171 /** The doctor's newest runs across repositories, in one read: every repository for `all`, public ones otherwise. */
172 export function recentDoctorRuns(db: Database, all: boolean, limit: number): { repo: string; run: DoctorRun }[] {
173 return db
174 .query<RunRow & { repo: string }, [string, number]>(
175 `SELECT ${RUN_COLUMNS}, u.username || '/' || r.name AS repo
176 FROM maintenance_run m
177 JOIN repo r ON r.id = m.repo_id JOIN user u ON u.id = r.owner_id
178 LEFT JOIN issue i ON i.id = m.issue_id
179 WHERE m.duty = ?1${all ? "" : " AND r.visibility = 'public'"}
180 ORDER BY m.id DESC LIMIT ?2`,
181 )
182 .all(DUTY, limit)
183 .map(({ repo, ...r }) => ({ repo, run: toRun(r) }));
184 }
src/checks/doctor_classify.ts added +59
@@ -0,0 +1,59 @@
1 /**
2 * The batch doctor's first question about a red (#7555): was it the load,
3 * the machine, or the code? Read off the verdict the gate already carries
4 * (`reason`, `tests`), never off a log.
5 *
6 * - `budget`: every failing test threw `expectBudget` — the gate's own load
7 * (the queue re-gates a red pull alone later); not held.
8 * - `infra`: the gate stopped before tests, or a test died on the machine
9 * (extract, ENOENT, tarball, disk); not held.
10 * - `functional`: a test the code made fail; held when alone.
11 *
12 * Plain data in, plain data out: no I/O.
13 */
14
15 import type { GateReason } from "./gate_reason.ts";
16 import type { FailedTest, GateTests } from "./gate_tests.ts";
17
18 /** What a test's `expectBudget` throws: the mark of a timing red, not a logic one. */
19 const BUDGET_MARK = "over budget (expectBudget)";
20
21 export type RedClass = "budget" | "infra" | "functional";
22
23 export interface Diagnosis {
24 readonly class: RedClass;
25 /** The first failing test, when the gate named one. */
26 readonly test?: FailedTest;
27 /** The line the class went on: the first error line, else the reason. */
28 readonly probe: string;
29 }
30
31 // Specific shapes only: a bare word like `extract` is as likely a function under test.
32 const INFRA = /\b(ENOENT|ENOSPC|EMFILE|ETIMEDOUT|ECONNRESET)\b|no space left|disk quota|(^|\s)tar: /i;
33 const PROBE_MAX = 300;
34
35 const clip = (s: string): string => (s.length > PROBE_MAX ? `${s.slice(0, PROBE_MAX - 1)}…` : s);
36
37 /** Class a red from its reason and the tests it named. */
38 export function classify(reason: GateReason | undefined, tests: GateTests | undefined): Diagnosis {
39 if (reason !== undefined && (reason.kind === "install" || reason.kind === "setup" || reason.kind === "timeout"))
40 return { class: "infra", probe: reason.kind };
41 const failing = tests?.failing ?? [];
42 const first = failing[0];
43 const probe = clip(first?.errors[0] ?? (reason?.kind ?? "check"));
44 // No test named: nothing to hold it by, so it is the queue's to re-gate.
45 if (first === undefined) return { class: "infra", probe };
46 const errors = (t: FailedTest): string => t.errors.join("\n");
47 if (failing.every((t) => t.errors[0]?.includes(BUDGET_MARK))) return { class: "budget", test: first, probe };
48 if (failing.every((t) => INFRA.test(errors(t)))) return { class: "infra", test: first, probe };
49 return { class: "functional", test: first, probe };
50 }
51
52 const SAFE = /^[\w@./+-]{1,200}$/;
53
54 /** The command that reproduces a failing test, or undefined when its file is not a plain path. */
55 export function reproduce(test: FailedTest | undefined): string | undefined {
56 if (test?.file === undefined || !SAFE.test(test.file)) return undefined;
57 const path = test.file.startsWith("./") || test.file.startsWith("/") ? test.file : `./${test.file}`;
58 return `bun test ${path}`;
59 }
src/core/migration-pins/20261111110000-maintenance-run-doctor.pin added +1
@@ -0,0 +1,1 @@
1 e88ce9739bfc9660667668ab3ca760a26d48aa98064a8a4cb79908e44b5d9c77
src/core/migrations/20261111110000-maintenance-run-doctor.ts added +13
@@ -0,0 +1,13 @@
1 // The batch doctor's acts (#7555) are `maintenance_run` rows (duty `batch-doctor`):
2 // the repository and pull they concern, the batch, the culprit pull, how the red
3 // was classed, what was done and the test output line it went on. Additive: columns only.
4 export default `
5 ALTER TABLE maintenance_run ADD COLUMN repo_id TEXT;
6 ALTER TABLE maintenance_run ADD COLUMN issue_id TEXT;

No cascade on these new columns: rows outlive their repository. Delete a repository's batch-doctor rows when the repository is deleted.

7 ALTER TABLE maintenance_run ADD COLUMN batch TEXT NOT NULL DEFAULT '';
8 ALTER TABLE maintenance_run ADD COLUMN culprit INTEGER;
9 ALTER TABLE maintenance_run ADD COLUMN class TEXT NOT NULL DEFAULT '';
10 ALTER TABLE maintenance_run ADD COLUMN action TEXT NOT NULL DEFAULT '';
11 ALTER TABLE maintenance_run ADD COLUMN probe TEXT NOT NULL DEFAULT '';
12 CREATE INDEX maintenance_run_repo ON maintenance_run (duty, repo_id, id);
13 `;
src/core/repos.ts +2
@@ -1050,6 +1050,8 @@ export async function deleteRepo(
10501050 // still reported.
10511051 const removed = await deleteRepoDir(dir);
10521052 if (!removed.ok && present) return err("delete_failed");
1053 // The doctor's rows carry `repo_id` without a foreign key (added columns on `maintenance_run`).
1054 db.query("DELETE FROM maintenance_run WHERE duty = 'batch-doctor' AND repo_id = ?1").run(repo.id);
10531055 db.query("DELETE FROM repo WHERE id = ?1").run(repo.id);
10541056 forgetRepoCursors(db, repo.id);
10551057 forgetListings(db);
src/crew/autonomy.ts +3
@@ -12,6 +12,7 @@
1212 * repository's origin.
1313 */
1414
15 import { treatRed } from "../checks/doctor.ts";
1516 import { tickBets } from "../actions/bets.ts";
1617 import { betsInUse } from "../core/bets.ts";
1718 import { onlyListDocs, resolveListConflicts } from "../actions/list-conflicts.ts";
@@ -2196,6 +2197,8 @@ async function admit(
21962197 });
21972198 addLearned(app.config.dataDir, pull.id, red.learn);
21982199 if (!again) addComment(app, by, repo, getIssueById(app.db, pull.id) ?? pull, clashSaid(red.comment, together));
2200 // A red that reproduces on the re-gate is the code's: only then is it the doctor's to hold.
2201 if (again) treatRed(app, repo, by, pull, red, baseOid);
21992202 },
22002203 });
22012204 }
src/http/mcp/tools/queue_gates.ts +3 −2
@@ -1,9 +1,10 @@
1 import { doctorRuns } from "../../../checks/doctor.ts";
12 import { recentGates } from "../../../checks/gate_result.ts";
23 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
34
45 export const tool: Tool = {
56 name: "queue_gates",
6 doc: "`{ repo, limit? }`: the merge queue's recent gates, newest first (`limit` 1–100, default 20), as `GET …/queue/gates` answers: each gate's pulls, state, reason, counts and failing tests by name with their first error lines, a test red in two gates that share no pull flagged `flaky`",
7 doc: "`{ repo, limit? }`: the merge queue's recent gates, newest first (`limit` 1–100, default 20), as `GET …/queue/gates` answers: each gate's pulls, state, reason, counts and failing tests by name with their first error lines, a test red in two gates that share no pull flagged `flaky`; `doctor`: the batch doctor's last acts on the repository (`culprit`, `class`, `action` held/recheck/kept, `probe`; a sealed pull's `culprit` is null and its `batch` and `probe` blank)",
78 description: "Recent merge-queue gates: pulls, verdicts, failing tests.",
89 inputSchema: {
910 type: "object",
@@ -22,6 +23,6 @@ export const tool: Tool = {
2223 return bad("invalid", "`limit` is a whole number from 1 to 100");
2324 const gates = recentGates(ctx.app.db, repo.id, limit);
2425 const red = gates.filter((g) => g.state === "red").length;
25 return good({ says: `${gates.length} gate(s), ${red} red.`, gates });
26 return good({ says: `${gates.length} gate(s), ${red} red.`, gates, doctor: doctorRuns(ctx.app.db, repo.id, limit) });
2627 },
2728 };
src/tui/screens/flow.ts +13
@@ -21,6 +21,7 @@
2121 */
2222
2323 import type { Database } from "bun:sqlite";
24 import { doctorRuns, type DoctorRun } from "../../checks/doctor.ts";
2425 import { recentGates, type KeptResult } from "../../checks/gate_result.ts";
2526 import { reportLine } from "../../checks/report.ts";
2627 import { HOLDS } from "../../crew/held.ts";
@@ -133,6 +134,7 @@ export function flowLines(
133134 return [
134135 `${pad(truncate(q.repo, 15), 15)} ${pad(String(q.queued), 6, "right")} ${pad(String(q.gating), 6, "right")} ${pad(String(q.gates), 5, "right")} ${pad(String(q.landedHour), 9, "right")} ${pad(q.approvedToLanded === null ? "—" : String(q.approvedToLanded), 12, "right")}`,
135136 ...gateLines(id === undefined ? [] : recentGates(db, id, GATES_SHOWN), now.getTime(), inner),
137 ...doctorLines(id === undefined ? [] : doctorRuns(db, id, DOCTOR_SHOWN), now.getTime(), inner),
136138 ];
137139 })),
138140 ...(red.length === 0
@@ -216,6 +218,17 @@ export function gateLines(gates: readonly KeptResult[], now: number, inner: numb
216218 });
217219 }
218220
221 /** The doctor's acts shown under a repository's gates. */
222 const DOCTOR_SHOWN = 3;
223
224 /** One indented line per batch-doctor act (#7555), newest first: `doctor #14 held · functional · how long ago · the error line`; none when it has not acted. */
225 export function doctorLines(runs: readonly DoctorRun[], now: number, inner: number): string[] {
226 return runs.map((r) => {
227 const pull = r.sealed || r.culprit === null ? "a sealed pull" : `#${r.culprit}`;
228 return truncate(` doctor ${pull} ${r.action} · ${r.class} · ${ago(Date.parse(r.at), now)}${r.probe === "" ? "" : ` · ${r.probe}`}`, inner);
229 });
230 }
231
219232 /** Idle pulls shown before `+N more`. */
220233 const IDLE_SHOWN = 5;
221234
src/web/pages/boards-doctor.ts added +33
@@ -0,0 +1,33 @@
1 /**
2 * The batch doctor's last acts (#7555), for the flow panel: the newest ten
3 * across the repositories the reader may see, one row each with when, the
4 * culprit pull, what the doctor did and how it classed the red. A sealed
5 * pull is named only as `a sealed pull`, with no probe; one read
6 * (`recentDoctorRuns`).
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { recentDoctorRuns, type DoctorRun } from "../../checks/doctor.ts";
11 import { span } from "../../flow/pipeline.ts";
12 import { html, type Html } from "../html.ts";
13 import { badge, card, row } from "../ui.ts";
14
15 /** The most runs the card lists. */
16 export const DOCTOR_LISTED = 10;
17
18 function runRow(repo: string, run: DoctorRun, now: number): Html {
19 const tone = run.action === "held" ? "danger" : "default";
20 return row({
21 data: { doctor: run.action },
22 title: html`${run.sealed || run.culprit === null ? html`<span class="muted">a sealed pull</span>` : html`<a href="/${repo}/pulls/${String(run.culprit)}">#${String(run.culprit)}</a>`} ${badge(run.action, { tone })}`,
23 meta: html`${span(Math.max(0, now - Date.parse(run.at)))} ago · ${repo} · ${run.class}`,
24 ...(run.probe === "" ? {} : { facts: [{ label: "error", value: html`<span class="break">${run.probe}</span>` }] }),
25 });
26 }
27
outdated

Principle 1: say in one plain sentence what this card is for. Principle 7: the dotted meta line mixes repo, class and probe, so give the probe its own row.

28 /** The "Batch doctor" card: nothing until the doctor has treated a red (an empty card is one more note on a page held to a count). */
29 export function doctorCard(db: Database, owner: boolean, now: number): Html {
30 const top = recentDoctorRuns(db, owner, DOCTOR_LISTED);
31 if (top.length === 0) return html``;
32 return card(html`<h2>Batch doctor</h2><p class="muted">When a merge gate finds a pull red on its own, the doctor names the failing test and holds the pull until it passes.</p><ul class="list">${top.map((l) => runRow(l.repo, l.run, now))}</ul>`, { id: "doctor" });
33 }
src/web/pages/flow-panel.ts +2
@@ -51,6 +51,7 @@ import { readHeld } from "../../flow/held.ts";
5151 import { readVerified, verifiedSays, type VerifiedRow } from "../../flow/verified.ts";
5252 import type { App } from "../../app.ts";
5353 import { html, type Html } from "../html.ts";
54 import { doctorCard } from "./boards-doctor.ts";
5455 import { keeperRow } from "../studio/keeper_row.ts";
5556 import { gatesSection } from "./boards-gates.ts";
5657 import { heldCard } from "./boards-held.ts";
@@ -233,6 +234,7 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
233234 ${heldCard(readHeld(app, reader.kind === "owner", now))}
234235 ${queueCard(queuesFor(app, reader, new Date(now), { batches: true }))}
235236 ${gatesSection(app.db, reader.kind === "owner", now)}
237 ${doctorCard(app.db, reader.kind === "owner", now)}
236238 ${url.searchParams.get("idle") === "all" && idle.length > 0 ? idleList(idle) : null}
237239 ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`;
238240 }
test/doctor-classify.test.ts added +51
@@ -0,0 +1,51 @@
1 import { describe, expect, test } from "bun:test";
2 import { classify, reproduce } from "../src/checks/doctor_classify.ts";
3 import { redFor } from "./helpers/doctor.ts";
4
5 const of = (error: string, reason?: Parameters<typeof redFor>[3]) => {
6 const red = redFor("t", "test/a.test.ts", error, reason);
7 return classify(red.reason, red.tests);
8 };
9
10 describe("the batch doctor classes a red", () => {
11 test("every test over its budget is a budget red", () => {
12 const d = of("error: over budget (expectBudget): GET / took 90ms, budget 50ms");
13 expect(d.class).toBe("budget");
14 expect(d.probe).toContain("over budget");
15 });
16
17 test("ENOENT, tarball and disk errors are infra", () => {
18 expect(of("error: ENOENT: no such file or directory, open 'x'").class).toBe("infra");
19 expect(of("error: tar: extract failed: unexpected end of tarball").class).toBe("infra");
20 expect(of("error: ENOSPC: no space left on device").class).toBe("infra");
21 });
22
23 test("a functional error that merely says extract or tarball is not infra", () => {
24 expect(of("error: expected extract() to return 3").class).toBe("functional");
25 expect(of("error: tarball is not a function").class).toBe("functional");
26 });
27
28 test("a gate that stopped before its tests is infra", () => {
29 expect(of("", { kind: "install" }).class).toBe("infra");
30 expect(of("", { kind: "timeout" }).class).toBe("infra");
31 });
32
33 test("anything else is functional, with the test and its error line as the probe", () => {
34 const d = of("error: expect(received).toBe(expected)");
35 expect(d.class).toBe("functional");
36 expect(d.test?.name).toBe("t");
37 expect(d.probe).toBe("error: expect(received).toBe(expected)");
38 });
39
40 test("a mix of budget and functional reds is functional", () => {
41 const red = redFor("a", "test/a.test.ts", "error: over budget (expectBudget): slow");
42 const tests = { ...red.tests!, failed: 2, failing: [...red.tests!.failing, { name: "b", file: "test/b.test.ts", errors: ["error: boom"] }] };
43 expect(classify(red.reason, tests).class).toBe("functional");
44 });
45
46 test("the reproduce command refuses a file that is not a plain path", () => {
47 expect(reproduce({ name: "t", file: "test/a.test.ts", errors: [] })).toBe("bun test ./test/a.test.ts");
48 expect(reproduce({ name: "t", file: "a; rm -rf /", errors: [] })).toBeUndefined();
49 expect(reproduce(undefined)).toBeUndefined();
50 });
51 });
test/doctor-culprit.test.ts added +80
@@ -0,0 +1,80 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { deleteRepo } from "../src/actions/repos.ts";
3 import { doctorRuns, HOLD_PREFIX, isDoctorHold, RUNS_KEPT, treatRed } from "../src/checks/doctor.ts";
4 import { getHold } from "../src/core/holds.ts";
5 import { listComments } from "../src/core/issues.ts";
6 import { doctorWorld, redFor, type Doctor } from "./helpers/doctor.ts";
7
8 describe("the batch doctor and a red culprit", () => {
9 let w: Doctor;
10 beforeAll(async () => {
11 w = await doctorWorld("culprit");
12 });
13 afterAll(() => w.close());
14
15 test("a functional red alone is held with a comment naming test, file, error and command; the batch is recorded", async () => {
16 const pull = await w.pull("bad.ts");
17 const red = redFor("adds two numbers", "test/add.test.ts", "error: expect(received).toBe(expected)");
18 expect(treatRed(w.app, w.repo, w.alice, pull, red, "b".repeat(40))).toBe("held");
19 const hold = getHold(w.app.db, pull.id)!;
20 expect(hold.until).toBe("green");
21 expect(hold.why).toContain("adds two numbers");
22 expect(hold.why.startsWith(HOLD_PREFIX)).toBe(true);
23 expect(isDoctorHold(hold.why)).toBe(true);
24 expect(isDoctorHold("waiting on a person")).toBe(false);
25 const said = listComments(w.app.db, pull.id).map((c) => c.body).join("\n");
26 expect(said).toContain("adds two numbers");
27 expect(said).toContain("test/add.test.ts");
28 expect(said).toContain("expect(received).toBe(expected)");
29 expect(said).toContain("bun test ./test/add.test.ts");
30 const [run] = doctorRuns(w.app.db, w.repo.id);
31 expect(run).toMatchObject({ culprit: pull.number, class: "functional", action: "held" });
32 expect(run!.batch).toStartWith("bbbbbbbbbbbb:");
33 expect(run!.probe).toContain("expect(received)");
34 });
35
36 test("a budget-only red is rechecked, not held", async () => {
37 const pull = await w.pull("slow.ts");
38 const red = redFor("serves /", "test/slow.test.ts", "error: over budget (expectBudget): 90ms");
39 expect(treatRed(w.app, w.repo, w.alice, pull, red, "c".repeat(40))).toBe("recheck");
40 expect(getHold(w.app.db, pull.id)).toBeUndefined();
41 expect(doctorRuns(w.app.db, w.repo.id)[0]).toMatchObject({ class: "budget", action: "recheck" });
42 });
43
44 test("the doctor keeps only its newest RUNS_KEPT runs per repository", async () => {
45 const pull = await w.pull("many.ts");
46 const red = redFor("serves /", "test/slow.test.ts", "error: over budget (expectBudget): 90ms");
47 for (let i = 0; i < RUNS_KEPT + 5; i++) treatRed(w.app, w.repo, w.alice, pull, red, "e".repeat(40));
48 const kept = w.app.db
49 .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM maintenance_run WHERE repo_id = ?1")
50 .get(w.repo.id)!.n;
51 expect(kept).toBe(RUNS_KEPT);
52 });
53
54 test("a test name with a backtick and @admin cannot break out of the span in the comment or the hold", async () => {
55 const pull = await w.pull("evil.ts");
56 const red = redFor("x` @admin [a](http://e.test) `", "test/e`@bob.test.ts", "error: boom` @carol");
57 expect(treatRed(w.app, w.repo, w.alice, pull, red, "f".repeat(40))).toBe("held");
58 const said = listComments(w.app.db, pull.id).map((c) => c.body).join("\n");
59 expect(said).not.toContain("@admin");
60 expect(said).not.toContain("@bob");
61 expect(said).not.toContain("@carol");
62 expect(getHold(w.app.db, pull.id)!.why).not.toContain("@");
63 expect(getHold(w.app.db, pull.id)!.why).not.toContain("`");
64 expect(said.split("`").length % 2).toBe(1);
65 expect(said.match(/`[^`\n]*`/g)!.join("")).not.toContain("@");
66 });
67
68 test("deleting the repository deletes its doctor rows", async () => {
69 const other = await doctorWorld("culprit-gone");
70 try {
71 const pull = await other.pull("g.ts");
72 treatRed(other.app, other.repo, other.alice, pull, redFor("t", "test/t.test.ts", "error: x"), "a".repeat(40));
73 expect(doctorRuns(other.app.db, other.repo.id).length).toBe(1);
74 expect((await deleteRepo(other.app, other.alice, other.repo)).ok).toBe(true);
75 expect(doctorRuns(other.app.db, other.repo.id).length).toBe(0);
76 } finally {
77 await other.close();
78 }
79 });
80 });
test/doctor-doors.test.ts added +91
@@ -0,0 +1,91 @@
1 /**
2 * The batch doctor's runs are reachable (#7555): the flow panel, the terminal Flow dialog and MCP `queue_gates`
3 * each show them, and none tells a guest or a sealed pull's probe.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { treatRed } from "../src/checks/doctor.ts";
7 import { createToken } from "../src/core/tokens.ts";
8 import { serve, type Servers } from "../src/http/server.ts";
9 import { doctorLines, flowLines } from "../src/tui/screens/flow.ts";
10 import { doctorWorld, redFor, type Doctor } from "./helpers/doctor.ts";
11
12 describe("the batch doctor's runs are reachable", () => {
13 let w: Doctor;
14 let servers: Servers;
15 let pullNumber = 0;
16 beforeAll(async () => {
17 w = await doctorWorld("doors");
18 servers = await serve(w.app);
19 const pull = await w.pull("bad.ts");
20 pullNumber = pull.number;
21 treatRed(w.app, w.repo, w.alice, pull, redFor("adds two", "test/add.test.ts", "error: expected 3 got 4"), "c".repeat(40));
22 });
23 afterAll(async () => {
24 servers.stop();
25 await w.close();
26 });
27
28 const login = async (): Promise<string> => {
29 const res = await fetch(`${servers.url}/signin`, {
30 method: "POST",
31 redirect: "manual",
32 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
33 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
34 });
35 return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
36 };
37 const mcp = async () => {
38 const res = await fetch(`${servers.url}/mcp`, {
39 method: "POST",
40 headers: {
41 authorization: `Bearer ${createToken(w.app.db, w.alice.id, "t", "ro").token}`,
42 "content-type": "application/json",
43 accept: "application/json, text/event-stream",
44 },
45 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "queue_gates", arguments: { repo: "alice/doors" } } }),
46 });
47 const body = (await res.json()) as { result: { isError?: boolean; content: { text: string }[] } };
48 expect(body.result.isError).not.toBe(true);
49 return JSON.parse(body.result.content[0]!.text) as { doctor: { culprit: number | null; batch: string; action: string; class: string; probe: string; sealed: boolean }[] };
50 };
51 const seal = (on: number) => w.app.db.query("UPDATE issue SET sealed = ?2 WHERE repo_id = ?1 AND number = ?3").run(w.repo.id, on, pullNumber);
52
53 test("the flow panel lists it for the owner and not for a guest", async () => {
54 const page = await (await fetch(`${servers.url}/boards`, { headers: { cookie: await login() } })).text();
55 expect(page).toContain("Batch doctor");
56 expect(page).toContain(`/alice/doors/pulls/${pullNumber}`);
57 expect(page).toContain("expected 3 got 4");
58 const guest = await (await fetch(`${servers.url}/boards`)).text();
59 expect(guest).not.toContain("alice/doors");
60 expect(guest).not.toContain("expected 3 got 4");
61 });
62
63 test("the terminal Flow dialog lists it under the repository's queue line", () => {
64 const queues = [{ repo: "alice/doors", queued: 1, gating: 0, gates: 0, landedHour: 0, approvedToLanded: null }];
65 const lines = flowLines(w.app.db, new Date(), [], { queues }, []);
66 expect(lines.some((l) => l.startsWith(` doctor #${pullNumber} held · functional`))).toBe(true);
67 });
68
69 test("MCP queue_gates carries it, and a read-only token may read it", async () => {
70 const { doctor } = await mcp();
71 expect(doctor[0]).toMatchObject({ culprit: pullNumber, action: "held", class: "functional", sealed: false });
72 expect(doctor[0]!.probe).toContain("expected 3 got 4");
73 });
74
75 test("a sealed culprit is named by no door and its probe is blank", async () => {
76 seal(1);
77 try {
78 const { doctor } = await mcp();
79 expect(doctor[0]).toMatchObject({ sealed: true, probe: "", culprit: null, batch: "" });
80 expect(JSON.stringify(doctor)).not.toContain(`"culprit":${pullNumber}`);
81 const page = await (await fetch(`${servers.url}/boards`, { headers: { cookie: await login() } })).text();
82 expect(page).toContain("a sealed pull");
83 expect(page).not.toContain("expected 3 got 4");
84 const lines = doctorLines(doctor.map((d) => ({ ...d, at: new Date().toISOString() })), Date.now(), 70);
85 expect(lines[0]).toContain("a sealed pull");
86 expect(lines[0]).not.toContain("expected 3 got 4");
87 } finally {
88 seal(0);
89 }
90 });
91 });
test/doctor-hold-lift.test.ts added +30
@@ -0,0 +1,30 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { releaseOnGreen } from "../src/actions/holds.ts";
3 import { treatRed } from "../src/checks/doctor.ts";
4 import { getHold } from "../src/core/holds.ts";
5 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
6 import { doctorWorld, redFor, type Doctor } from "./helpers/doctor.ts";
7
8 describe("a hold the doctor set lifts on a green new head", () => {
9 let w: Doctor;
10 beforeAll(async () => {
11 w = await doctorWorld("lift");
12 });
13 afterAll(() => w.close());
14
15 const check = (pullId: string, number: number, commitOid: string, state: "ok" | "failed") => {
16 const run = createRun(w.app.db, { repoId: w.repo.id, kind: "check", issueId: pullId, env: `pull-${number}`, commitOid, reason: "pull" });
17 claimRun(w.app.db, run.id);
18 return finishRun(w.app.db, run.id, { state, summary: state })!;
19 };
20
21 test("a red check keeps it, a green check of the head lets it go", async () => {
22 const pull = await w.pull("lift.ts");
23 treatRed(w.app, w.repo, w.alice, pull, redFor("t", "test/t.test.ts", "error: boom"), "e".repeat(40));
24 expect(getHold(w.app.db, pull.id)).toBeDefined();
25 expect(releaseOnGreen(w.app, check(pull.id, pull.number, pull.headOid, "failed"))).toBe(false);
26 expect(getHold(w.app.db, pull.id)).toBeDefined();
27 expect(releaseOnGreen(w.app, check(pull.id, pull.number, pull.headOid, "ok"))).toBe(true);
28 expect(getHold(w.app.db, pull.id)).toBeUndefined();
29 });
30 });
test/doctor-person-hold.test.ts added +32
@@ -0,0 +1,32 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { releaseOnGreen } from "../src/actions/holds.ts";
3 import { doctorRuns, treatRed } from "../src/checks/doctor.ts";
4 import { getHold, setHold } from "../src/core/holds.ts";
5 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
6 import { doctorWorld, redFor, type Doctor } from "./helpers/doctor.ts";
7
8 describe("the doctor never touches a hold a person set", () => {
9 let w: Doctor;
10 beforeAll(async () => {
11 w = await doctorWorld("person");
12 });
13 afterAll(() => w.close());
14
15 test("a person's hold stands through a functional red: kept, not overwritten", async () => {
16 const pull = await w.pull("mine.ts");
17 setHold(w.app.db, pull.id, w.alice.id, "waiting on design");
18 const action = treatRed(w.app, w.repo, w.alice, pull, redFor("t", "test/t.test.ts", "error: boom"), "f".repeat(40));
19 expect(action).toBe("kept");
20 expect(getHold(w.app.db, pull.id)).toMatchObject({ why: "waiting on design", heldByName: "alice" });
21 expect(doctorRuns(w.app.db, w.repo.id)[0]).toMatchObject({ action: "kept" });
22 });
23
24 test("a green check does not lift a person's plain hold", async () => {
25 const pull = await w.pull("mine2.ts");
26 setHold(w.app.db, pull.id, w.alice.id, "waiting on design");
27 const run = createRun(w.app.db, { repoId: w.repo.id, kind: "check", issueId: pull.id, env: `pull-${pull.number}`, commitOid: pull.headOid, reason: "pull" });
28 claimRun(w.app.db, run.id);
29 expect(releaseOnGreen(w.app, finishRun(w.app.db, run.id, { state: "ok", summary: "ok" })!)).toBe(false);
30 expect(getHold(w.app.db, pull.id)).toBeDefined();
31 });
32 });
test/helpers/doctor.ts added +76
@@ -0,0 +1,76 @@
1 import { mkdtemp, rm } from "node:fs/promises";
2 import { tmpdir } from "node:os";
3 import { join } from "node:path";
4 import { createApp, type App } from "../../src/app.ts";
5 import { openPull } from "../../src/actions/pulls.ts";
6 import { commitToBranch, createRepo } from "../../src/actions/repos.ts";
7 import type { Red } from "../../src/checks/queue.ts";
8 import { getPull, type Pull } from "../../src/core/pulls.ts";
9 import { getRepoById, type Repo } from "../../src/core/repos.ts";
10 import { createUser, type User } from "../../src/core/users.ts";
11
12 export interface Doctor {
13 readonly app: App;
14 readonly alice: User;
15 readonly repo: Repo;
16 /** Open one more pull from a fresh branch. */
17 pull(path: string): Promise<Pull>;
18 close(): Promise<void>;
19 }
20
21 /** A forge with one person, one repository, and a way to open pulls: what the doctor treats. */
22 export async function doctorWorld(name: string): Promise<Doctor> {
23 const dataDir = await mkdtemp(join(tmpdir(), `forge-doctor-${name}-`));
24 const app = createApp({ dataDir, port: 0 });
25 const a = await createUser(app.db, {
26 origin: "seed",
27 trusted: true,
28 username: "alice",
29 email: "[email protected]",
30 password: "a-good-passphrase",
31 adminIfFirst: true,
32 });
33 if (!a.ok) throw new Error(a.error);
34 const alice = a.value;
35 const made = await createRepo(app, alice, { name, visibility: "private" });
36 if (!made.ok) throw new Error(String(made.error));
37 const seeded = await commitToBranch(app, alice, made.value, {
38 message: "Start",
39 files: [{ path: "README.md", content: `# ${name}\n` }],
40 });
41 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
42 const repo = getRepoById(app.db, made.value.id)!;
43 return {
44 app,
45 alice,
46 repo,
47 async pull(path) {
48 const pushed = await commitToBranch(app, alice, repo, {
49 branch: `feature/${path}`,
50 base: "main",
51 message: path,
52 files: [{ path, content: `export const n = "${path}";\n` }],
53 });
54 if (!pushed.ok) throw new Error(JSON.stringify(pushed.error));
55 const opened = await openPull(app, alice, repo, { title: path, baseBranch: "main", headBranch: `feature/${path}` });
56 if (!opened.ok) throw new Error(String(opened.error));
57 return getPull(app.db, repo.id, opened.value.number)!;
58 },
59 async close() {
60 await app.close?.();
61 await rm(dataDir, { recursive: true, force: true });
62 },
63 };
64 }
65
66 /** A red verdict naming one failing test with this error line. */
67 export function redFor(name: string, file: string, error: string, reason: Red["reason"] = { kind: "tests", failed: 1 }): Red {
68 return {
69 lands: false,
70 reopen: true,
71 comment: "red",
72 learn: [file],
73 ...(reason === undefined ? {} : { reason }),
74 tests: { passed: 10, failed: 1, failing: [{ name, file, errors: [error] }] },
75 };
76 }
test/merge-queue.test.ts +41 −1
@@ -23,6 +23,8 @@ import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
2323 import { createUser, type User } from "../src/core/users.ts";
2424 import { landIfGreen, REGATE_RED_MS, regateDue, sweepGreen } from "../src/crew/autonomy.ts";
2525 import { listComments } from "../src/core/issues.ts";
26 import { doctorRuns, isDoctorHold } from "../src/checks/doctor.ts";
27 import { getHold } from "../src/core/holds.ts";
2628 import { readQueues } from "../src/flow/queue.ts";
2729 import { repoDir } from "../src/git/repos.ts";
2830 import { logLevel, setLogLevel } from "../src/kernel/log.ts";
@@ -681,6 +683,39 @@ describe("the merge queue", () => {
681683 expect(await sweepGreen(app, repo)).toBe(1);
682684 expect(gatesOf(repo)).toHaveLength(before + 1);
683685 expect(merged(repo, pull)).toBe(true);
686 // A red that went green on the re-gate was never the doctor's.
687 expect(getHold(app.db, pull.id)).toBeUndefined();
688 expect(doctorRuns(app.db, repo.id)).toEqual([]);
689 });
690
691 test("the batch doctor holds a red only once it reproduces on the re-gate, with its own comment (#7555)", async () => {
692 const repo = await autonomousRepo("doctor-wiring");
693 const pull = await greenPull(repo, "red-a.ts");
694 expect(await sweepGreen(app, repo)).toBe(0);
695 // First red: told, not held, nothing recorded.
696 expect(getHold(app.db, pull.id)).toBeUndefined();
697 expect(doctorRuns(app.db, repo.id)).toEqual([]);
698 age(pull);
699 expect(await sweepGreen(app, repo)).toBe(0);
700 // The same head and base red again: held, marked as the doctor's, recorded.
701 const hold = getHold(app.db, pull.id);
702 expect(hold?.until).toBe("green");
703 expect(isDoctorHold(hold!.why)).toBe(true);
704 expect(hold!.why).toContain("goes red");
705 const [run] = doctorRuns(app.db, repo.id);
706 expect(run).toMatchObject({ culprit: pull.number, class: "functional", action: "held" });
707 expect(listComments(app.db, pull.id).at(-1)!.body).toContain("bun test ./test/red.test.ts");
708 });
709
710 test("a red handed back while others landed ahead of it is not held by the doctor (#7555)", async () => {
711 const repo = await autonomousRepo("doctor-on-top");
712 const pulls = [];
713 for (const name of ["a.ts", "b.ts", "red-c.ts"]) pulls.push(await greenPull(repo, name));
714 expect(await sweepGreen(app, repo)).toBe(2);
715 const red = pulls[2]!;
716 expect(latestDecision(app.db, red.id, "land")?.answer).toBe("red");
717 expect(getHold(app.db, red.id)).toBeUndefined();
718 expect(doctorRuns(app.db, repo.id)).toEqual([]);
684719 });
685720
686721 test("a red that reproduces on the re-gate stays red, told once", async () => {
@@ -695,7 +730,12 @@ describe("the merge queue", () => {
695730 expect(again?.answer).toBe("red");
696731 expect((again?.state as { again?: boolean }).again).toBe(true);
697732 expect(regateDue(again!, Date.now() + 10 * REGATE_RED_MS)).toBe(false);
698 expect(listComments(app.db, pull.id)).toHaveLength(comments);
733 // The red is not told twice. The one comment added is the doctor's hold
734 // notice: it says the red reproduced, which the first comment could not.
735 const added = listComments(app.db, pull.id).slice(comments);
736 expect(added).toHaveLength(1);
737 expect(added[0]!.body).toContain("The batch doctor held this pull");
738 expect(added[0]!.body).toContain("goes red");
699739 // However long it waits now, it is not gated again until a side moves.
700740 age(pull);
701741 const before = gatesOf(repo).length;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs