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

Remove the proof step: with check.run lint and types only, it proves nothing the merge queue's gate does not #8301

Merged opened this

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

Merged

mason/forge:mason/7779-remove-the-proof-step-with-check-run-lin → main 14 commits fork

  • Merged. mason/7779-remove-the-proof-step-with-check-run-lin was merged into main as adb37a9.

Review

Sign in to review this change.
33 files changed +148 −1489
docs/CREW.md +3 −25
@@ -169,31 +169,9 @@ In the database the pull's `close_reason` stays NULL (its CHECK predates the
169169 word) and `issue.parked = 1` carries it, so raw SQL never reads it as done.
170170
171171 **The merge queue.** A green, approved pull in an autonomous repository
172 joins its repository's queue (`src/checks/queue.ts`) once it is proved
173 (#7616, `src/checks/proof.ts`): when its check has a fast tier, the full
174 gate — the same command, environment and sharding — runs on its head
175 squashed onto main's tip first, using one check slot. Green admits it, kept
176 as a `proof` row in the decision ledger with the head and base. A red one is
177 held (`proof-red` in the tick's waits; `proving` while it runs), and the
178 pull gets a comment that names the failing tests, so a test the fast tier
179 never reached costs no batch. A proof goes stale when main moves more than
180 16 first-parent landings past it (`STALE_LANDINGS`). The pull is then proved
181 again before it joins, and a pull already in the line is left out of its
182 next batch until it is proved again. A proof that has not answered by the
183 gate's deadline (15 minutes) is cut off and no verdict is kept, so a hung
184 one never holds its pull or a check slot.
185
186 A **proof** is a run of kind `proof`: the full suite on a pull's head squashed
187 onto the base, before the pull joins the merge queue. It is `queued` while no
188 check slot is spare, `running`, then `ok` (shown *Green*), `failed` (red) or
189 `cancelled` (shown *Cut off*: the deadline passed or the forge restarted; it is
190 proved again on the next sweep). A push ends the old head's unfinished proof as
191 `cancelled`, "superseded by a push". The run's summary says why a proof waited
192 or could not start, and the pull is told once per head in a comment and in its
193 next step ("approved and green, but its proof waits: no check slot is spare").
194 Read them in the runs list (`?kind=proof`), the terminal's Runs screen, the API
195 (`GET …/runs?kind=proof`) and the MCP `runs` tool (`kind: "proof"`); only those
196 lists mix proofs in with the other kinds.
172 joins its repository's queue (`src/checks/queue.ts`) on its own check and
173 its approval: there is no proof before the line (removed in #7779), and the
174 queue's gate is the one full run.
197175
198176 The queue builds one candidate: main, then each queued pull's squash on top of the one before, in
199177 queue order, leaving out any pull that conflicts or has nothing left to land.
docs/MCP.md +1 −1
@@ -154,7 +154,7 @@ for a tunnel and a forge that sleeps.
154154 | `review` | `{ repo, number, verdict, reason? }`: approve a pull (`approve`) or request changes (`changes`, with a `reason`), as the page does; a writer's or the crew's, and never your own pull | yes |
155155 | `rollback` | (admin scope) `{ sha, dryRun? }`: roll the forge back to a kept release, as /releases does; plans unless `dryRun` is false (from which release to which, and whether the live schema allows it); a person who may roll back, with an admin-scope token, never an agent | yes |
156156 | `run_log` | `{ repo, run, tail? }`: a run's log, or its last `tail` lines (1–2000), with the REST route's access: a hidden log or unreadable repository is not found | |
157 | `runs` | `{ repo, kind?, limit? }`: the repository's recent runs (kind agent, deploy, check or proof — a proof is the full-suite run before the merge queue), newest first (id, kind, state, issue or pull number, started, ended), 1–50, default 20; read as the runs page reads them | |
157 | `runs` | `{ repo, kind?, limit? }`: the repository's recent runs (kind agent, deploy or check), newest first (id, kind, state, issue or pull number, started, ended), 1–50, default 20; read as the runs page reads them | |
158158 | `say` | speak on an issue or a pull; a working builder hears it as its next step. With no `repo` or `number` it speaks to the forge's own agent (`forge talk`), which answers in `GET /api/v1/conversation/turns` | yes |
159159 | `scorecard` | (forge admin) no input: the crew's scorecard per agent, all time and 30 days: landed, review rounds, red rate, reverts | |
160160 | `seal` | `{ repo, number, sealed }`: seal a security report (and the pull that fixes it) so only writers and the crew see it, or unseal it for every reader; a writer's, recorded; with `action: "private"` instead of `sealed`, the private repository a writer fixes a sealed report in, made on first need, with its name and clone URL (the API's `POST …/issues/:number/private`, same rules) | yes |
docs/features/pipeline.md +2 −2
@@ -8,7 +8,7 @@ Every change follows four steps, and a person only needs those four words:
88 4. **Live**: a release builds the default branch and production serves it.
99
1010 `/pipeline` says this to anyone. Each step's finer parts (the check queue, the
11 proof, the merge queue's gate, the release build and boot check) sit one
11 merge queue's gate, the release build and boot check) sit one
1212 click down in a `<details>`. The live counts per stage stay on `/boards`,
1313 whose Pipeline panel links back to `/pipeline`.
1414
@@ -67,5 +67,5 @@ label with `l`.
6767
6868 ## Not here yet
6969
70 The rest of #7703 is filed as its own issues: remove the proof (#7779), re-run a shard that crashed
70 The rest of #7703 is filed as its own issues: re-run a shard that crashed
7171 outside any test (#7781), reuse a verdict for the same tree (#7782), and measure each step (#7784).
src/checks/landing.ts −32
@@ -62,38 +62,6 @@ export async function gateCandidate(
6262 return gateVerdict(app, repo, commitOid, await checks.gate(repo, pull, commitOid, signal));
6363 }
6464
65 /**
66 * The proof before the line (#7616, `proof.ts`): the full gate on the
67 * pull's head squashed onto its base, run as the gate runs. Undefined when
68 * there is nothing to prove with — no checks service, a lane that cannot
69 * prove, or a check with no fast tier, whose pull check was already the
70 * whole suite.
71 */
72 export async function proveCandidate(
73 app: App,
74 repo: Repo,
75 pull: Pull,
76 dir: string,
77 commitOid: string,
78 signal?: AbortSignal,
79 ): Promise<LandVerdict | undefined> {
80 const prove = checksFor(app)?.prove;
81 if (prove === undefined || !(await provable(app, dir, commitOid))) return undefined;
82 // Not the forge's last gate: the Resilience page reads the queue's gates only.
83 return gateVerdict(app, repo, commitOid, await prove(repo, pull, commitOid, signal), false);
84 }
85
86 /** Whether a pull at `commitOid` is proved before the line: a lane that proves, and a check with a fast tier. */
87 export async function provable(app: App, dir: string, commitOid: string): Promise<boolean> {
88 if (checksFor(app)?.prove === undefined) return false;
89 const [forgeJson, packageJson] = await Promise.all([
90 readFileAt(dir, commitOid, "forge.json"),
91 readFileAt(dir, commitOid, "package.json"),
92 ]);
93 const declared = parseCheck(forgeJson, packageJson);
94 return declared.ok && declared.value !== undefined && declared.value.fast !== null;
95 }
96
9765 /** How long the queue waits between reads of a gate another forge runs (#6611). */
9866 export const ADOPT_POLL_MS = 5_000;
9967
src/checks/proof.ts deleted −307
@@ -1,307 +0,0 @@
1 /**
2 * The proof before the line (#7616). A pull's own check is the fast tier —
3 * the tests its change reaches — while the merge queue's gate runs every
4 * test, so a pull that broke a test its change never reached was found in
5 * the queue, where one red costs a batch of up to eight. Before a pull
6 * joins the line it is proved: the full gate (the same command,
7 * environment and sharding — `ChecksHandle.prove` is the gate's own run)
8 * on its head squashed onto the base's tip, the tree a batch of one would
9 * gate. Green is kept in the decision ledger as a `proof` row with the head
10 * and the base it was proved on, and admits the pull; red is told to the
11 * pull with its failing tests named and holds it, at no cost to any batch.
12 *
13 * A proof goes stale once the base has moved more than `STALE_LANDINGS`
14 * first-parent commits past the base it was proved on, or off it: the pull
15 * is proved again before it joins, and one already waiting in the line is
16 * left out of its next batch (`Queued.proven`) and proved again. A red
17 * proof stands for its head until a push, or until the base has moved that
18 * far — a red that was main's may be gone from a newer main.
19 *
20 * A proof is a run (#7706, `core/proof_runs.ts`, kind `proof`): waiting for
21 * a check slot, running, then green, red or cut off. A proof that cannot
22 * start (no slot spare, the head does not merge onto the base tip) says why
23 * on the pull, once per head. A proof cut off (the forge stopping, the Job
24 * lost) is no verdict: its run ends cut off, the pull is told, and the next
25 * sweep proves it again. One that has not answered by the gate's own
26 * deadline (`GATE_DEADLINE_MS`, #3430) is cut off the same way, its run
27 * aborted, so a hung proof never holds its pull or a check slot for good.
28 * One proof runs at a time per pull, and only one beyond the first while no
29 * check slot is spare.
30 */
31
32 import type { Database } from "bun:sqlite";
33 import { latestDecision, recordDecision } from "../core/decisions.ts";
34 import { cutOffBefore, cutSummary, endedBefore, endProofRun, latestProofRun, openProofRun, startProofRun } from "../core/proof_runs.ts";
35 import type { Pull } from "../core/pulls.ts";
36 import { STOPPED_SUMMARY } from "../core/runs.ts";
37 import { runGit } from "../git/env.ts";
38 import { mergeable } from "../git/merge.ts";
39 import { GATE_DEADLINE_MS } from "./queue.ts";
40 import type { LandVerdict } from "./tiers.ts";
41
42 /**
43 * How far the base may move past a proof before it is proved again: two
44 * full batches (`MAX_CANDIDATE` is 8), so a pull proved as it joined is
45 * still proved while the two batches ahead of it land, and one that waited
46 * longer than that is proved on what main has become.
47 */
48 export const STALE_LANDINGS = 16;
49
50 /** A red proof is tried again this long after it, in case what failed was not the pull. */
51 export const RED_PROOF_RETRY_MS = 30 * 60_000;
52
53 /** What a proof found: the head it ran on, the base tip it was squashed onto, and whether it was green. */
54 export interface Proof {
55 readonly head: string;
56 readonly base: string;
57 readonly green: boolean;
58 /** When the ledger kept it. */
59 readonly at?: string;
60 }
61
62 /** Where a pull stands before the line. */
63 export type Standing = "proved" | "proving" | "red" | "unproved";
64
65 /** The proofs running now: pull id → the head being proved. */
66 const proving = new Map<string, string>();
67
68 /** How many proofs are running on this forge: they hold check slots as gates do. */
69 export function proofsInFlight(): number {
70 return proving.size;
71 }
72
73 /** The newest proof of a pull, read off the ledger; undefined when it has none. */
74 export function proofOf(db: Database, pullId: string): Proof | undefined {
75 const row = latestDecision(db, pullId, "proof");
76 if (row === undefined) return undefined;
77 const state = row.state;
78 if (typeof state !== "object" || state === null) return undefined;
79 const { head, base } = state as Record<string, unknown>;
80 if (typeof head !== "string" || typeof base !== "string") return undefined;
81 return { head, base, green: row.answer === "green", at: row.createdAt };
82 }
83
84 /** Keep a proof's verdict on the ledger. */
85 export function keepProof(db: Database, repoId: string, pullId: string, proof: Proof): void {
86 recordDecision(db, {
87 kind: "proof",
88 repoId,
89 issueId: pullId,
90 state: { head: proof.head, base: proof.base },
91 answer: proof.green ? "green" : "red",
92 p: 1,
93 by: "rule",
94 ms: 0,
95 });
96 }
97
98 /**
99 * First-parent commits from `from` to `to`: the landings since a proof.
100 * Undefined when `from` is not behind `to` (the base was rewritten) or git
101 * cannot say.
102 */
103 export async function landingsSince(dir: string, from: string, to: string): Promise<number | undefined> {
104 if (from === to) return 0;
105 const behind = await runGit(["merge-base", "--is-ancestor", from, to], { cwd: dir });
106 if (!behind.ok) return undefined;
107 const count = await runGit(["rev-list", "--count", "--first-parent", `${from}..${to}`], { cwd: dir });
108 if (!count.ok) return undefined;
109 const n = Number(count.value.trim());
110 return Number.isSafeInteger(n) && n >= 0 ? n : undefined;
111 }
112
113 /** A proof still holds on `tip`: the base has moved no more than `STALE_LANDINGS` past it. */
114 export async function current(dir: string, proof: Proof, tip: string): Promise<boolean> {
115 const moved = await landingsSince(dir, proof.base, tip);
116 return moved !== undefined && moved <= STALE_LANDINGS;
117 }
118
119 /** Where `pull` at its head stands on the base tip `tip`. */
120 export async function standing(
121 db: Database,
122 dir: string,
123 pull: Pull,
124 tip: string,
125 now: number = Date.now(),
126 ): Promise<Standing> {
127 if (proving.get(pull.id) === pull.headOid) return "proving";
128 const proof = proofOf(db, pull.id);
129 if (proof === undefined || proof.head !== pull.headOid) return "unproved";
130 if (!(await current(dir, proof, tip))) return "unproved";
131 if (proof.green) return "proved";
132 // A red says something only about the main it ran on, and for a while: a flake or a
133 // broken gate must not strand a pull until its next push.
134 const fresh = proof.at !== undefined && now - Date.parse(proof.at) < RED_PROOF_RETRY_MS;
135 return proof.base === tip && fresh ? "red" : "unproved";
136 }
137
138 /** The pull's head squashed onto `tip`, as a commit the gate can run on; undefined when it does not merge. */
139 export async function proofCommit(dir: string, tip: string, pull: Pull): Promise<string | undefined> {
140 const merged = await mergeable(dir, tip, pull.headOid);
141 if (!merged.ok) return undefined;
142 const commit = await runGit(
143 ["commit-tree", merged.value.treeOid, "-p", tip, "-m", `proof: #${pull.number}`],
144 { cwd: dir, env: IDENTITY },
145 );
146 return commit.ok ? commit.value.trim() : undefined;
147 }
148
149 const IDENTITY = {
150 GIT_AUTHOR_NAME: "forge",
151 GIT_AUTHOR_EMAIL: "forge@localhost",
152 GIT_COMMITTER_NAME: "forge",
153 GIT_COMMITTER_EMAIL: "forge@localhost",
154 };
155
156 /** What starting a proof needs: where, on what, how to run the gate, and what to do with its answer. */
157 export interface ProofRun {
158 readonly db: Database;
159 readonly repoId: string;
160 readonly pull: Pull;
161 readonly dir: string;
162 readonly tip: string;
163 /** The full gate on a commit; `signal` aborts at the deadline, and the run should stop. */
164 readonly run: (commitOid: string, signal: AbortSignal) => Promise<LandVerdict>;
165 /** Check slots spare now; a second proof starts only on one. */
166 readonly room: () => number;
167 /** The proof was judged and kept. */
168 readonly done: (proof: Proof, verdict: LandVerdict) => void;
169 /** Said to the pull: a proof that cannot start, or was cut off. Once per head and reason. */
170 readonly told?: (words: string) => void;
171 /** How long the proof may run before it is cut off; the gate's deadline unless a test shortens it. */
172 readonly deadlineMs?: number;
173 }
174
175 /** Why a proof waits or cannot start: the run's summary, and what the pull is told. */
176 export const WAIT_SLOT = "no check slot is spare";
177 export const WAIT_MERGE = "cannot start: the head does not merge onto the base tip";
178
179 /**
180 * Say once per head why the proof has not started: a waiting run (`queued`)
181 * for no slot, a failed one for a head that does not merge.
182 */
183 function refuse(x: ProofRun, summary: string, words: string): void {
184 const last = latestProofRun(x.db, x.pull.id);
185 // Once per head: any run of it that already waited or failed for this reason was told.
186 const state = summary === WAIT_SLOT ? "queued" : "failed";
187 const told = endedBefore(x.db, x.pull.id, x.pull.headOid, state, summary);
188 if (told && last?.commitOid === x.pull.headOid && last.summary === summary) return;
189 if (summary === WAIT_SLOT) {
190 openProofRun(x.db, { repoId: x.repoId, pullId: x.pull.id, head: x.pull.headOid, base: x.tip, state: "queued", summary });
191 } else if (last !== undefined && last.state === "queued" && last.commitOid === x.pull.headOid) {
192 endProofRun(x.db, last.id, "failed", summary);
193 } else {
194 const run = openProofRun(x.db, { repoId: x.repoId, pullId: x.pull.id, head: x.pull.headOid, base: x.tip, state: "running", summary });
195 endProofRun(x.db, run.id, "failed", summary);
196 }
197 if (!told) x.told?.(words);
198 }
199
200 /** End a proof run cut off for `reason`, and tell the pull once per head and reason. */
201 function cutOff(x: ProofRun, runId: string, head: string, reason: string): void {
202 const told = cutOffBefore(x.db, x.pull.id, head, reason, runId);
203 endProofRun(x.db, runId, "cancelled", cutSummary(reason));
204 if (!told && head === x.pull.headOid) x.told?.(cutWords(reason));
205 }
206
207 /** What a pull is told when its proof was cut off. */
208 const cutWords = (why: string): string =>
209 `The full-suite proof of this head was cut off (${why}), so it has no verdict. It is proved again on the next sweep.`;
210
211 /**
212 * Start proving `pull` on `tip` beside the caller, which does not wait.
213 * False when it did not start: a proof of this head runs already, no slot
214 * is free, or the head does not merge onto the tip. The last two are kept
215 * as a run and told to the pull, once per head.
216 */
217 export async function startProof(x: ProofRun): Promise<boolean> {
218 if (proving.has(x.pull.id)) return false;
219 // A run still `running` that this process does not hold was cut off by a restart.
220 const orphan = latestProofRun(x.db, x.pull.id);
221 if (orphan?.state === "running") {
222 cutOff(x, orphan.id, orphan.commitOid, STOPPED_SUMMARY);
223 }
224 if (proving.size > 0 && x.room() <= 0) {
225 refuse(
226 x,
227 WAIT_SLOT,
228 `The full-suite proof of this head is waiting: ${WAIT_SLOT}. It starts when one frees.`,
229 );
230 return false;
231 }
232 proving.set(x.pull.id, x.pull.headOid);
233 let commit: string | undefined;
234 try {
235 commit = await proofCommit(x.dir, x.tip, x.pull);
236 } catch {
237 commit = undefined;
238 }
239 if (commit === undefined) {
240 proving.delete(x.pull.id);
241 refuse(
242 x,
243 WAIT_MERGE,
244 `The full-suite proof cannot start: this head does not merge onto ${x.pull.baseBranch} (${x.tip.slice(0, 7)}). Bring the pull up to date with the base and push; it is proved then.`,
245 );
246 return false;
247 }
248 const waiting = latestProofRun(x.db, x.pull.id);
249 let runId: string;
250 if (waiting?.state === "queued" && waiting.commitOid === x.pull.headOid) {
251 runId = waiting.id;
252 startProofRun(x.db, runId, x.tip);
253 } else {
254 runId = openProofRun(x.db, { repoId: x.repoId, pullId: x.pull.id, head: x.pull.headOid, base: x.tip, state: "running", summary: "" }).id;
255 }
256 const abandon = new AbortController();
257 let timer: ReturnType<typeof setTimeout> | undefined;
258 let deadline = false;
259 // A proof that never answers is cut off at the deadline: no verdict (#3430).
260 const cut = new Promise<LandVerdict>((resolve) => {
261 timer = setTimeout(() => {
262 deadline = true;
263 abandon.abort();
264 resolve({ lands: false, interrupted: true });
265 }, x.deadlineMs ?? GATE_DEADLINE_MS);
266 });
267 void Promise.race([x.run(commit, abandon.signal), cut])
268 .then(
269 (verdict) => {
270 // Released before the answer is acted on: `done` may sweep, and must not read it as running.
271 clearTimeout(timer);
272 proving.delete(x.pull.id);
273 if ("interrupted" in verdict) {
274 cutOff(x, runId, x.pull.headOid, deadline ? "at the deadline" : "the gate was interrupted");
275 return;
276 }
277 const proof: Proof = { head: x.pull.headOid, base: x.tip, green: verdict.lands };
278 keepProof(x.db, x.repoId, x.pull.id, proof);
279 endProofRun(x.db, runId, verdict.lands ? "ok" : "failed", verdict.lands ? "green" : "red");
280 x.done(proof, verdict);
281 },
282 () => {
283 clearTimeout(timer);
284 proving.delete(x.pull.id);
285 cutOff(x, runId, x.pull.headOid, "the gate failed to answer");
286 },
287 );
288 return true;
289 }
290
291 /** Forget the proofs running: a test's reset. */
292 export function resetProofsForTest(): void {
293 proving.clear();
294 }
295
296 /** What a red proof tells the pull: the base it was squashed onto, the tests that failed, and the gate's log. */
297 export function proofRedWords(branch: string, base: string, red: Extract<LandVerdict, { reopen: true }>): string {
298 const names = (red.tests?.failing ?? []).map((t) => `- ${t.name}`);
299 const more = (red.tests?.failed ?? names.length) - names.length;
300 const log = /The gate's log: (\S+)/.exec(red.comment)?.[1];
301 return [
302 `The full suite on this head squashed onto ${branch} (${base.slice(0, 7)}) was red, so the pull has not joined the merge queue.`,
303 names.length === 0 ? "It named no test (a type error, a timeout, a setup step)." : `Red tests:\n${names.join("\n")}${more > 0 ? `\n- and ${more} more` : ""}`,
304 ...(log === undefined ? [] : [`The gate's log: ${log}`]),
305 "It is proved again on a push.",
306 ].join("\n\n");
307 }
src/checks/queue.ts −15
@@ -57,11 +57,6 @@
5757 * with its pulls, counts and failing tests (#6739, `gate_result.ts`), read
5858 * by `GET …/queue/gates`.
5959 *
60 * A pull joins proved (#7616, `proof.ts`): the full suite was green on its
61 * head squashed onto its base. One whose proof the base has since moved too
62 * far past is left out of its batch, as a conflict is, and proved again
63 * before it joins once more.
64 *
6560 * What is queued is not stored: it is the pulls `landIfGreen` admitted, and
6661 * every sweep admits them again. The queue only orders them and remembers
6762 * what is at the gate and what its searches found, for the tick line and
@@ -107,12 +102,6 @@ export interface Queued {
107102 * that its red tree held: none when it was red on its own merge.
108103 */
109104 handBack(red: Red, baseOid: string, together: readonly number[]): void;
110 /**
111 * The pull's proof before the line still holds on this base (#7616,
112 * `proof.ts`). False leaves it out of the batch, answered `false`, to be
113 * proved again before it joins; absent, it always holds.
114 */
115 proven?(baseOid: string): Promise<boolean>;
116105 }
117106
118107 /** One pull's squash on top of the steps before it. */
@@ -1607,10 +1596,6 @@ async function build(batch: readonly Waiting[], on?: Candidate): Promise<Candida
16071596 let treeOid = baseTreeOid;
16081597 const steps: Step[] = [];
16091598 for (const { entry } of batch) {
1610 if (entry.proven !== undefined && !(await entry.proven(tipOid))) {
1611 log.info(`left out of the batch: #${entry.pull.number}'s proof is stale, proved again first`);
1612 continue;
1613 }
16141599 const merged = await mergeable(dir, tipOid, entry.pull.headOid);
16151600 if (!merged.ok) {
16161601 // Left out, never red (#5276): the rest gate without it, and once the
src/checks/service.ts +4 −15
@@ -118,7 +118,6 @@ import {
118118 import { evictSoon, lend } from "./install_evict.ts";
119119 import { meterEgress } from "../apps/egress.ts";
120120 import { checkStage, type GateStage } from "./gate_reason.ts";
121 import { proofsInFlight } from "./proof.ts";
122121 import { extraGates, GATE_DEADLINE_MS, gateQueued, gateRunId, gatesInFlight, watchGates } from "./queue.ts";
123122 import type { JobOptions } from "../cluster/jobs.ts";
124123 import type { CheckDetail } from "../core/runs.ts";
@@ -201,14 +200,6 @@ export interface ChecksHandle {
201200 * `signal` aborting (the queue's deadline, #3430) cuts it off the same way.
202201 */
203202 gate(repo: Repo, pull: Pull, commitOid: string, signal?: AbortSignal): Promise<GateDone>;
204 /**
205 * The proof before the line (#7616, `checks/proof.ts`): the gate's own
206 * run — command, environment, sharding — on the pull's head squashed onto
207 * its base, cut off as a gate is when `signal` aborts (its deadline). A
208 * lane without it (a test's stand-in) admits on the pull's own check, as
209 * before.
210 */
211 prove?(repo: Repo, pull: Pull, commitOid: string, signal?: AbortSignal): Promise<GateDone>;
212203 /**
213204 * The gate of `commitOid` this forge is running now, to wait on rather
214205 * than start again (#6611); undefined when none is.
@@ -385,8 +376,7 @@ function gateMayBeRunning(path: string, now: number): boolean {
385376 * directory and database with the old one, whose checks are still running in
386377 * theirs; those are left alone. A gate writes no run: its checkout is left
387378 * while it is younger than a gate's deadline, and past that while its
388 * `check_gate` row is open, for as long as a check may run — the proof
389 * step's full suite outlives the window, and lost its `node_modules` (#7673).
379 * `check_gate` row is open, for as long as a check may run.
390380 */
391381 export function sweepCheckouts(app: App, now: number = Date.now()): void {
392382 const root = checksRoot(app.config.dataDir);
@@ -608,7 +598,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
608598 if (options.gate === undefined) gate.resize(limit);
609599 startVerify();
610600 // Every merge-queue gate in flight holds a slot, a line's first too (#6376).
611 while (slotted() < limit - gatesInFlight() - proofsInFlight()) {
601 while (slotted() < limit - gatesInFlight()) {
612602 // A pull's check waits while its main is red (`checks/red_main.ts`).
613603 const queued = unheld(
614604 listActive(app.db, "check").filter(
@@ -628,7 +618,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
628618 const { rebases, rest: others } = rebasesFirst(app.db, queued);
629619 // While a gate is about to start, the last free slot is its: a pull's
630620 // check waits for it, a rebase's and a main check do not.
631 const held = holdsLastSlot(limit - gatesInFlight() - proofsInFlight() - slotted(), gateQueued());
621 const held = holdsLastSlot(limit - gatesInFlight() - slotted(), gateQueued());
632622 const next = startable(rebases) ?? startable(held ? others.filter((run) => run.issueId === null) : others);
633623 if (!next) return;
634624 const repo = getRepoById(app.db, next.repoId);
@@ -1689,12 +1679,11 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
16891679 const handle: ChecksHandle = {
16901680 request,
16911681 gate: gateRun,
1692 prove: gateRun,
16931682 adopt: (commitOid) => {
16941683 for (const g of gates.values()) if (g.commitOid === commitOid) return g.done;
16951684 return undefined;
16961685 },
1697 spareSlots: () => Math.max(0, capacity() - PULL_RESERVE - slotted() - extraGates() - proofsInFlight()),
1686 spareSlots: () => Math.max(0, capacity() - PULL_RESERVE - slotted() - extraGates()),
16981687 verifyDue: (now) => askVerifications(now),
16991688 lane: gate,
17001689 stop: async (stopOptions) => {
src/core/decisions.ts +2 −6
@@ -20,18 +20,14 @@ import { id as newId } from "../kernel/id.ts";
2020 *
2121 * `hand` is a person's tap on "Let the decider" on a hand-over card
2222 * (`crew/handover.ts`): its state is the curated context the decider saw.
23 *
24 * `proof` is the full suite on a pull's head squashed onto its base, before
25 * the pull joins the merge queue (`checks/proof.ts`): its state is the head
26 * and the base, its answer `green` or `red`. */
23 */
2724 export type DecisionKind =
2825 | "next"
2926 | "land"
3027 | "retry"
3128 | "lean"
3229 | "guard"
33 | "hand"
34 | "proof";
30 | "hand";
3531
3632 export interface Decision {
3733 readonly id: string;
src/core/issues.ts −3
@@ -9,7 +9,6 @@ import { id } from "../kernel/id.ts";
99 import { err, ok, type Result } from "../kernel/result.ts";
1010 import { transact } from "../kernel/db.ts";
1111 import { repoVisible } from "./visibility.ts";
12 import { endProofRunsForPull } from "./proof_runs.ts";
1312
1413 export type IssueKind = "issue" | "pull";
1514 export type IssueState = "open" | "closed";
@@ -549,8 +548,6 @@ export function setIssueState(
549548 WHERE id = ?4 AND state != ?1`,
550549 )
551550 .run(state, closedAt, now, issueId, why, parked).changes;
552 // A closed pull's waiting proof would sit queued for good (#7706).
553 if (changed > 0 && state === "closed") endProofRunsForPull(db, issueId, "pull closed");
554551 return changed > 0 ? getIssueById(db, issueId) : undefined;
555552 }
556553
src/core/migration-pins/20261102160000-remove-proof.pin added +1
@@ -0,0 +1,1 @@
1 32439e4058aba21ac63b4ae8efdf26bfc5795a937e4d708c33f4abc57e347262
src/core/migrations/20261102160000-remove-proof.ts added +8
@@ -0,0 +1,8 @@
1 // The proof before the line is gone (#7779): the pull's check and the merge
2 // queue's gate run the same command, so a proof proved nothing the gate does
3 // not. Its ledger rows are deleted with it. The `proof_run` table stays,
4 // unread: a migration never drops a table across a roll (the release still
5 // in play reads it), and a later one may once no such release is.
6 export default `
7 DELETE FROM decision WHERE kind = 'proof';
8 `;
src/core/proof_runs.ts deleted −160
@@ -1,160 +0,0 @@
1 /**
2 * Proof runs (#7706). A proof (`checks/proof.ts`) writes one row here and
3 * moves it `queued` (no check slot spare) → `running` → `ok`, `failed` or
4 * `cancelled` (cut off at the deadline or by a restart). Read as a `Run` of
5 * kind `proof`, so the runs list, the pull and MCP `runs` show it. Pure
6 * data: no authz, no events.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { id } from "../kernel/id.ts";
11 import type { Run, RunState } from "./runs.ts";
12
13 interface Row {
14 id: string;
15 repo_id: string;
16 issue_id: string;
17 head: string;
18 base: string;
19 state: RunState;
20 summary: string;
21 created_at: string;
22 started_at: string | null;
23 finished_at: string | null;
24 }
25
26 const toRun = (r: Row): Run => ({
27 id: r.id,
28 repoId: r.repo_id,
29 kind: "proof",
30 issueId: r.issue_id,
31 env: "",
32 commitOid: r.head,
33 branch: "",
34 state: r.state,
35 summary: r.summary,
36 actorId: null,
37 reason: "proof",
38 sessionId: "",
39 costUsd: 0,
40 notBefore: null,
41 attempt: 0,
42 heartbeatAt: null,
43 turns: 0,
44 sandboxEnforced: null,
45 sandboxNote: "",
46 placement: "",
47 node: "",
48 checkDetail: null,
49 baseOid: r.base,
50 variantGroup: null,
51 createdAt: r.created_at,
52 startedAt: r.started_at,
53 servingAt: null,
54 finishedAt: r.finished_at,
55 });
56
57 const NOW = "strftime('%Y-%m-%dT%H:%M:%fZ','now')";
58
59 /** A proof of `head` on `base`, waiting (`queued`) with the reason, or already `running`. */
60 export function openProofRun(
61 db: Database,
62 input: { repoId: string; pullId: string; head: string; base: string; state: "queued" | "running"; summary: string },
63 ): Run {
64 const runId = id();
65 // A push makes the old head's proof moot: it waited or ran for a head no one will land.
66 db.query(
67 `UPDATE proof_run SET state = 'cancelled', summary = 'superseded by a push', finished_at = ${NOW}
68 WHERE issue_id = ?1 AND head <> ?2 AND state IN ('queued','running')`,
69 ).run(input.pullId, input.head);
70 db.query(
71 `INSERT INTO proof_run (id, repo_id, issue_id, head, base, state, summary, started_at)
72 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ${input.state === "running" ? NOW : "NULL"})`,
73 ).run(runId, input.repoId, input.pullId, input.head, input.base, input.state, input.summary.slice(0, 500));
74 return getProofRun(db, runId)!;
75 }
76
77 /** A pull left `open` (landed or closed): its waiting or running proofs end, for no one will land them. */
78 export function endProofRunsForPull(db: Database, pullId: string, summary: "pull landed" | "pull closed"): void {
79 db.query(
80 `UPDATE proof_run SET state = 'cancelled', summary = ?2, finished_at = ${NOW}
81 WHERE issue_id = ?1 AND state IN ('queued','running')`,
82 ).run(pullId, summary);
83 }
84
85 export function getProofRun(db: Database, runId: string): Run | undefined {
86 const row = db.query<Row, [string]>("SELECT * FROM proof_run WHERE id = ?1").get(runId);
87 return row ? toRun(row) : undefined;
88 }
89
90 /** The newest proof run of a pull, any head. */
91 export function latestProofRun(db: Database, pullId: string): Run | undefined {
92 const row = db
93 .query<Row, [string]>("SELECT * FROM proof_run WHERE issue_id = ?1 ORDER BY id DESC LIMIT 1")
94 .get(pullId);
95 return row ? toRun(row) : undefined;
96 }
97
98 /** Whether a proof of `head` was already cut off for this reason, apart from run `except`. */
99 export function cutOffBefore(db: Database, pullId: string, head: string, reason: string, except: string): boolean {
100 return endedBefore(db, pullId, head, "cancelled", cutSummary(reason), except);
101 }
102
103 /** Whether a proof run of `head` other than `except` ended (or waits) as `state` with `summary`. */
104 export function endedBefore(
105 db: Database,
106 pullId: string,
107 head: string,
108 state: RunState,
109 summary: string,
110 except = "",
111 ): boolean {
112 return (
113 db
114 .query(
115 "SELECT 1 FROM proof_run WHERE issue_id = ?1 AND head = ?2 AND id <> ?3 AND state = ?4 AND summary = ?5 LIMIT 1",
116 )
117 .get(pullId, head, except, state, summary) !== null
118 );
119 }
120
121 /** The summary of a proof cut off for `reason`. */
122 export const cutSummary = (reason: string): string => `cut off: ${reason}`;
123
124 /** A waiting proof starts. */
125 export function startProofRun(db: Database, runId: string, base: string): void {
126 db.query(
127 `UPDATE proof_run SET state = 'running', base = ?2, summary = '', started_at = ${NOW}
128 WHERE id = ?1 AND state = 'queued'`,
129 ).run(runId, base);
130 }
131
132 /** A waiting or running proof ends; one already ended stays as it ended. */
133 export function endProofRun(
134 db: Database,
135 runId: string,
136 state: "ok" | "failed" | "cancelled",
137 summary: string,
138 ): void {
139 db.query(
140 `UPDATE proof_run SET state = ?2, summary = ?3, finished_at = ${NOW}
141 WHERE id = ?1 AND state IN ('queued','running')`,
142 ).run(runId, state, summary.slice(0, 500));
143 }
144
145 /** A repository's proof runs, newest first, after `cursor`. */
146 export function listProofRuns(
147 db: Database,
148 repoId: string,
149 options: { state?: RunState | undefined; issueId?: string | undefined; cursor?: string | undefined; limit: number },
150 ): Run[] {
151 return db
152 .query<Row, [string, string | null, string | null, string | null, number]>(
153 `SELECT * FROM proof_run
154 WHERE repo_id = ?1 AND (?2 IS NULL OR state = ?2) AND (?3 IS NULL OR issue_id = ?3)
155 AND (?4 IS NULL OR id < ?4)
156 ORDER BY id DESC LIMIT ?5`,
157 )
158 .all(repoId, options.state ?? null, options.issueId ?? null, options.cursor ?? null, options.limit)
159 .map(toRun);
160 }
src/core/pulls.ts −2
@@ -9,7 +9,6 @@
99 import { getRepoById } from "./repos.ts";
1010 import { repoDir } from "../git/repos.ts";
1111 import { sealedByNumber } from "./sealed.ts";
12 import { endProofRunsForPull } from "./proof_runs.ts";
1312 import type { Database } from "bun:sqlite";
1413 import { diffRange, type Diff, type DiffLaps } from "../git/diff.ts";
1514 import { isSafeRef } from "../git/guard.ts";
@@ -1222,7 +1221,6 @@ function closeAsMerged(
12221221 landed.headOid,
12231222 pullId,
12241223 );
1225 endProofRunsForPull(db, pullId, "pull landed");
12261224 setIssueState(db, pullId, "closed");
12271225 });
12281226 }
src/core/runs.ts +4 −21
@@ -11,10 +11,8 @@
1111 import type { Database } from "bun:sqlite";
1212 import { BUDGET_PREFIX } from "../kernel/budgets.ts";
1313 import { id } from "../kernel/id.ts";
14 import { getProofRun, listProofRuns } from "./proof_runs.ts";
1514
16 /** `proof` is read from `proof_run` (`core/proof_runs.ts`), never stored in `run`. */
17 export type RunKind = "deploy" | "agent" | "check" | "proof";
15 export type RunKind = "deploy" | "agent" | "check";
1816
1917 /**
2018 * The one summary a run cancelled by the forge's own restart carries — never
@@ -26,7 +24,7 @@ export const STOPPED_SUMMARY = "the forge stopped";
2624
2725 /** The kinds that pass judgement on a commit: a preview's deploy, or a repository's own check. */
2826 export const CHECK_KINDS: readonly RunKind[] = ["deploy", "check"];
29 export const RUN_KINDS: readonly RunKind[] = ["deploy", "agent", "check", "proof"];
27 export const RUN_KINDS: readonly RunKind[] = ["deploy", "agent", "check"];
3028 /** Every state a run can be in — the type is read off this list, so a new state is named here once. */
3129 export const RUN_STATES = ["queued", "running", "ok", "failed", "cancelled"] as const;
3230 export type RunState = (typeof RUN_STATES)[number];
@@ -212,7 +210,7 @@ export function getRun(db: Database, runId: string): Run | undefined {
212210 const row = db
213211 .query<RunRow, [string]>("SELECT * FROM run WHERE id = ?1")
214212 .get(runId);
215 return row ? toRun(row) : getProofRun(db, runId);
213 return row ? toRun(row) : undefined;
216214 }
217215
218216 /**
@@ -694,13 +692,6 @@ export interface ListRunsOptions {
694692 limit?: number | undefined;
695693 /** Resume after this run id (exclusive) — lists run newest first. */
696694 cursor?: string | undefined;
697 /**
698 * With no `kind`, merge in the proof runs (`core/proof_runs.ts`, #7706):
699 * for the lists that show the whole story. Off, the rest of the callers
700 * (counts, "latest run" lookups) read `run` alone. A proof has no
701 * environment, so `env` leaves proofs out.
702 */
703 withProofs?: boolean | undefined;
704695 }
705696
706697 export function listRuns(
@@ -709,14 +700,6 @@ export function listRuns(
709700 options: ListRunsOptions = {},
710701 ): Run[] {
711702 const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);
712 // A proof is read from its own table; the rest of the list is `run`'s.
713 const proofs =
714 options.kind === "proof" || (options.kind === undefined && options.withProofs === true)
715 ? options.env === undefined
716 ? listProofRuns(db, repoId, { state: options.state, issueId: options.issueId, cursor: options.cursor, limit })
717 : []
718 : [];
719 if (options.kind === "proof") return proofs;
720703 const params: (string | number)[] = [repoId];
721704 let where = "repo_id = ?1";
722705 if (options.kind !== undefined) {
@@ -746,7 +729,7 @@ export function listRuns(
746729 )
747730 .all(...params)
748731 .map(toRun);
749 return proofs.length === 0 ? rows : [...rows, ...proofs].sort((a, b) => (a.id < b.id ? 1 : -1)).slice(0, limit);
732 return rows;
750733 }
751734
752735 /** Queued runs of one kind, oldest first — what a worker takes from. */
src/crew/autonomy.ts +1 −79
@@ -24,8 +24,7 @@ import {
2424 setIssueState,
2525 } from "../actions/issues.ts";
2626 import type { AccessError } from "../actions/access.ts";
27 import { adoptGate, gateCandidate, provable, proveCandidate, releaseOnLand } from "../checks/landing.ts";
28 import { current, proofOf, proofRedWords, standing as proofStanding, startProof } from "../checks/proof.ts";
27 import { adoptGate, gateCandidate, releaseOnLand } from "../checks/landing.ts";
2928 import { queueStore } from "../checks/queue_store.ts";
3029 import { addLearned } from "../checks/learned.ts";
3130 import { batchWords, dropFromLine, enqueue, type GateCandidate, type Queued, type QueueOptions, queueClashes, queueWords, waitingPulls } from "../checks/queue.ts";
@@ -1987,14 +1986,6 @@ async function admit(
19871986 // not hold it for good; a red that reproduces stays red (#3155).
19881987 const told = latestDecision(app.db, pull.id, "land");
19891988 if (redOf(told)?.head === pull.headOid && !regateDue(told!, Date.now())) return err("red");
1990 // The proof before the line (#7616): the whole suite, green on this head
1991 // squashed onto the base's tip, or the pull does not join — a test its
1992 // fast tier never reached is found here, at no batch's cost.
1993 const proves = baseTip !== "" && (await provable(app, dir, pull.headOid));
1994 if (proves) {
1995 const wait = await proofWait(app, repo, by, pull, dir, baseTip);
1996 if (wait !== undefined) return err(wait);
1997 }
19981989 // Green only against a base that has moved since is admitted as it is:
19991990 // the candidate's full gate runs on the base tip with this pull merged,
20001991 // which is the proof a re-check of its own would give, and a red one
@@ -2035,78 +2026,9 @@ async function admit(
20352026 addLearned(app.config.dataDir, pull.id, red.learn);
20362027 if (!again) addComment(app, by, repo, getIssueById(app.db, pull.id) ?? pull, clashSaid(red.comment, together));
20372028 },
2038 // A proof the base has moved too far past is proved again before the batch.
2039 ...(proves ? { proven: (baseOid: string) => proofHolds(app, pull, dir, baseOid) } : {}),
20402029 });
20412030 }
20422031
2043 /**
2044 * What a proof says to `pull`, said only while the pull is open on the head
2045 * it proved: a push or a close since makes the words about nothing.
2046 */
2047 export function toldWhileOpen(app: App, repo: Repo, by: User, pull: Pull): (words: string) => void {
2048 return (words) => {
2049 const now = getPull(app.db, repo.id, pull.number);
2050 if (now !== undefined && now.headOid === pull.headOid && now.state === "open")
2051 addComment(app, by, repo, getIssueById(app.db, pull.id) ?? now, words);
2052 };
2053 }
2054
2055 /**
2056 * Why a pull waits on its proof (#7616), or undefined when it is proved on
2057 * `baseTip`. An unproved pull's proof is started here, beside the sweep:
2058 * green admits it at once (`landIfGreen`), red is told to the pull with
2059 * its failing tests named.
2060 */
2061 async function proofWait(
2062 app: App,
2063 repo: Repo,
2064 by: User,
2065 pull: Pull,
2066 dir: string,
2067 baseTip: string,
2068 ): Promise<"proving" | "proof-red" | undefined> {
2069 const at = await proofStanding(app.db, dir, pull, baseTip);
2070 if (at === "proved") return undefined;
2071 if (at === "red") return "proof-red";
2072 if (at === "proving") return "proving";
2073 const started = await startProof({
2074 db: app.db,
2075 repoId: repo.id,
2076 pull,
2077 dir,
2078 tip: baseTip,
2079 run: async (commitOid, signal) => (await proveCandidate(app, repo, pull, dir, commitOid, signal)) ?? { lands: true },
2080 room: () => checksFor(app)?.spareSlots() ?? 0,
2081 told: toldWhileOpen(app, repo, by, pull),
2082 done: (proof, verdict) => {
2083 log.info(`proof ${proof.green ? "green" : "red"}`, {
2084 repo: `${repo.ownerName}/${repo.name}`,
2085 pull: pull.number,
2086 base: proof.base.slice(0, 7),
2087 });
2088 const now = getPull(app.db, repo.id, pull.number);
2089 if (now === undefined || now.headOid !== proof.head || now.state !== "open") return;
2090 if (!verdict.lands && "reopen" in verdict) {
2091 addLearned(app.config.dataDir, pull.id, verdict.learn);
2092 addComment(app, by, repo, getIssueById(app.db, pull.id) ?? now, proofRedWords(pull.baseBranch, proof.base, verdict));
2093 return;
2094 }
2095 void landIfGreen(app, repo, now).catch((error) =>
2096 log.warn("could not land after its proof", { pull: pull.number, error: String(error) }),
2097 );
2098 },
2099 });
2100 if (started) log.info("proving before the line", { repo: `${repo.ownerName}/${repo.name}`, pull: pull.number, base: baseTip.slice(0, 7) });
2101 return "proving";
2102 }
2103
2104 /** The pull's green proof still holds on `baseOid` (#7616). */
2105 async function proofHolds(app: App, pull: Pull, dir: string, baseOid: string): Promise<boolean> {
2106 const proof = proofOf(app.db, pull.id);
2107 return proof !== undefined && proof.green && proof.head === pull.headOid && (await current(dir, proof, baseOid));
2108 }
2109
21102032 /**
21112033 * A red's comment, and when it was red on top of pulls of its candidate
21122034 * that landed first, a line naming them (#4235). The search does not gate
src/crew/waits.ts −2
@@ -21,8 +21,6 @@ export type Wait =
2121 | "carried"
2222 | "wall"
2323 | "review"
24 | "proving"
25 | "proof-red"
2624 | "red";
2725
2826 /** How many approved pulls waited on each reason. */
src/flow/next_actor.ts −23
@@ -7,7 +7,6 @@
77 import type { Database } from "bun:sqlite";
88 import { getHold } from "../core/holds.ts";
99 import type { Pull } from "../core/pulls.ts";
10 import { latestProofRun } from "../core/proof_runs.ts";
1110 import { checkSummary, type RunState } from "../core/runs.ts";
1211 import { reviewSummary } from "../core/reviews.ts";
1312 import { wallAsk, wallWords } from "./wall_wait.ts";
@@ -29,8 +28,6 @@ export interface NextInput {
2928 readonly approvals: number;
3029 /** Verdicts passed on an older head: the pull was pushed to since. */
3130 readonly pushedSince: number;
32 /** What the pull's proof on this head is doing when it is not green (#7706): "waits: no check slot is spare". */
33 readonly proof?: string | null;
3431 /** What the access wall holds this head for, in plain words (#7847): a person decides it. */
3532 readonly wall?: readonly string[] | null;
3633 }
@@ -51,7 +48,6 @@ export function nextActor(i: NextInput): Next {
5148 if (i.check === "failed") return { who: "builder", why: "its check is red" };
5249 if (i.wall != null && i.wall.length > 0) return { who: "person", why: wallAsk(i.wall) };
5350 if (i.approvals > 0) {
54 if (i.check === "ok" && i.proof) return { who: "queue", why: `approved and green, but its proof ${i.proof}` };
5551 if (i.check === "ok") return { who: "queue", why: "approved and green" };
5652 return { who: "queue", why: `approved, ${WAITING[i.check]}` };
5753 }
@@ -61,24 +57,6 @@ export function nextActor(i: NextInput): Next {
6157 };
6258 }
6359
64 /** What the pull's proof on `head` is doing, in words; null when it is green or has not been tried. */
65 export function proofWords(db: Database, pull: Pull, head: string): string | null {
66 const run = latestProofRun(db, pull.id);
67 if (run === undefined || run.commitOid !== head) return null;
68 switch (run.state) {
69 case "queued":
70 return `waits: ${run.summary}`;
71 case "running":
72 return "is running";
73 case "failed":
74 return run.summary === "red" ? "is red" : run.summary;
75 case "cancelled":
76 return `was ${run.summary}; it is proved again on the next sweep`;
77 default:
78 return null;
79 }
80 }
81
8260 /** The next actor of an open pull at `headOid`; null once it is merged or closed. */
8361 export function nextOfPull(
8462 db: Database,
@@ -95,7 +73,6 @@ export function nextOfPull(
9573 changesRequested: reviews.changesRequested,
9674 approvals: reviews.approvals,
9775 pushedSince: reviews.stale,
98 proof: proofWords(db, pull, live.headOid),
9976 wall: wallWords(db, pull.id, live.headOid),
10077 });
10178 }
src/flow/path.ts +1 −1
@@ -1,6 +1,6 @@
11 /**
22 * The one path a change follows, in four words a person can hold (#7703):
3 * check, review, land, live. Every finer step (the check queue, the proof,
3 * check, review, land, live. Every finer step (the check queue,
44 * the merge queue, the release build, the boot check) sits one click down on
55 * /boards; this module only says which of the four a pull is at and, in one
66 * plain sentence, why it waits there.
src/flow/pipeline.ts +2 −3
@@ -248,7 +248,7 @@ export const STEP_WINDOW_DAYS = 30;
248248
249249 /** One measured step of the path: what it is, and how long and how often red it has been. */
250250 export interface StepStat {
251 readonly key: "pull-check" | "review" | "proof" | "gate" | "main-check" | "release" | "boot" | "verify";
251 readonly key: "pull-check" | "review" | "gate" | "main-check" | "release" | "boot" | "verify";
252252 readonly label: string;
253253 /** Finished runs counted (green + red); cancelled ones are no verdict and not counted. */
254254 readonly runs: number;
@@ -296,7 +296,7 @@ const MS = `(julianday(x.finished_at) - julianday(x.started_at)) * 86400000.0`;
296296
297297 /**
298298 * Each step's median time and red rate over the last 30 days, from the runs
299 * table (`run`, `proof_run`) and the merge-queue gate's trace (`check_gate`,
299 * table (`run`) and the merge-queue gate's trace (`check_gate`,
300300 * kept a week and without a verdict, so time only). `publicOnly` reads public
301301 * repositories alone, for a reader who must not learn what private ones hold.
302302 *
@@ -324,7 +324,6 @@ export function readStepStats(db: Database, now = new Date(), publicOnly = false
324324 return [
325325 stat("pull-check", "Pull check", runs("run", `x.kind = 'check' AND x.issue_id IS NOT NULL AND NOT ${verifyLane}`)),
326326 { ...stat("review", "Review run", runs("run", `x.kind = 'agent' AND x.reason = 'review'`)), redWord: "errored" },
327 stat("proof", "Proof", runs("proof_run", "1")),
328327 {
329328 ...gateStat,
330329 runs: gate.length,
src/http/api.ts +1 −1
@@ -3496,7 +3496,7 @@ export function apiRoutes(router: Router<Handler>): void {
34963496 // the branch.
34973497 const runs = timePhase(ctx.request, "runs", () => {
34983498 const listed = listRepoRuns(ctx.app, ctx.auth.user, repo, {
3499 ...(kind === "deploy" || kind === "agent" || kind === "proof" ? { kind } : { withProofs: true }),
3499 ...(kind === "deploy" || kind === "agent" ? { kind } : {}),
35003500 ...(env !== null && isEnvName(env) ? { env } : {}),
35013501 ...(q.get("cursor") ? { cursor: q.get("cursor")! } : {}),
35023502 limit,
src/http/mcp/tools/runs.ts +3 −3
@@ -5,7 +5,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"
55
66 export const tool: Tool = {
77 name: "runs",
8 doc: "`{ repo, kind?, limit? }`: the repository's recent runs (kind agent, deploy, check or proof — a proof is the full-suite run before the merge queue), newest first (id, kind, state, issue or pull number, started, ended), 1–50, default 20; read as the runs page reads them",
8 doc: "`{ repo, kind?, limit? }`: the repository's recent runs (kind agent, deploy or check), newest first (id, kind, state, issue or pull number, started, ended), 1–50, default 20; read as the runs page reads them",
99 description:
1010 "A repository's runs, newest first: id, kind, state, number, start, end.",
1111 inputSchema: {
@@ -14,7 +14,7 @@ export const tool: Tool = {
1414 repo: REPO_ARG,
1515 kind: {
1616 type: "string",
17 enum: ["agent", "deploy", "check", "proof"],
17 enum: ["agent", "deploy", "check"],
1818 description: "Only runs of this kind; all kinds when omitted.",
1919 },
2020 limit: {
@@ -38,7 +38,7 @@ export const tool: Tool = {
3838 if (isAnswer(repo)) return repo;
3939 const runs = listRepoRuns(ctx.app, user, repo, {
4040 limit,
41 ...(kind === undefined ? { withProofs: true } : { kind: kind as RunKind }),
41 ...(kind === undefined ? {} : { kind: kind as RunKind }),
4242 });
4343 if (!runs.ok) return bad("not_found", "no such app or repository (owner/name)");
4444 const answer = good({
src/tui/screens/runlist.ts −1
@@ -48,7 +48,6 @@ export function runTitle(
4848 const pull = run.env.startsWith("pull-") ? Number(run.env.slice(5)) : undefined;
4949 return `Check of ${run.commitOid.slice(0, 7)}${pull === undefined ? "" : ` for #${pull}`}`;
5050 }
51 if (run.kind === "proof") return `Proof of ${run.commitOid.slice(0, 7)}`;
5251 if (run.kind === "deploy") return `deploy ${envLabel(run.env)}`;
5352 const who =
5453 run.actorId === null
src/tui/screens/runs.ts −1
@@ -42,7 +42,6 @@ export function runsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
4242 const read = (): void => {
4343 const listed = listRepoRuns(f.app, f.user, repo, {
4444 state: RUN_STATE_CYCLE[at]![0],
45 withProofs: true,
4645 limit: LIMIT,
4746 });
4847 list.set(listed.ok ? listed.value : []);
src/web/pages/board.ts −1
@@ -155,7 +155,6 @@ const KIND_WORDS: Record<DecisionKind, string> = {
155155 retry: "try again",
156156 lean: "which way",
157157 hand: "hand-over",
158 proof: "proved before the queue",
159158 // Never listed (`listDecisionsForRepos` leaves guards out); named for the type.
160159 guard: "not started",
161160 };
src/web/pages/pipeline.ts +1 −2
@@ -38,7 +38,6 @@ export const STEP_DETAIL: Record<Step, { waits: string; catches: string; inside:
3838 waits: "the merge queue, which merges approved, green pulls into the default branch in order.",
3939 catches: "a pull that no longer merges onto the base as it is now.",
4040 inside: [
41 "Before it joins the queue, a pull is proved once on the base's tip; that proof is being removed (#7779).",
4241 "The merge queue's gate: lint and types on the pull merged with the base's tip.",
4342 "A conflicting pull goes back to its builder to rebase before it can land.",
4443 ],
@@ -58,7 +57,7 @@ export const STEP_DETAIL: Record<Step, { waits: string; catches: string; inside:
5857 export const STEP_MEASURES: Record<Step, readonly StepStat["key"][]> = {
5958 check: ["pull-check"],
6059 review: ["review"],
61 land: ["proof", "gate"],
60 land: ["gate"],
6261 live: ["main-check", "release", "boot", "verify"],
6362 };
6463
src/web/pages/runs.ts +1 −3
@@ -76,8 +76,6 @@ export function runTitle(
7676 const of = `Check of ${run.commitOid.slice(0, 7)}`;
7777 return number === undefined ? of : `${of} for #${number}`;
7878 }
79 if (run.kind === "proof")
80 return `Proof of ${run.commitOid.slice(0, 7)}${issue === undefined ? "" : ` for #${issue.number}`}`;
8179 if (run.kind === "deploy")
8280 return about === "" ? "Deploy production" : `Deploy preview ${about}`;
8381 const who = actor ?? "an agent";
@@ -235,7 +233,7 @@ export function runTitleFrom(
235233 }
236234
237235 const kindIcon = (kind: RunKind) =>
238 icon(kind === "agent" ? "bot" : kind === "check" || kind === "proof" ? "check" : "rocket");
236 icon(kind === "agent" ? "bot" : kind === "check" ? "check" : "rocket");
239237
240238 // ---- rows ----
241239
src/web/studio/lists.ts +3 −5
@@ -603,11 +603,11 @@ export async function changesList(ctx: Ctx): Promise<Response> {
603603 /** Which runs a list shows: all three kinds by default, or the one that
604604 * `?kind=` asks for — the narrowing dev's list offered, kept so a link
605605 * written to it keeps meaning what it said. */
606 type RunsKind = "agent" | "deploy" | "check" | "proof";
606 type RunsKind = "agent" | "deploy" | "check";
607607
608608 function readRunsKind(ctx: Ctx): RunsKind | undefined {
609609 const raw = ctx.url.searchParams.get("kind");
610 return raw === "agent" || raw === "deploy" || raw === "check" || raw === "proof"
610 return raw === "agent" || raw === "deploy" || raw === "check"
611611 ? raw
612612 : undefined;
613613 }
@@ -698,7 +698,6 @@ const RUNS_KIND_WORDS: Record<RunsKind, string> = {
698698 agent: "Agent runs",
699699 deploy: "Deploys",
700700 check: "Checks",
701 proof: "Proofs",
702701 };
703702
704703 /**
@@ -725,7 +724,6 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> {
725724 // Working is what runs now: read only the running ones, so the page
726725 // is not spent on finished rows it would drop.
727726 state: state ?? (filter === "working" ? "running" : undefined),
728 withProofs: true,
729727 limit: LIST_PAGE,
730728 ...(cursor ? { cursor } : {}),
731729 });
@@ -815,7 +813,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> {
815813 )}
816814 </div>
817815 <p class="muted small mt">
818 Show ${(["agent", "deploy", "check", "proof"] as const).map(
816 Show ${(["agent", "deploy", "check"] as const).map(
819817 (k, i) =>
820818 html`${i > 0 ? " · " : ""}${k === kind
821819 ? html`<strong>${RUNS_KIND_WORDS[k]}</strong>`
src/web/ui.ts +2 −2
@@ -369,14 +369,14 @@ export function runStateBadge(state: RunState, kind: RunKind = "deploy"): Html {
369369 case "running":
370370 return badge("Running", { icon: "loader", tone: "attn" });
371371 case "ok":
372 return badge(kind === "agent" ? "Done" : kind === "proof" ? "Green" : "Passed", {
372 return badge(kind === "agent" ? "Done" : "Passed", {
373373 icon: "check",
374374 tone: "success",
375375 });
376376 case "failed":
377377 return badge("Failed", { icon: "x", tone: "danger" });
378378 case "cancelled":
379 return badge(kind === "proof" ? "Cut off" : "Cancelled", { icon: "ban", variant: "outline" });
379 return badge("Cancelled", { icon: "ban", variant: "outline" });
380380 }
381381 }
382382
test/merge-queue-no-proof.test.ts added +104
@@ -0,0 +1,104 @@
1 /**
2 * No proof before the line (#7779). An approved, green pull joins the merge
3 * queue on its own check: the queue's gate is the one full run, and nothing
4 * else is run, kept or told to the pull before it.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { openPull } from "../src/actions/pulls.ts";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { provideChecksForTest, type ChecksHandle } from "../src/checks/service.ts";
14 import { listComments } from "../src/core/issues.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 { nextActor } from "../src/flow/next_actor.ts";
21 import { logLevel, setLogLevel } from "../src/kernel/log.ts";
22
23 let dataDir: string;
24 let app: App;
25 let alice: User;
26 let level: ReturnType<typeof logLevel>;
27 const gated: string[] = [];
28
29 beforeAll(async () => {
30 level = logLevel();
31 setLogLevel("error");
32 dataDir = await mkdtemp(join(tmpdir(), "forge-merge-queue-no-proof-"));
33 app = createApp({ dataDir, port: 0 });
34 const a = await createUser(app.db, {
35 origin: "seed",
36 trusted: true,
37 username: "alice",
38 email: "[email protected]",
39 password: "a-good-passphrase",
40 adminIfFirst: true,
41 });
42 if (!a.ok) throw new Error(a.error);
43 alice = a.value;
44 provideChecksForTest(app, {
45 gate: async (_repo: Repo, _pull: Pull, commitOid: string) => {
46 gated.push(commitOid);
47 return { ok: true, error: "", lines: [] };
48 },
49 spareSlots: () => 0,
50 } as unknown as ChecksHandle);
51 });
52
53 afterAll(async () => {
54 setLogLevel(level);
55 await app.close?.();
56 await rm(dataDir, { recursive: true, force: true });
57 });
58
59 test("an approved green pull joins the merge queue and lands with no proof run", async () => {
60 const made = await createRepo(app, alice, { name: "no-proof", visibility: "private" });
61 if (!made.ok) throw new Error(String(made.error));
62 const seeded = await commitToBranch(app, alice, made.value, {
63 message: "Start",
64 files: [
65 { path: "README.md", content: "# no-proof\n" },
66 { path: "forge.json", content: JSON.stringify({ check: { run: "true", fast: "true" } }) },
67 ],
68 });
69 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
70 app.db.query("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2 WHERE id = ?1").run(made.value.id, alice.id);
71 const repo = getRepoById(app.db, made.value.id)!;
72 const pushed = await commitToBranch(app, alice, repo, {
73 branch: "feature/fine",
74 base: "main",
75 message: "fine",
76 files: [{ path: "fine.ts", content: "export const n = 1;\n" }],
77 });
78 if (!pushed.ok) throw new Error(JSON.stringify(pushed.error));
79 const opened = await openPull(app, alice, repo, { title: "fine", baseBranch: "main", headBranch: "feature/fine" });
80 if (!opened.ok) throw new Error(String(opened.error));
81 const pull = getPull(app.db, repo.id, opened.value.number)!;
82 const check = createRun(app.db, {
83 repoId: repo.id,
84 kind: "check",
85 issueId: pull.id,
86 env: `pull-${pull.number}`,
87 commitOid: pull.headOid,
88 reason: "pull",
89 });
90 claimRun(app.db, check.id);
91 finishRun(app.db, check.id, { state: "ok", summary: "check passed" });
92
93 // Admitted at once: no wait for a proof, so the one sweep lands it.
94 expect(await sweepGreen(app, repo)).toBe(1);
95 expect(getPull(app.db, repo.id, pull.number)!.mergedAt).not.toBeNull();
96 expect(gated).toHaveLength(1);
97 expect(listComments(app.db, pull.id)).toHaveLength(0);
98 expect(app.db.query("SELECT 1 FROM decision WHERE kind = 'proof'").get()).toBeNull();
99 });
100
101 test("the next-actor line of an approved green pull says nothing of a proof", () => {
102 const next = nextActor({ held: null, conflicted: false, check: "ok", changesRequested: 0, approvals: 1, pushedSince: 0 });
103 expect(next).toEqual({ who: "queue", why: "approved and green" });
104 });
test/merge-queue-proof.test.ts deleted −572
@@ -1,572 +0,0 @@
1 /**
2 * The proof before the line (#7616). A pull whose own check was the fast
3 * tier joins the merge queue only once the full suite is green on its head
4 * squashed onto main. The lane here is a stand-in: its proof goes red on a
5 * tree holding a `breaks-*` file (a test the pull's change never reached),
6 * and its gate counts the candidates it was asked about.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { openPull } from "../src/actions/pulls.ts";
14 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import {
16 keepProof,
17 landingsSince,
18 proofOf,
19 proofRedWords,
20 proofsInFlight,
21 RED_PROOF_RETRY_MS,
22 resetProofsForTest,
23 STALE_LANDINGS,
24 standing,
25 startProof,
26 WAIT_MERGE,
27 WAIT_SLOT,
28 } from "../src/checks/proof.ts";
29 import { enqueue, type Queued } from "../src/checks/queue.ts";
30 import { provideChecksForTest, type ChecksHandle } from "../src/checks/service.ts";
31 import { landVerdict, type LandVerdict } from "../src/checks/tiers.ts";
32 import { listComments } from "../src/core/issues.ts";
33 import { openProofRun } from "../src/core/proof_runs.ts";
34 import { getPull, type Pull } from "../src/core/pulls.ts";
35 import { listRuns } from "../src/core/runs.ts";
36 import { nextActor, proofWords } from "../src/flow/next_actor.ts";
37 import { getRepoById, type Repo } from "../src/core/repos.ts";
38 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
39 import { createUser, type User } from "../src/core/users.ts";
40 import { sweepGreen, toldWhileOpen } from "../src/crew/autonomy.ts";
41 import { mergePull } from "../src/actions/pulls.ts";
42 import { setIssueState } from "../src/actions/issues.ts";
43 import { repoDir } from "../src/git/repos.ts";
44 import { logLevel, setLogLevel } from "../src/kernel/log.ts";
45
46 /** A loaded check machine runs git eight times slower: the clock must not decide the verdict. */
47 const SLOW = 120_000;
48
49 describe("the proof before the merge queue", () => {
50 let dataDir: string;
51 let app: App;
52 let alice: User;
53 let level: ReturnType<typeof logLevel>;
54 /** Every proof the lane ran, and every candidate it gated. */
55 const proved: { repo: string; commit: string; ok: boolean }[] = [];
56 const gated: { repo: string; commit: string }[] = [];
57
58 const git = (repo: Repo, ...args: string[]) =>
59 Bun.spawnSync(["git", ...args], { cwd: repoDir(app.reposRoot, repo.ownerName, repo.name) })
60 .stdout.toString()
61 .trim();
62
63 beforeAll(async () => {
64 level = logLevel();
65 setLogLevel("error");
66 dataDir = await mkdtemp(join(tmpdir(), "forge-merge-queue-proof-"));
67 app = createApp({ dataDir, port: 0 });
68 const a = await createUser(app.db, {
69 origin: "seed",
70 trusted: true,
71 username: "alice",
72 email: "[email protected]",
73 password: "a-good-passphrase",
74 adminIfFirst: true,
75 });
76 if (!a.ok) throw new Error(a.error);
77 alice = a.value;
78 provideChecksForTest(app, {
79 gate: async (repo: Repo, _pull: Pull, commitOid: string) => {
80 gated.push({ repo: repo.id, commit: commitOid });
81 return { ok: true, error: "", lines: [] };
82 },
83 prove: async (repo: Repo, _pull: Pull, commitOid: string) => {
84 const files = git(repo, "ls-tree", "-r", "--name-only", commitOid).split("\n");
85 const ok = !files.some((f) => f.startsWith("breaks-"));
86 proved.push({ repo: repo.id, commit: commitOid, ok });
87 return ok
88 ? { ok, error: "", lines: [] }
89 : { ok, error: "red", lines: ["test/unrelated.test.ts:", "(fail) unrelated > still works [1.00ms]"] };
90 },
91 spareSlots: () => 0,
92 } as unknown as ChecksHandle);
93 });
94
95 afterAll(async () => {
96 resetProofsForTest();
97 setLogLevel(level);
98 await app.close?.();
99 await rm(dataDir, { recursive: true, force: true });
100 });
101
102 async function autonomousRepo(name: string, autonomous = true): Promise<Repo> {
103 const made = await createRepo(app, alice, { name, visibility: "private" });
104 if (!made.ok) throw new Error(String(made.error));
105 const seeded = await commitToBranch(app, alice, made.value, {
106 message: "Start",
107 files: [
108 { path: "README.md", content: `# ${name}\n` },
109 { path: "forge.json", content: JSON.stringify({ check: { run: "true", fast: "true" } }) },
110 ],
111 });
112 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
113 if (autonomous) app.db.query("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2 WHERE id = ?1").run(made.value.id, alice.id);
114 return getRepoById(app.db, made.value.id)!;
115 }
116
117 /** A pull whose own (fast) check passed on its head. */
118 async function greenPull(repo: Repo, path: string, content = `export const n = "${path}";\n`): Promise<Pull> {
119 const pushed = await commitToBranch(app, alice, repo, {
120 branch: `feature/${path}`,
121 base: "main",
122 message: path,
123 files: [{ path, content }],
124 });
125 if (!pushed.ok) throw new Error(JSON.stringify(pushed.error));
126 const opened = await openPull(app, alice, repo, { title: path, baseBranch: "main", headBranch: `feature/${path}` });
127 if (!opened.ok) throw new Error(String(opened.error));
128 const pull = getPull(app.db, repo.id, opened.value.number)!;
129 const check = createRun(app.db, {
130 repoId: repo.id,
131 kind: "check",
132 issueId: pull.id,
133 env: `pull-${pull.number}`,
134 commitOid: pull.headOid,
135 reason: "pull",
136 });
137 claimRun(app.db, check.id);
138 finishRun(app.db, check.id, { state: "ok", summary: "check passed" });
139 return pull;
140 }
141
142 const merged = (repo: Repo, pull: Pull) => getPull(app.db, repo.id, pull.number)!.mergedAt !== null;
143 const tree = (repo: Repo, rev: string) => git(repo, "rev-parse", `${rev}^{tree}`);
144 const mainOf = (repo: Repo) => git(repo, "rev-parse", "refs/heads/main");
145 const until = async (ok: () => boolean): Promise<void> => {
146 for (const end = Date.now() + SLOW; !ok() && Date.now() < end; ) await Bun.sleep(5);
147 };
148
149 test("a pull that breaks an unrelated test is never admitted, and is told the test by name", async () => {
150 const repo = await autonomousRepo("breaks-unrelated");
151 const pull = await greenPull(repo, "breaks-other.ts");
152 expect(await sweepGreen(app, repo)).toBe(0);
153 await until(() => proofOf(app.db, pull.id) !== undefined);
154 // Proved on its head squashed onto main, red, and nothing reached the gate.
155 expect(proved.filter((p) => p.repo === repo.id)).toHaveLength(1);
156 const [proof] = proved.filter((p) => p.repo === repo.id);
157 expect(git(repo, "rev-parse", `${proof!.commit}^`)).toBe(mainOf(repo));
158 expect(proofOf(app.db, pull.id)).toMatchObject({ head: pull.headOid, base: mainOf(repo), green: false });
159 await until(() => listComments(app.db, pull.id).length > 0);
160 const told = listComments(app.db, pull.id).map((c) => c.body).join("\n");
161 expect(told).toContain("has not joined the merge queue");
162 expect(told).toContain("- unrelated > still works");
163 // The next sweep neither admits it nor proves the same head again.
164 expect(await sweepGreen(app, repo)).toBe(0);
165 expect(proved.filter((p) => p.repo === repo.id)).toHaveLength(1);
166 expect(gated.filter((g) => g.repo === repo.id)).toHaveLength(0);
167 expect(merged(repo, pull)).toBe(false);
168 });
169
170 test("a green proof admits the pull, which lands on the tree it was proved on", async () => {
171 const repo = await autonomousRepo("proved-green");
172 const pull = await greenPull(repo, "fine.ts");
173 // Not admitted on its fast check alone: proved first, beside the sweep.
174 expect(await sweepGreen(app, repo)).toBe(0);
175 await until(() => merged(repo, pull));
176 expect(merged(repo, pull)).toBe(true);
177 const proofs = proved.filter((p) => p.repo === repo.id);
178 expect(proofs).toHaveLength(1);
179 expect(tree(repo, "refs/heads/main")).toBe(tree(repo, proofs[0]!.commit));
180 expect(gated.filter((g) => g.repo === repo.id)).toHaveLength(1);
181 });
182
183 test("a proof main has moved too far past is proved again on the new main before it joins", async () => {
184 const repo = await autonomousRepo("proof-stale");
185 const pull = await greenPull(repo, "late.ts");
186 const old = mainOf(repo);
187 keepProof(app.db, repo.id, pull.id, { head: pull.headOid, base: old, green: true });
188 for (let i = 0; i <= STALE_LANDINGS; i++) {
189 const moved = await commitToBranch(app, alice, repo, {
190 message: `landing ${i}`,
191 files: [{ path: `main-${i}.ts`, content: `${i}\n` }],
192 });
193 if (!moved.ok) throw new Error(JSON.stringify(moved.error));
194 }
195 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
196 expect(await landingsSince(dir, old, mainOf(repo))).toBe(STALE_LANDINGS + 1);
197 const now = mainOf(repo);
198 expect(await sweepGreen(app, repo)).toBe(0);
199 await until(() => merged(repo, pull));
200 expect(merged(repo, pull)).toBe(true);
201 const proofs = proved.filter((p) => p.repo === repo.id);
202 expect(proofs).toHaveLength(1);
203 expect(git(repo, "rev-parse", `${proofs[0]!.commit}^`)).toBe(now);
204 });
205
206 test("an admitted pull whose proof went stale in the line is left out of its batch, unrun", async () => {
207 const repo = await autonomousRepo("stale-in-line");
208 const pulls = [await greenPull(repo, "ok.ts"), await greenPull(repo, "stale.ts")];
209 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
210 const asked: string[] = [];
211 const entries: Queued[] = pulls.map((pull, i) => ({
212 pull,
213 dir,
214 land: async () => undefined,
215 release: () => {},
216 handBack: () => {},
217 proven: async (base) => {
218 asked.push(base);
219 return i === 0;
220 },
221 }));
222 const steps: number[][] = [];
223 const answers = await enqueue(repo.id, entries, async (candidate) => {
224 steps.push(candidate.steps.map((s) => s.entry.pull.number));
225 return { lands: true };
226 });
227 expect(answers).toEqual([false, false]);
228 // The stale one never reached a gate; the fresh one gated alone.
229 expect(steps).toEqual([[pulls[0]!.number]]);
230 expect(asked.length).toBeGreaterThanOrEqual(2);
231 });
232
233 test("a red proof's words name the base, every failing test and the log", () => {
234 const red = landVerdict(false, ["a > b"], ["test/a.test.ts"], "/api/v1/repos/o/r/gates/x/log", undefined, {
235 passed: 9,
236 failed: 3,
237 failing: [
238 { name: "a > b", errors: [] },
239 { name: "c", errors: [] },
240 ],
241 });
242 if (red.lands) throw new Error("green");
243 const words = proofRedWords("main", "0123456789abcdef", red);
244 expect(words).toContain("squashed onto main (0123456)");
245 expect(words).toContain("- a > b\n- c\n- and 1 more");
246 expect(words).toContain("The gate's log: /api/v1/repos/o/r/gates/x/log");
247 });
248
249 test("a proof that never answers is cut off at its deadline: its slot and its pull are let go, no verdict kept", async () => {
250 const repo = await autonomousRepo("proof-hangs", false);
251 const pull = await greenPull(repo, "hang.ts");
252 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
253 const tip = mainOf(repo);
254 let aborted: AbortSignal | undefined;
255 let answered = false;
256 const started = await startProof({
257 db: app.db,
258 repoId: repo.id,
259 pull,
260 dir,
261 tip,
262 // Hung: answers only once its signal aborts, and then too late to count.
263 run: (_commit, signal) =>
264 new Promise((resolve) => {
265 aborted = signal;
266 signal.addEventListener("abort", () => setTimeout(() => resolve({ lands: true }), 50), { once: true });
267 }),
268 room: () => 0,
269 done: () => {
270 answered = true;
271 },
272 deadlineMs: 100,
273 });
274 expect(started).toBe(true);
275 // Running: it holds a slot, and the pull stands `proving`.
276 expect(proofsInFlight()).toBe(1);
277 expect(await standing(app.db, dir, pull, tip)).toBe("proving");
278 await until(() => proofsInFlight() === 0);
279 expect(aborted?.aborted).toBe(true);
280 expect(proofsInFlight()).toBe(0);
281 expect(await standing(app.db, dir, pull, tip)).toBe("unproved");
282 await Bun.sleep(100);
283 expect(answered).toBe(false);
284 expect(proofOf(app.db, pull.id)).toBeUndefined();
285 });
286
287 test("a red proof stands only on the main it ran on, and only for a while", async () => {
288 const repo = await autonomousRepo("proof-red-retry", false);
289 const pull = await greenPull(repo, "red.txt", "red\n");
290 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
291 const tip = mainOf(repo);
292 keepProof(app.db, repo.id, pull.id, { head: pull.headOid, base: tip, green: false });
293 expect(await standing(app.db, dir, pull, tip)).toBe("red");
294 expect(await standing(app.db, dir, pull, tip, Date.now() + RED_PROOF_RETRY_MS + 1000)).toBe("unproved");
295 const moved = await commitToBranch(app, alice, repo, { message: "on", files: [{ path: "on.txt", content: "on\n" }] });
296 if (!moved.ok) throw new Error(JSON.stringify(moved.error));
297 expect(await standing(app.db, dir, pull, mainOf(repo))).toBe("unproved");
298 });
299
300 test("a head that does not merge onto the tip is not proved, and holds no slot", async () => {
301 const repo = await autonomousRepo("proof-conflict", false);
302 const pull = await greenPull(repo, "README.md", "# theirs\n");
303 const moved = await commitToBranch(app, alice, repo, { message: "ours", files: [{ path: "README.md", content: "# ours\n" }] });
304 if (!moved.ok) throw new Error(JSON.stringify(moved.error));
305 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
306 let ran = false;
307 const started = await startProof({
308 db: app.db,
309 repoId: repo.id,
310 pull,
311 dir,
312 tip: mainOf(repo),
313 run: async () => {
314 ran = true;
315 return { lands: true };
316 },
317 room: () => 1,
318 done: () => {},
319 });
320 expect(started).toBe(false);
321 expect(ran).toBe(false);
322 expect(proofsInFlight()).toBe(0);
323 expect(await standing(app.db, dir, pull, mainOf(repo))).toBe("unproved");
324 });
325
326 test("a proof waits while every check slot is taken, says so once, and starts when one frees", async () => {
327 const repo = await autonomousRepo("proof-slots", false);
328 const first = await greenPull(repo, "one.ts");
329 const second = await greenPull(repo, "two.ts");
330 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
331 const tip = mainOf(repo);
332 let release: (v: LandVerdict) => void = () => {};
333 expect(
334 await startProof({
335 db: app.db, repoId: repo.id, pull: first, dir, tip, room: () => 1, done: () => {},
336 run: () => new Promise<LandVerdict>((resolve) => (release = resolve)),
337 }),
338 ).toBe(true);
339 const told: string[] = [];
340 let free = 0;
341 const again = () =>
342 startProof({
343 db: app.db, repoId: repo.id, pull: second, dir, tip, room: () => free, done: () => {},
344 told: (w) => told.push(w), run: async () => ({ lands: true }),
345 });
346 expect(await again()).toBe(false);
347 expect(await again()).toBe(false);
348 expect(told).toHaveLength(1);
349 expect(told[0]).toContain("no check slot is spare");
350 const waiting = listRuns(app.db, repo.id, { kind: "proof", issueId: second.id });
351 expect(waiting).toHaveLength(1);
352 expect(waiting[0]!.state).toBe("queued");
353 release({ lands: true });
354 await until(() => proofsInFlight() === 0);
355 free = 1;
356 expect(await again()).toBe(true);
357 await until(() => proofsInFlight() === 0);
358 const ran = listRuns(app.db, repo.id, { kind: "proof", issueId: second.id });
359 expect(ran).toHaveLength(1);
360 expect(ran[0]!.id).toBe(waiting[0]!.id);
361 expect(ran[0]!.state).toBe("ok");
362 // The runs list carries proofs among the rest.
363 expect(listRuns(app.db, repo.id, { withProofs: true }).some((r) => r.kind === "proof")).toBe(true);
364 });
365
366 test("a head that does not merge says so on the pull once, and its next names the wait", async () => {
367 const repo = await autonomousRepo("proof-says", false);
368 const pull = await greenPull(repo, "README.md", "# theirs\n");
369 const moved = await commitToBranch(app, alice, repo, { message: "ours", files: [{ path: "README.md", content: "# ours\n" }] });
370 if (!moved.ok) throw new Error(JSON.stringify(moved.error));
371 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
372 const told: string[] = [];
373 const go = () =>
374 startProof({
375 db: app.db, repoId: repo.id, pull, dir, tip: mainOf(repo), room: () => 1, done: () => {},
376 told: (w) => told.push(w), run: async () => ({ lands: true }),
377 });
378 expect(await go()).toBe(false);
379 expect(await go()).toBe(false);
380 expect(told).toHaveLength(1);
381 expect(told[0]).toContain("does not merge");
382 const run = listRuns(app.db, repo.id, { kind: "proof" })[0]!;
383 expect(run.state).toBe("failed");
384 expect(proofWords(app.db, pull, pull.headOid)).toBe(WAIT_MERGE);
385 expect(
386 nextActor({ held: null, conflicted: false, check: "ok", changesRequested: 0, approvals: 1, pushedSince: 0, proof: "cannot start: the head does not merge onto the base tip" }).why,
387 ).toContain("its proof cannot start");
388 });
389
390 test("a proof cut off leaves a cut-off run and a comment, and is proved again", async () => {
391 const repo = await autonomousRepo("proof-cut", false);
392 const pull = await greenPull(repo, "cut.ts");
393 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
394 const tip = mainOf(repo);
395 const told: string[] = [];
396 const go = (run: () => Promise<LandVerdict>, deadlineMs?: number) =>
397 startProof({
398 db: app.db, repoId: repo.id, pull, dir, tip, room: () => 1, done: () => {},
399 told: (w) => told.push(w), run, ...(deadlineMs === undefined ? {} : { deadlineMs }),
400 });
401 expect(await go(() => new Promise(() => {}), 50)).toBe(true);
402 await until(() => proofsInFlight() === 0);
403 const cut = listRuns(app.db, repo.id, { kind: "proof", issueId: pull.id })[0]!;
404 expect(cut.state).toBe("cancelled");
405 expect(cut.summary).toContain("deadline");
406 expect(told).toHaveLength(1);
407 expect(told[0]).toContain("cut off");
408 expect(proofWords(app.db, pull, pull.headOid)).toBe("was cut off: at the deadline; it is proved again on the next sweep");
409 // The next sweep proves it again.
410 expect(await go(async () => ({ lands: true }))).toBe(true);
411 await until(() => proofsInFlight() === 0);
412 expect(listRuns(app.db, repo.id, { kind: "proof", issueId: pull.id })[0]!.state).toBe("ok");
413 });
414
415 test("a proof cut off twice on one head tells the pull once", async () => {
416 const repo = await autonomousRepo("proof-cut-twice", false);
417 const pull = await greenPull(repo, "twice.ts");
418 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
419 const told: string[] = [];
420 const go = () =>
421 startProof({
422 db: app.db, repoId: repo.id, pull, dir, tip: mainOf(repo), room: () => 1, done: () => {},
423 told: (w) => told.push(w), run: () => new Promise(() => {}), deadlineMs: 30,
424 });
425 expect(await go()).toBe(true);
426 await until(() => proofsInFlight() === 0);
427 expect(await go()).toBe(true);
428 await until(() => proofsInFlight() === 0);
429 const runs = listRuns(app.db, repo.id, { kind: "proof", issueId: pull.id });
430 expect(runs.map((r) => r.state)).toEqual(["cancelled", "cancelled"]);
431 expect(told).toHaveLength(1);
432 });
433
434 test("a push ends the old head's waiting proof as superseded", async () => {
435 const repo = await autonomousRepo("proof-superseded", false);
436 const first = await greenPull(repo, "super.ts");
437 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
438 let release: (v: LandVerdict) => void = () => {};
439 const other = await greenPull(repo, "other.ts");
440 expect(
441 await startProof({
442 db: app.db, repoId: repo.id, pull: other, dir, tip: mainOf(repo), room: () => 1, done: () => {},
443 run: () => new Promise<LandVerdict>((resolve) => (release = resolve)),
444 }),
445 ).toBe(true);
446 const wait = () =>
447 startProof({ db: app.db, repoId: repo.id, pull: getPull(app.db, repo.id, first.number)!, dir, tip: mainOf(repo), room: () => 0, done: () => {}, run: async () => ({ lands: true }) });
448 expect(await wait()).toBe(false);
449 const queued = listRuns(app.db, repo.id, { kind: "proof", issueId: first.id });
450 expect(queued.map((r) => r.state)).toEqual(["queued"]);
451 const pushed = await commitToBranch(app, alice, repo, {
452 branch: "feature/super.ts", base: "main", message: "more", files: [{ path: "more.ts", content: "export const m = 1;\n" }],
453 });
454 if (!pushed.ok) throw new Error(JSON.stringify(pushed.error));
455 expect(await wait()).toBe(false);
456 const after = listRuns(app.db, repo.id, { kind: "proof", issueId: first.id });
457 expect(after.map((r) => [r.state, r.summary])).toEqual([
458 ["queued", "no check slot is spare"],
459 ["cancelled", "superseded by a push"],
460 ]);
461 expect(after[0]!.commitOid).not.toBe(queued[0]!.commitOid);
462 release({ lands: true });
463 await until(() => proofsInFlight() === 0);
464 });
465
466 test("a proof a restart left running is cut off and told, then proved again", async () => {
467 const repo = await autonomousRepo("proof-restart", false);
468 const pull = await greenPull(repo, "restart.ts");
469 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
470 openProofRun(app.db, { repoId: repo.id, pullId: pull.id, head: pull.headOid, base: "", state: "running", summary: "" });
471 const told: string[] = [];
472 expect(
473 await startProof({
474 db: app.db, repoId: repo.id, pull, dir, tip: mainOf(repo), room: () => 1, done: () => {},
475 told: (w) => told.push(w), run: async () => ({ lands: true }),
476 }),
477 ).toBe(true);
478 await until(() => proofsInFlight() === 0);
479 const runs = listRuns(app.db, repo.id, { kind: "proof", issueId: pull.id });
480 expect(runs.map((r) => r.state)).toEqual(["ok", "cancelled"]);
481 expect(told[0]).toContain("the forge stopped");
482 });
483
484 test("a gate that fails to answer cuts the proof off and tells the pull once", async () => {
485 const repo = await autonomousRepo("proof-throws", false);
486 const pull = await greenPull(repo, "throws.ts");
487 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
488 const told: string[] = [];
489 const go = () =>
490 startProof({
491 db: app.db, repoId: repo.id, pull, dir, tip: mainOf(repo), room: () => 1, done: () => {},
492 told: (w) => told.push(w), run: () => Promise.reject(new Error("no gate")),
493 });
494 expect(await go()).toBe(true);
495 await until(() => proofsInFlight() === 0);
496 expect(await go()).toBe(true);
497 await until(() => proofsInFlight() === 0);
498 const runs = listRuns(app.db, repo.id, { kind: "proof", issueId: pull.id });
499 expect(runs.map((r) => [r.state, r.summary])).toEqual([
500 ["cancelled", "cut off: the gate failed to answer"],
501 ["cancelled", "cut off: the gate failed to answer"],
502 ]);
503 expect(told).toHaveLength(1);
504 expect(told[0]).toContain("the gate failed to answer");
505 });
506
507 test("a head that cannot merge, then waits for a slot, then cannot merge is told each reason once", async () => {
508 const repo = await autonomousRepo("proof-says-once", false);
509 const pull = await greenPull(repo, "README.md", "# theirs\n");
510 const moved = await commitToBranch(app, alice, repo, { message: "ours", files: [{ path: "README.md", content: "# ours\n" }] });
511 if (!moved.ok) throw new Error(JSON.stringify(moved.error));
512 const other = await greenPull(repo, "busy.ts");
513 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
514 const told: string[] = [];
515 const go = (room: number) =>
516 startProof({
517 db: app.db, repoId: repo.id, pull, dir, tip: mainOf(repo), room: () => room, done: () => {},
518 told: (w) => told.push(w), run: async () => ({ lands: true }),
519 });
520 expect(await go(1)).toBe(false);
521 let release: (v: LandVerdict) => void = () => {};
522 expect(
523 await startProof({
524 db: app.db, repoId: repo.id, pull: other, dir, tip: mainOf(repo), room: () => 1, done: () => {},
525 run: () => new Promise<LandVerdict>((resolve) => (release = resolve)),
526 }),
527 ).toBe(true);
528 expect(await go(0)).toBe(false);
529 release({ lands: true });
530 await until(() => proofsInFlight() === 0);
531 expect(await go(1)).toBe(false);
532 expect(told).toHaveLength(2);
533 expect(told[0]).toContain("does not merge");
534 expect(told[1]).toContain("no check slot is spare");
535 expect(listRuns(app.db, repo.id, { kind: "proof", issueId: pull.id }).map((r) => r.state)).toEqual(["failed", "failed"]);
536 });
537
538 for (const ending of ["merge", "close"] as const)
539 test(`${ending === "merge" ? "landing" : "closing"} a pull ends its waiting proof`, async () => {
540 const repo = await autonomousRepo(`proof-${ending}-ends`, false);
541 const pull = await greenPull(repo, `${ending}-ends.ts`);
542 openProofRun(app.db, { repoId: repo.id, pullId: pull.id, head: pull.headOid, base: "", state: "queued", summary: WAIT_SLOT });
543 if (ending === "merge") {
544 const landed = await mergePull(app, alice, repo, pull, "merge");
545 if (!landed.ok) throw new Error(String(landed.error));
546 } else {
547 const closed = setIssueState(app, alice, repo, pull, "closed");
548 if (!closed.ok) throw new Error(String(closed.error));
549 }
550 const runs = listRuns(app.db, repo.id, { kind: "proof", issueId: pull.id });
551 expect(runs.map((r) => [r.state, r.summary])).toEqual([["cancelled", ending === "merge" ? "pull landed" : "pull closed"]]);
552 });
553
554 test("a proof's words reach the pull only while it is open on the head proved", async () => {
555 const repo = await autonomousRepo("proof-told-guard", false);
556 const pull = await greenPull(repo, "guard.ts");
557 const said = () => listComments(app.db, pull.id).filter((c) => c.body.startsWith("guard:")).length;
558 toldWhileOpen(app, repo, alice, pull)("guard: current");
559 expect(said()).toBe(1);
560 const pushed = await commitToBranch(app, alice, repo, {
561 branch: "feature/guard.ts", base: "main", message: "more", files: [{ path: "guard2.ts", content: "export const g = 2;\n" }],
562 });
563 if (!pushed.ok) throw new Error(JSON.stringify(pushed.error));
564 toldWhileOpen(app, repo, alice, pull)("guard: stale head");
565 expect(said()).toBe(1);
566 const now = getPull(app.db, repo.id, pull.number)!;
567 const closed = setIssueState(app, alice, repo, now, "closed");
568 if (!closed.ok) throw new Error(String(closed.error));
569 toldWhileOpen(app, repo, alice, now)("guard: closed");
570 expect(said()).toBe(1);
571 });
572 });
test/next_actor.test.ts −1
@@ -19,7 +19,6 @@ const cases: [string, Partial<NextInput>, string, string][] = [
1919 ["no review", { check: "running" }, "reviewer", "no review yet"],
2020 ["approved and green", { approvals: 1, check: "ok" }, "queue", "approved and green"],
2121 ["approved, no check", { approvals: 1 }, "queue", "approved, waiting for its check"],
22 ["approved and green, proof waits", { approvals: 1, check: "ok", proof: "waits: no check slot is spare" }, "queue", "approved and green, but its proof waits: no check slot is spare"],
2322 ["approved, queued", { approvals: 1, check: "queued" }, "queue", "approved, its check is queued"],
2423 ["approved, cancelled", { approvals: 1, check: "cancelled" }, "queue", "approved, its check was cancelled"],
2524 ["approved, running", { approvals: 1, check: "running" }, "queue", "approved, its check is running"],
test/pipeline-steps.test.ts +4 −24
@@ -31,13 +31,6 @@ function seed(kind: string, state: string, mins: number, daysAgo: number, set: R
3131 .run(state, new Date(end - mins * MIN).toISOString(), new Date(end).toISOString(), set["issue"] ?? null, set["env"] ?? "", run.id);
3232 }
3333
34 function proof(state: string, mins: number, daysAgo: number, repo = repoId) {
35 const end = Date.now() - daysAgo * DAY;
36 app.db
37 .query("INSERT INTO proof_run (id, repo_id, issue_id, head, base, state, summary, started_at, finished_at) VALUES (?1, ?2, ?3, 'h', 'b', ?4, '', ?5, ?6)")
38 .run(`p${Math.random()}`, repo, repo === repoId ? pullIssueId : privIssueId, state, new Date(end - mins * MIN).toISOString(), new Date(end).toISOString());
39 }
40
4134 function gate(mins: number | null, daysAgo: number, repo = repoId) {
4235 const end = Date.now() - daysAgo * DAY;
4336 app.db
@@ -46,7 +39,6 @@ function gate(mins: number | null, daysAgo: number, repo = repoId) {
4639 }
4740
4841 let pullIssueId = "";
49 let privIssueId = "";
5042
5143 beforeAll(async () => {
5244 dataDir = await mkdtemp(join(tmpdir(), "forge-steps-"));
@@ -67,7 +59,6 @@ beforeAll(async () => {
6759 if (!po.ok) throw new Error(String(po.error));
6860 const privIssue = po.value.id;
6961 pullIssueId = pullIssue;
70 privIssueId = privIssue;
7162 // Pull checks: 4, 6, 10 minutes green and one 20-minute red → median 8m, 25% red.
7263 seed("check", "ok", 4, 1, { issue: pullIssue });
7364 seed("check", "ok", 6, 2, { issue: pullIssue });
@@ -82,11 +73,6 @@ beforeAll(async () => {
8273 // Verify lane: a verify and a verify-bisect check; even one tied to a pull is not a pull check.
8374 seed("check", "ok", 10, 1, { env: "verify" });
8475 seed("check", "failed", 30, 1, { env: "verify-bisect", issue: pullIssue });
85 // Proofs: 5 and 15 minutes green, 40 red → median 15m, 1 of 3 red; a private one is hidden.
86 proof("ok", 5, 1);
87 proof("ok", 15, 2);
88 proof("failed", 40, 3);
89 proof("failed", 1, 1, privateId);
9076 // Merge-queue gates: 6 and 12 minutes; an open one and a private one do not count.
9177 gate(6, 1);
9278 gate(12, 2);
@@ -124,10 +110,6 @@ test("a step's median time and red rate follow from its runs in the last 30 days
124110 expect(by["verify"]?.runs).toBe(2);
125111 expect(by["verify"]?.medianMs).toBeCloseTo(20 * MIN, -2);
126112 expect(by["verify"]?.redRate).toBe(0.5);
127 // 5, 15, 40 → median 15m; 1 of 3 red; the private proof is cut.
128 expect(by["proof"]?.runs).toBe(3);
129 expect(by["proof"]?.medianMs).toBeCloseTo(15 * MIN, -2);
130 expect(by["proof"]?.redRate).toBeCloseTo(1 / 3, 10);
131113 // Gates: time only — 6 and 12 → median 9m; no verdict; the open and private rows are cut.
132114 expect(by["gate"]?.runs).toBe(2);
133115 expect(by["gate"]?.medianMs).toBeCloseTo(9 * MIN, -2);
@@ -140,7 +122,6 @@ test("a step's median time and red rate follow from its runs in the last 30 days
140122 expect(statWords(by["pull-check"] as never)).toBe("median 8m · 25% red (1 of 4)");
141123 const all = Object.fromEntries(readStepStats(app.db, new Date(), false).map((s) => [s.key, s]));
142124 expect(all["pull-check"]?.runs).toBe(5);
143 expect(all["proof"]?.runs).toBe(4);
144125 expect(all["gate"]?.runs).toBe(3);
145126 });
146127
@@ -169,14 +150,13 @@ test("the terminal's Flow lines carry the steps, private repositories for an adm
169150 expect(admin).toContain("Merge-queue gate: median 12m");
170151 const agent = flowLines(app.db, new Date(), undefined, { queues: [] }).join(" ").replace(/\s+/g, " ");
171152 expect(agent).toContain("Merge-queue gate: median 9m");
172 expect(admin).toContain("Proof: median");
173153 });
174154
175155 test("the MCP flow tool and the API return the steps, an agent's cut to public repositories", () => {
176156 const owner = flowFor(app, { kind: "owner" } as never).steps;
177157 const agent = flowFor(app, { kind: "agent" } as never).steps;
178 const proofOf = (steps: typeof owner) => steps.find((s) => s.key === "proof");
179 expect(proofOf(owner)?.runs).toBe(4);
180 expect(proofOf(agent)?.runs).toBe(3);
181 expect(proofOf(owner)?.says).toContain("median");
158 const gateOf = (steps: typeof owner) => steps.find((s) => s.key === "gate");
159 expect(gateOf(owner)?.runs).toBe(3);
160 expect(gateOf(agent)?.runs).toBe(2);
161 expect(owner.some((s) => s.key === ("proof" as never))).toBe(false);
182162 });
test/proof-runs.test.ts deleted −176
@@ -1,176 +0,0 @@
1 /**
2 * A proof is a run (#7706): the doors read it — MCP `runs`, the API, the
3 * terminal's runs list, `/runs/<id>` — and a push ends the old head's wait.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createRepo } from "../src/actions/repos.ts";
10 import { createApp, type App } from "../src/app.ts";
11 import { createIssue } from "../src/core/issues.ts";
12 import { endProofRun, getProofRun, openProofRun } from "../src/core/proof_runs.ts";
13 import type { Repo } from "../src/core/repos.ts";
14 import { createRun, getRun, listRuns } from "../src/core/runs.ts";
15 import { createToken } from "../src/core/tokens.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { createForgeApp } from "../src/tui/forge.ts";
19 import { runsScreen } from "../src/tui/screens/runs.ts";
20 import { headlessTerminal } from "../src/tui/term/terminal.ts";
21 import { runStateBadge } from "../src/web/ui.ts";
22 import { quietLogs } from "./helpers/globals.ts";
23 import { settleApp } from "./helpers/tui.ts";
24
25 quietLogs();
26
27 describe("proof runs through the doors", () => {
28 let dataDir: string;
29 let app: App;
30 let servers: Servers;
31 let alice: User;
32 let repo: Repo;
33 let pullId: string;
34 let token: string;
35 let rpc = 1;
36
37 const mcp = async (args: unknown) => {
38 const res = await fetch(`${servers.url}/mcp`, {
39 method: "POST",
40 headers: {
41 "content-type": "application/json",
42 accept: "application/json, text/event-stream",
43 authorization: `Bearer ${token}`,
44 },
45 body: JSON.stringify({ jsonrpc: "2.0", id: rpc++, method: "tools/call", params: { name: "runs", arguments: args } }),
46 });
47 const body: any = await res.json();
48 const text: string = body.result.content[0].text;
49 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
50 };
51
52 beforeAll(async () => {
53 dataDir = await mkdtemp(join(tmpdir(), "forge-proof-runs-"));
54 app = createApp({ dataDir, port: 0 });
55 servers = await serve(app);
56 const u = await createUser(app.db, {
57 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
58 password: "a-good-passphrase", adminIfFirst: true,
59 });
60 if (!u.ok) throw new Error(u.error);
61 alice = u.value;
62 token = createToken(app.db, alice.id, "ro", "ro").token;
63 const made = await createRepo(app, alice, { name: "vault", visibility: "private" });
64 if (!made.ok) throw new Error(String(made.error));
65 repo = made.value;
66 const issue = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title: "A pull" });
67 if (!issue.ok) throw new Error(issue.error);
68 pullId = issue.value.id;
69 }, 30_000);
70
71 afterAll(async () => {
72 servers.stop();
73 app.close();
74 await rm(dataDir, { recursive: true, force: true });
75 });
76
77 const open = (head: string, state: "queued" | "running" = "queued", summary = "no check slot is spare") =>
78 openProofRun(app.db, { repoId: repo.id, pullId, head, base: "b".repeat(40), state, summary });
79
80 test("a new head ends the old head's waiting proof as superseded, and leaves the new one queued", () => {
81 const old = open("a".repeat(40));
82 const fresh = open("c".repeat(40));
83 const ended = getProofRun(app.db, old.id)!;
84 expect(ended.state).toBe("cancelled");
85 expect(ended.summary).toBe("superseded by a push");
86 expect(ended.finishedAt).not.toBeNull();
87 expect(getProofRun(app.db, fresh.id)!.state).toBe("queued");
88 endProofRun(app.db, fresh.id, "ok", "green");
89 });
90
91 test("MCP runs lists proofs unfiltered and filters by kind=proof; a bad kind is refused", async () => {
92 createRun(app.db, { repoId: repo.id, kind: "deploy", env: "production", reason: "push" });
93 const proof = open("d".repeat(40), "running", "");
94 const all = await mcp({ repo: "alice/vault" });
95 expect(all.value.runs.map((r: any) => r.kind)).toContain("proof");
96 const only = await mcp({ repo: "alice/vault", kind: "proof" });
97 expect(only.value.runs.length).toBeGreaterThan(0);
98 expect(only.value.runs.every((r: any) => r.kind === "proof")).toBe(true);
99 expect(only.value.runs[0].id).toBe(proof.id);
100 const deploys = await mcp({ repo: "alice/vault", kind: "deploy" });
101 expect(deploys.value.runs.every((r: any) => r.kind === "deploy")).toBe(true);
102 expect((await mcp({ repo: "alice/vault", kind: "bogus" })).error).toContain("kind");
103 endProofRun(app.db, proof.id, "failed", "red");
104 });
105
106 test("the API filters ?kind=proof", async () => {
107 const res = await fetch(`${servers.url}/api/v1/repos/alice/vault/runs?kind=proof`, {
108 headers: { authorization: `Bearer ${token}` },
109 });
110 const body: any = await res.json();
111 const items = body.items ?? body.runs ?? body;
112 expect(items.length).toBeGreaterThan(0);
113 expect(items.every((r: any) => r.kind === "proof")).toBe(true);
114 });
115
116 test("callers that omit kind still read only `run`; the lists ask for proofs", () => {
117 expect(listRuns(app.db, repo.id).some((r) => r.kind === "proof")).toBe(false);
118 expect(listRuns(app.db, repo.id, { withProofs: true }).some((r) => r.kind === "proof")).toBe(true);
119 expect(listRuns(app.db, repo.id, { kind: "proof", env: "production" })).toEqual([]);
120 });
121
122 test("the merged list pages across both tables with no run lost or repeated", () => {
123 createRun(app.db, { repoId: repo.id, kind: "deploy", env: "production", reason: "push" });
124 open("9".repeat(40), "running", "");
125 createRun(app.db, { repoId: repo.id, kind: "agent", reason: "assigned" });
126 const seen: string[] = [];
127 let cursor: string | undefined;
128 for (let i = 0; i < 20; i++) {
129 const page = listRuns(app.db, repo.id, { withProofs: true, limit: 2, ...(cursor ? { cursor } : {}) });
130 if (page.length === 0) break;
131 seen.push(...page.map((r) => r.id));
132 cursor = page[page.length - 1]!.id;
133 }
134 const whole = listRuns(app.db, repo.id, { withProofs: true, limit: 200 }).map((r) => r.id);
135 expect(seen).toEqual(whole);
136 expect(new Set(seen).size).toBe(seen.length);
137 expect([...whole].sort().reverse()).toEqual(whole);
138 expect(whole.length).toBeGreaterThanOrEqual(7);
139 });
140
141 test("the badges name a proof's states Green, Cut off and Failed", () => {
142 expect(String(runStateBadge("ok", "proof"))).toContain("Green");
143 expect(String(runStateBadge("cancelled", "proof"))).toContain("Cut off");
144 expect(String(runStateBadge("queued", "proof"))).toContain("Queued");
145 expect(String(runStateBadge("ok", "check"))).toContain("Passed");
146 });
147
148 test("/runs/<id> opens a proof run, and its short door finds it", async () => {
149 const run = open("e".repeat(40), "running", "");
150 endProofRun(app.db, run.id, "ok", "green");
151 expect(getRun(app.db, run.id)!.kind).toBe("proof");
152 const page = await fetch(`${servers.url}/alice/vault/runs/${run.id}`, { headers: { authorization: `Bearer ${token}` } });
153 expect(page.status).toBe(200);
154 expect(await page.text()).toContain("Proof of eeeeeee");
155 const api = await fetch(`${servers.url}/api/v1/repos/alice/vault/runs/${run.id}`, { headers: { authorization: `Bearer ${token}` } });
156 expect(((await api.json()) as any).kind).toBe("proof");
157 });
158
159 test("the terminal's runs list shows a proof row with its summary", async () => {
160 const run = open("f".repeat(40), "queued", "no check slot is spare");
161 const { app: tui, session } = createForgeApp(
162 app,
163 { sshUsername: "alice", remoteAddress: "127.0.0.1", user: alice },
164 { terminal: headlessTerminal(140, 30), exitHooks: false },
165 );
166 tui.start();
167 await settleApp(tui);
168 tui.push(runsScreen(session, tui.ctx, repo));
169 await settleApp(tui);
170 const text = tui.screen.toText();
171 expect(text).toContain("Proof of fffffff");
172 expect(text).toContain("no check slot is spare");
173 tui.stop();
174 endProofRun(app.db, run.id, "cancelled", "cut off: the forge stopped");
175 });
176 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs