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

A proof is a run with a state, and a proof that cannot start or is cut off says why on the pull #7708

Merged opened this

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

Merged

scout/forge:scout/7706-a-proof-is-a-run-with-a-state-and-a-proo → main 5 commits fork 4 threads · 4 unresolved

  • Merged. scout/7706-a-proof-is-a-run-with-a-state-and-a-proo was merged into main as 7099b12.

Review

Sign in to review this change.
21 files changed +809 −26
docs/CREW.md +13
@@ -181,6 +181,19 @@ again before it joins, and a pull already in the line is left out of its
181181 next batch until it is proved again. A proof that has not answered by the
182182 gate's deadline (15 minutes) is cut off and no verdict is kept, so a hung
183183 one never holds its pull or a check slot.
184
185 A **proof** is a run of kind `proof`: the full suite on a pull's head squashed
186 onto the base, before the pull joins the merge queue. It is `queued` while no
187 check slot is spare, `running`, then `ok` (shown *Green*), `failed` (red) or
188 `cancelled` (shown *Cut off*: the deadline passed or the forge restarted; it is
189 proved again on the next sweep). A push ends the old head's unfinished proof as
190 `cancelled`, "superseded by a push". The run's summary says why a proof waited
191 or could not start, and the pull is told once per head in a comment and in its
192 next step ("approved and green, but its proof waits: no check slot is spare").
193 Read them in the runs list (`?kind=proof`), the terminal's Runs screen, the API
194 (`GET …/runs?kind=proof`) and the MCP `runs` tool (`kind: "proof"`); only those
195 lists mix proofs in with the other kinds.
196
184197 The queue builds one candidate: main, then each queued pull's squash on top of the one before, in
185198 queue order, leaving out any pull that conflicts or has nothing left to land.
186199 A candidate carries at most `queue.batch` pulls (Settings → Capacity,
docs/MCP.md +1 −1
@@ -149,7 +149,7 @@ for a tunnel and a forge that sleeps.
149149 | `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 |
150150 | `rollback` | `{ 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 |
151151 | `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 | |
152 | `runs` | `{ repo, limit? }`: the repository's recent runs, newest first (id, kind, state, issue or pull number, started, ended), 1–50, default 20; read as the runs page reads them | |
152 | `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 | |
153153 | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes |
154154 | `scorecard` | (forge admin) no input: the crew's scorecard per agent, all time and 30 days: landed, review rounds, red rate, reverts | |
155155 | `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 |
src/checks/proof.ts +86 −9
@@ -17,17 +17,23 @@
1717 * proof stands for its head until a push, or until the base has moved that
1818 * far — a red that was main's may be gone from a newer main.
1919 *
20 * A proof cut off (the forge stopping, the Job lost) is no verdict: nothing
21 * is kept, and the next sweep proves it again. One that has not answered by
22 * the gate's own deadline (`GATE_DEADLINE_MS`, #3430) is cut off the same
23 * way, its run aborted, so a hung proof never holds its pull or a check slot
24 * for good. One proof runs at a time per
25 * pull, and only one beyond the first while no check slot is spare.
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.
2630 */
2731
2832 import type { Database } from "bun:sqlite";
2933 import { latestDecision, recordDecision } from "../core/decisions.ts";
34 import { cutOffBefore, cutSummary, endedBefore, endProofRun, latestProofRun, openProofRun, startProofRun } from "../core/proof_runs.ts";
3035 import type { Pull } from "../core/pulls.ts";
36 import { STOPPED_SUMMARY } from "../core/runs.ts";
3137 import { runGit } from "../git/env.ts";
3238 import { mergeable } from "../git/merge.ts";
3339 import { GATE_DEADLINE_MS } from "./queue.ts";
@@ -160,18 +166,69 @@ export interface ProofRun {
160166 readonly room: () => number;
161167 /** The proof was judged and kept. */
162168 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;
163171 /** How long the proof may run before it is cut off; the gate's deadline unless a test shortens it. */
164172 readonly deadlineMs?: number;
165173 }
166174
175 /** Why a proof waits or cannot start: the run's summary, and what the pull is told. */
outdated

This dedupe only compares the latest run. Look for any run of this head with the same summary, as cutOffBefore does, so merge-fail, slot-wait, merge-fail does not tell the pull twice.

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
167211 /**
168212 * Start proving `pull` on `tip` beside the caller, which does not wait.
169213 * False when it did not start: a proof of this head runs already, no slot
170 * is free, or the head does not merge onto the tip.
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.
171216 */
172217 export async function startProof(x: ProofRun): Promise<boolean> {
173218 if (proving.has(x.pull.id)) return false;
174 if (proving.size > 0 && x.room() <= 0) 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 }
175232 proving.set(x.pull.id, x.pull.headOid);
176233 let commit: string | undefined;
177234 try {
@@ -181,13 +238,28 @@ export async function startProof(x: ProofRun): Promise<boolean> {
181238 }
182239 if (commit === undefined) {
183240 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 );
184246 return false;
185247 }
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 }
186256 const abandon = new AbortController();
187257 let timer: ReturnType<typeof setTimeout> | undefined;
258 let deadline = false;
188259 // A proof that never answers is cut off at the deadline: no verdict (#3430).
189260 const cut = new Promise<LandVerdict>((resolve) => {
190261 timer = setTimeout(() => {
262 deadline = true;
191263 abandon.abort();
192264 resolve({ lands: false, interrupted: true });
193265 }, x.deadlineMs ?? GATE_DEADLINE_MS);
@@ -198,14 +270,19 @@ export async function startProof(x: ProofRun): Promise<boolean> {
198270 // Released before the answer is acted on: `done` may sweep, and must not read it as running.
199271 clearTimeout(timer);
200272 proving.delete(x.pull.id);
201 if ("interrupted" in verdict) return;
273 if ("interrupted" in verdict) {
274 cutOff(x, runId, x.pull.headOid, deadline ? "at the deadline" : "the gate was interrupted");
275 return;
276 }
202277 const proof: Proof = { head: x.pull.headOid, base: x.tip, green: verdict.lands };
203278 keepProof(x.db, x.repoId, x.pull.id, proof);
279 endProofRun(x.db, runId, verdict.lands ? "ok" : "failed", verdict.lands ? "green" : "red");
204280 x.done(proof, verdict);
205281 },
206282 () => {
207283 clearTimeout(timer);
208284 proving.delete(x.pull.id);
285 cutOff(x, runId, x.pull.headOid, "the gate failed to answer");
209286 },
210287 );
211288 return true;
src/core/issues.ts +3
@@ -9,6 +9,7 @@ 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";
1213
1314 export type IssueKind = "issue" | "pull";
1415 export type IssueState = "open" | "closed";
@@ -548,6 +549,8 @@ export function setIssueState(
548549 WHERE id = ?4 AND state != ?1`,
549550 )
550551 .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");
551554 return changed > 0 ? getIssueById(db, issueId) : undefined;
552555 }
553556
src/core/migration-pins/20261101120000-proof-run.pin added +1
@@ -0,0 +1,1 @@
1 750769103b9d02aa1baf5fb82fb9084cbcd197123a4d19fe0b69c57da9403d14
src/core/migrations/20261101120000-proof-run.ts added +23
@@ -0,0 +1,23 @@
1 // A proof is a run with a state (#7706): the full suite on a pull's head
2 // squashed onto the base, before the pull joins the merge queue. `run` has a
3 // CHECK on its kind that SQLite cannot widen without a rebuild an old process
4 // would not survive (#875), so a proof's rows sit beside it and are read as
5 // runs of kind `proof` (`core/proof_runs.ts`). `state` is a run's: queued
6 // (waiting for a check slot), running, ok, failed, cancelled (cut off).
7 export default `
8 CREATE TABLE IF NOT EXISTS proof_run (
9 id TEXT PRIMARY KEY,
10 repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
11 issue_id TEXT NOT NULL REFERENCES issue(id) ON DELETE CASCADE,
12 head TEXT NOT NULL,
13 base TEXT NOT NULL DEFAULT '',
14 state TEXT NOT NULL DEFAULT 'queued'
15 CHECK (state IN ('queued','running','ok','failed','cancelled')),
16 summary TEXT NOT NULL DEFAULT '',
17 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
18 started_at TEXT,
19 finished_at TEXT
20 );
21 CREATE INDEX IF NOT EXISTS proof_run_repo ON proof_run(repo_id, id DESC);
22 CREATE INDEX IF NOT EXISTS proof_run_issue ON proof_run(issue_id, id DESC);
23 `;
src/core/proof_runs.ts added +160
@@ -0,0 +1,160 @@
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(
outdated

Unfinished proof runs are ended only when a new head opens a run. Also end them when the pull merges or closes, otherwise they sit 'Queued' forever.

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,6 +9,7 @@
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";
1213 import type { Database } from "bun:sqlite";
1314 import { diffRange, type Diff, type DiffLaps } from "../git/diff.ts";
1415 import { isSafeRef } from "../git/guard.ts";
@@ -1088,6 +1089,7 @@ function closeAsMerged(
10881089 landed.headOid,
10891090 pullId,
10901091 );
1092 endProofRunsForPull(db, pullId, "pull landed");
10911093 setIssueState(db, pullId, "closed");
10921094 });
10931095 }
src/core/runs.ts +24 −5
@@ -11,8 +11,10 @@
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";
1415
15 export type RunKind = "deploy" | "agent" | "check";
16 /** `proof` is read from `proof_run` (`core/proof_runs.ts`), never stored in `run`. */
17 export type RunKind = "deploy" | "agent" | "check" | "proof";

RunKind gains "proof" but RUN_KINDS (line 27) does not; kind filters will reject it. Add it there.

1618
1719 /**
1820 * The one summary a run cancelled by the forge's own restart carries — never
@@ -24,7 +26,7 @@ export const STOPPED_SUMMARY = "the forge stopped";
2426
2527 /** The kinds that pass judgement on a commit: a preview's deploy, or a repository's own check. */
2628 export const CHECK_KINDS: readonly RunKind[] = ["deploy", "check"];
27 export const RUN_KINDS: readonly RunKind[] = ["deploy", "agent", "check"];
29 export const RUN_KINDS: readonly RunKind[] = ["deploy", "agent", "check", "proof"];
2830 /** Every state a run can be in — the type is read off this list, so a new state is named here once. */
2931 export const RUN_STATES = ["queued", "running", "ok", "failed", "cancelled"] as const;
3032 export type RunState = (typeof RUN_STATES)[number];
@@ -206,7 +208,7 @@ export function getRun(db: Database, runId: string): Run | undefined {
206208 const row = db
207209 .query<RunRow, [string]>("SELECT * FROM run WHERE id = ?1")
208210 .get(runId);
209 return row ? toRun(row) : undefined;
211 return row ? toRun(row) : getProofRun(db, runId);
210212 }
211213
212214 /**
@@ -688,6 +690,13 @@ export interface ListRunsOptions {
688690 limit?: number | undefined;
689691 /** Resume after this run id (exclusive) — lists run newest first. */
690692 cursor?: string | undefined;
693 /**
694 * With no `kind`, merge in the proof runs (`core/proof_runs.ts`, #7706):
695 * for the lists that show the whole story. Off, the rest of the callers
696 * (counts, "latest run" lookups) read `run` alone. A proof has no
697 * environment, so `env` leaves proofs out.
698 */
699 withProofs?: boolean | undefined;
691700 }
692701
693702 export function listRuns(
@@ -695,6 +704,15 @@ export function listRuns(
695704 repoId: string,
696705 options: ListRunsOptions = {},
697706 ): Run[] {
707 const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);
708 // A proof is read from its own table; the rest of the list is `run`'s.
709 const proofs =
710 options.kind === "proof" || (options.kind === undefined && options.withProofs === true)
711 ? options.env === undefined
712 ? listProofRuns(db, repoId, { state: options.state, issueId: options.issueId, cursor: options.cursor, limit })
713 : []
714 : [];
715 if (options.kind === "proof") return proofs;
698716 const params: (string | number)[] = [repoId];
699717 let where = "repo_id = ?1";
700718 if (options.kind !== undefined) {
@@ -717,13 +735,14 @@ export function listRuns(
717735 params.push(options.cursor);
718736 where += ` AND id < ?${params.length}`;
719737 }
720 params.push(Math.min(Math.max(options.limit ?? 50, 1), 200));
721 return db
738 params.push(limit);
739 const rows = db
722740 .query<RunRow, (string | number)[]>(
723741 `SELECT * FROM run WHERE ${where} ORDER BY id DESC LIMIT ?${params.length}`,
724742 )
725743 .all(...params)
726744 .map(toRun);
745 return proofs.length === 0 ? rows : [...rows, ...proofs].sort((a, b) => (a.id < b.id ? 1 : -1)).slice(0, limit);
727746 }
728747
729748 /** Queued runs of one kind, oldest first — what a worker takes from. */
src/crew/autonomy.ts +13
@@ -2075,6 +2075,18 @@ async function admit(
20752075 });
20762076 }
20772077
2078 /**
2079 * What a proof says to `pull`, said only while the pull is open on the head
2080 * it proved: a push or a close since makes the words about nothing.
2081 */
2082 export function toldWhileOpen(app: App, repo: Repo, by: User, pull: Pull): (words: string) => void {
2083 return (words) => {
2084 const now = getPull(app.db, repo.id, pull.number);
2085 if (now !== undefined && now.headOid === pull.headOid && now.state === "open")
2086 addComment(app, by, repo, getIssueById(app.db, pull.id) ?? now, words);
2087 };
2088 }
2089
20782090 /**
20792091 * Why a pull waits on its proof (#7616), or undefined when it is proved on
20802092 * `baseTip`. An unproved pull's proof is started here, beside the sweep:
@@ -2101,6 +2113,7 @@ async function proofWait(
21012113 tip: baseTip,
21022114 run: async (commitOid, signal) => (await proveCandidate(app, repo, pull, dir, commitOid, signal)) ?? { lands: true },
21032115 room: () => checksFor(app)?.spareSlots() ?? 0,
2116 told: toldWhileOpen(app, repo, by, pull),
21042117 done: (proof, verdict) => {
21052118 log.info(`proof ${proof.green ? "green" : "red"}`, {
21062119 repo: `${repo.ownerName}/${repo.name}`,
src/flow/next_actor.ts +23
@@ -7,6 +7,7 @@
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";
1011 import { checkSummary, type RunState } from "../core/runs.ts";
1112 import { reviewSummary } from "../core/reviews.ts";
1213
@@ -27,6 +28,8 @@ export interface NextInput {
2728 readonly approvals: number;
2829 /** Verdicts passed on an older head: the pull was pushed to since. */
2930 readonly pushedSince: number;
31 /** What the pull's proof on this head is doing when it is not green (#7706): "waits: no check slot is spare". */
32 readonly proof?: string | null;
3033 }
3134
3235 const WAITING = {
@@ -44,6 +47,7 @@ export function nextActor(i: NextInput): Next {
4447 return { who: "builder", why: "changes were asked on this head" };
4548 if (i.check === "failed") return { who: "builder", why: "its check is red" };
4649 if (i.approvals > 0) {
50 if (i.check === "ok" && i.proof) return { who: "queue", why: `approved and green, but its proof ${i.proof}` };
4751 if (i.check === "ok") return { who: "queue", why: "approved and green" };
4852 return { who: "queue", why: `approved, ${WAITING[i.check]}` };
4953 }
@@ -53,6 +57,24 @@ export function nextActor(i: NextInput): Next {
5357 };
5458 }
5559
60 /** What the pull's proof on `head` is doing, in words; null when it is green or has not been tried. */
61 export function proofWords(db: Database, pull: Pull, head: string): string | null {
62 const run = latestProofRun(db, pull.id);
63 if (run === undefined || run.commitOid !== head) return null;
64 switch (run.state) {
65 case "queued":
66 return `waits: ${run.summary}`;
67 case "running":
68 return "is running";
69 case "failed":
70 return run.summary === "red" ? "is red" : run.summary;
71 case "cancelled":
72 return `was ${run.summary}; it is proved again on the next sweep`;
73 default:
74 return null;
75 }
76 }
77
5678 /** The next actor of an open pull at `headOid`; null once it is merged or closed. */
5779 export function nextOfPull(
5880 db: Database,
@@ -69,5 +91,6 @@ export function nextOfPull(
6991 changesRequested: reviews.changesRequested,
7092 approvals: reviews.approvals,
7193 pushedSince: reviews.stale,
94 proof: proofWords(db, pull, live.headOid),
7295 });
7396 }
src/http/api.ts +1 −1
@@ -3551,7 +3551,7 @@ export function apiRoutes(router: Router<Handler>): void {
35513551 // the branch.
35523552 const runs = timePhase(ctx.request, "runs", () => {
35533553 const listed = listRepoRuns(ctx.app, ctx.auth.user, repo, {
3554 ...(kind === "deploy" || kind === "agent" ? { kind } : {}),
3554 ...(kind === "deploy" || kind === "agent" || kind === "proof" ? { kind } : { withProofs: true }),
35553555 ...(env !== null && isEnvName(env) ? { env } : {}),
35563556 ...(q.get("cursor") ? { cursor: q.get("cursor")! } : {}),
35573557 limit,
src/http/mcp/tools/runs.ts +14 −2
@@ -1,16 +1,22 @@
11 import { listRepoRuns } from "../../../actions/runs.ts";
2 import { RUN_KINDS, type RunKind } from "../../../core/runs.ts";
23 import { getIssueById } from "../../../core/issues.ts";
34 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
45
56 export const tool: Tool = {
67 name: "runs",
7 doc: "`{ repo, limit? }`: the repository's recent runs, 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, 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",
89 description:
910 "A repository's runs, newest first: id, kind, state, number, start, end.",
1011 inputSchema: {
1112 type: "object",
1213 properties: {
1314 repo: REPO_ARG,
15 kind: {
16 type: "string",
17 enum: ["agent", "deploy", "check", "proof"],
18 description: "Only runs of this kind; all kinds when omitted.",
19 },
1420 limit: {
1521 type: "integer",
1622 minimum: 1,
@@ -25,9 +31,15 @@ export const tool: Tool = {
2531 const limit = args["limit"] ?? 20;
2632 if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 50)
2733 return bad("bad_limit", "limit is a whole number from 1 to 50");
34 const kind = args["kind"];
35 if (kind !== undefined && !RUN_KINDS.some((k) => k === kind))
36 return bad("bad_kind", `kind is one of ${RUN_KINDS.join(", ")}`);
2837 const repo = repoArg(ctx, user, args);
2938 if (isAnswer(repo)) return repo;
30 const runs = listRepoRuns(ctx.app, user, repo, { limit });
39 const runs = listRepoRuns(ctx.app, user, repo, {
40 limit,
41 ...(kind === undefined ? { withProofs: true } : { kind: kind as RunKind }),
42 });
3143 if (!runs.ok) return bad("not_found", "no such app or repository (owner/name)");
3244 const answer = good({
3345 runs: runs.value.map((r) => ({
src/tui/screens/runlist.ts +1
@@ -48,6 +48,7 @@ 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)}`;
5152 if (run.kind === "deploy") return `deploy ${envLabel(run.env)}`;
5253 const who =
5354 run.actorId === null
src/tui/screens/runs.ts +1
@@ -42,6 +42,7 @@ 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,
4546 limit: LIMIT,
4647 });
4748 list.set(listed.ok ? listed.value : []);
src/web/pages/runs.ts +3 −1
@@ -76,6 +76,8 @@ 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}`}`;
7981 if (run.kind === "deploy")
8082 return about === "" ? "Deploy production" : `Deploy preview ${about}`;
8183 const who = actor ?? "an agent";
@@ -233,7 +235,7 @@ export function runTitleFrom(
233235 }
234236
235237 const kindIcon = (kind: RunKind) =>
236 icon(kind === "agent" ? "bot" : kind === "check" ? "check" : "rocket");
238 icon(kind === "agent" ? "bot" : kind === "check" || kind === "proof" ? "check" : "rocket");
237239
238240 // ---- rows ----
239241
src/web/studio/lists.ts +5 −3
@@ -581,11 +581,11 @@ export async function changesList(ctx: Ctx): Promise<Response> {
581581 /** Which runs a list shows: all three kinds by default, or the one that
582582 * `?kind=` asks for — the narrowing dev's list offered, kept so a link
583583 * written to it keeps meaning what it said. */
584 type RunsKind = "agent" | "deploy" | "check";
584 type RunsKind = "agent" | "deploy" | "check" | "proof";
585585
586586 function readRunsKind(ctx: Ctx): RunsKind | undefined {
587587 const raw = ctx.url.searchParams.get("kind");
588 return raw === "agent" || raw === "deploy" || raw === "check"
588 return raw === "agent" || raw === "deploy" || raw === "check" || raw === "proof"
589589 ? raw
590590 : undefined;
591591 }
@@ -637,6 +637,7 @@ const RUNS_KIND_WORDS: Record<RunsKind, string> = {
637637 agent: "Agent runs",
638638 deploy: "Deploys",
639639 check: "Checks",
640 proof: "Proofs",
640641 };
641642
642643 /**
@@ -661,6 +662,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> {
661662 const listed = listRepoRuns(ctx.app, ctx.auth.user, repo, {
662663 kind,
663664 state,
665 withProofs: true,
664666 limit: LIST_PAGE,
665667 ...(cursor ? { cursor } : {}),
666668 });
@@ -754,7 +756,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> {
754756 )}
755757 </div>
756758 <p class="muted small mt">
757 Show ${(["agent", "deploy", "check"] as const).map(
759 Show ${(["agent", "deploy", "check", "proof"] as const).map(
758760 (k, i) =>
759761 html`${i > 0 ? " · " : ""}${k === kind
760762 ? 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" : "Passed", {
372 return badge(kind === "agent" ? "Done" : kind === "proof" ? "Green" : "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("Cancelled", { icon: "ban", variant: "outline" });
379 return badge(kind === "proof" ? "Cut off" : "Cancelled", { icon: "ban", variant: "outline" });
380380 }
381381 }
382382
test/merge-queue-proof.test.ts +256 −2
@@ -23,16 +23,23 @@ import {
2323 STALE_LANDINGS,
2424 standing,
2525 startProof,
26 WAIT_MERGE,
27 WAIT_SLOT,
2628 } from "../src/checks/proof.ts";
2729 import { enqueue, type Queued } from "../src/checks/queue.ts";
2830 import { provideChecksForTest, type ChecksHandle } from "../src/checks/service.ts";
29 import { landVerdict } from "../src/checks/tiers.ts";
31 import { landVerdict, type LandVerdict } from "../src/checks/tiers.ts";
3032 import { listComments } from "../src/core/issues.ts";
33 import { openProofRun } from "../src/core/proof_runs.ts";
3134 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";
3237 import { getRepoById, type Repo } from "../src/core/repos.ts";
3338 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
3439 import { createUser, type User } from "../src/core/users.ts";
35 import { sweepGreen } from "../src/crew/autonomy.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";
3643 import { repoDir } from "../src/git/repos.ts";
3744 import { logLevel, setLogLevel } from "../src/kernel/log.ts";
3845
@@ -315,4 +322,251 @@ describe("the proof before the merge queue", () => {
315322 expect(proofsInFlight()).toBe(0);
316323 expect(await standing(app.db, dir, pull, mainOf(repo))).toBe("unproved");
317324 });
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 });
318572 });
test/next_actor.test.ts +1
@@ -19,6 +19,7 @@ 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"],
2223 ["approved, queued", { approvals: 1, check: "queued" }, "queue", "approved, its check is queued"],
2324 ["approved, cancelled", { approvals: 1, check: "cancelled" }, "queue", "approved, its check was cancelled"],
2425 ["approved, running", { approvals: 1, check: "running" }, "queue", "approved, its check is running"],
test/proof-runs.test.ts added +176
@@ -0,0 +1,176 @@
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