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

A stale green holds back the inbox's look ask and disables the merge button (after #1179) #2288

Merged opened this

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

Merged

scout/forge:scout/2028-a-stale-green-holds-back-the-inbox-s-loo → main 5 commits fork 1 thread · 1 unresolved

  • Merged. scout/2028-a-stale-green-holds-back-the-inbox-s-loo was merged into main as 61f5295.

Review

Sign in to review this change.
7 files changed +267 −21
README.md +1 −1
@@ -1291,7 +1291,7 @@ Invariants are proven by faults, not by hope. No dependencies were added.
12911291
12921292 `forge.json`'s `check` may declare `fast` beside `run` (the full gate; `full` names it explicitly). `bun scripts/test-shards.ts --tier fast --diff <base>..<head>` runs the tests that touch the diff (changed, named for a changed source file, or importing one within two hops), every test the times table records under 1 s, and any named by `--learned a,b`. `src/checks/tiers.ts` decides; it adds no dependency.
12931293
1294 In the forge itself: a pull's check runs `fast` when the manifest has one, in a clone holding the fork point and the head as `FORGE_CHECK_BASE` and `HEAD` (`origin/main` is not a ref there), with `FORGE_CHECK_LEARNED` naming tests earlier land attempts taught the pull (`src/checks/history.ts`, `learned.ts`). Landing (`landIfGreen`) runs `full` once on the merge with its base before merging (`src/checks/landing.ts`); red comments on the pull with the tests named, is remembered per head and base, and the files ride the pull's next fast run. The pull page's checks line shows the tier words. The checks line (page and terminal) also names the base tip the fast check merged with ("checked against main at abc1234"); `GET …/pulls/:number/checks` returns it as `run.baseOid`, empty for a default-branch push and for the `full` land gate. When the base has moved since that green, the page's line reads "Checked against an older main" (a clock, not a green mark) and the terminal's reads `checks: passed against an older main`. `GET /api/v1/repos/:owner/:name/pulls/:number` and the MCP `pull` tool carry `checkStale`, true only for an open, unmerged pull whose last check recorded a base that is no longer the base branch's tip.
1294 In the forge itself: a pull's check runs `fast` when the manifest has one, in a clone holding the fork point and the head as `FORGE_CHECK_BASE` and `HEAD` (`origin/main` is not a ref there), with `FORGE_CHECK_LEARNED` naming tests earlier land attempts taught the pull (`src/checks/history.ts`, `learned.ts`). Landing (`landIfGreen`) runs `full` once on the merge with its base before merging (`src/checks/landing.ts`); red comments on the pull with the tests named, is remembered per head and base, and the files ride the pull's next fast run. The pull page's checks line shows the tier words. The checks line (page and terminal) also names the base tip the fast check merged with ("checked against main at abc1234"); `GET …/pulls/:number/checks` returns it as `run.baseOid`, empty for a default-branch push and for the `full` land gate. When the base has moved since that green, the page's line reads "Checked against an older main" (a clock, not a green mark) and the terminal's reads `checks: passed against an older main`. `GET /api/v1/repos/:owner/:name/pulls/:number` and the MCP `pull` tool carry `checkStale`, true only for an open, unmerged pull whose last check recorded a base that is no longer the base branch's tip. With `require_checks` on, such a stale green counts as a check still to come (`checksHold` in `src/checks/stale.ts`): the page reads "Waiting on checks" and disables **Merge pull request**, the terminal refuses `m` the same way, and the inbox's `look` ask and an autonomous repository's `review` ask wait for the re-check. The ask side works from the stale-ask sweep's last read of the base tip, so it drops after the next sweep, not the instant the base moves. `mergePull` itself does not yet refuse a stale green (API and MCP merges are not held).
12951295
12961296 While the default branch's own last check is red, the forge holds its pulls' checks queued rather than fail each one on the same test (`src/checks/red_main.ts`): the vitals carry one card naming the failing test and how many checks wait, and main going green releases them. A check a person asks for again runs at once, and no check is held more than 20 minutes, so the pull that fixes main can always show green.
12971297
src/checks/stale.ts +14
@@ -13,3 +13,17 @@ export function checkStale(
1313 if (baseTip === undefined || baseTip === "") return false;
1414 return run.baseOid !== baseTip;
1515 }
16
17 /**
18 * Whether `require_checks` holds a merge back: the live head's check is not
19 * green, or is green only against an older base — a check still to come.
20 * The page and the terminal read this one answer, so neither offers a merge
21 * the other would not.
22 */
23 export function checksHold(
24 requireChecks: boolean,
25 state: string,
26 stale: boolean,
27 ): boolean {
28 return requireChecks && (state !== "ok" || stale);
29 }
src/crew/stale_asks.ts +28 −5
@@ -18,6 +18,7 @@ import { joinMany, pullStatus } from "../core/pulls.ts";
1818 import { getIssuesByIds } from "../core/issues.ts";
1919 import { getRepoById } from "../core/repos.ts";
2020 import { latestRunsForIssues } from "../core/runs.ts";
21 import { checkStale } from "../checks/stale.ts";
2122 import { resolveOid } from "../git/merge.ts";
2223 import { repoDir } from "../git/repos.ts";
2324
@@ -27,6 +28,8 @@ interface Known {
2728 /** The pull's head the answer was taken at, as the database names it. */
2829 readonly head: string;
2930 readonly stale: boolean;
31 /** The base's tip when it was last read; empty when it could not be. */
32 readonly base: string;
3033 /** The files that stop it merging; 0 when it merges or is simply already in. */
3134 readonly conflicts: number;
3235 /** When a sweep last saw it go from not merging to merging: its ask is new from then. */
@@ -51,6 +54,21 @@ export function isStalePull(
5154 return known !== undefined && known.stale && known.head === headOid;
5255 }
5356
57 /**
58 * Whether this pull's green was checked against an older tip of its base, as
59 * the last sweep read that tip (#1179's `checkStale`). A tip not yet read is
60 * no answer, and the green stands.
61 */
62 export function staleGreen(
63 db: Database,
64 pull: { readonly id: string; readonly headOid: string },
65 check: { readonly baseOid: string } | null | undefined,
66 ): boolean {
67 const known = knownByDb.get(db)?.get(pull.id);
68 if (known === undefined || known.head !== pull.headOid) return false;
69 return checkStale(check ?? null, known.base);
70 }
71
5472 /**
5573 * How many files stop this pull, at this head, from merging, as the last
5674 * sweep found it: 0 when it merges, or when no sweep has answered yet.
@@ -96,7 +114,6 @@ export async function sweepStaleAsks(app: App): Promise<void> {
96114 .query<{ id: string }, [number]>(
97115 `SELECT issue.id AS id FROM issue JOIN repo ON repo.id = issue.repo_id
98116 WHERE issue.kind = 'pull' AND issue.state = 'open'
99 AND repo.autonomy <> 'autonomous'
100117 ORDER BY issue.updated_at DESC LIMIT ?1`,
101118 )
102119 .all(SWEEP_LIMIT)
@@ -115,10 +132,14 @@ export async function sweepStaleAsks(app: App): Promise<void> {
115132 (await resolveOid(dir, `refs/heads/${pull.baseBranch}`)) ?? "";
116133 const between = `${pull.headOid}:${base}`;
117134 if (known.get(pull.id)?.between === between) continue;
118 const status = await pullStatus(dir, pull);
135 // An autonomous repository settles its own mergeability; only the
136 // base tip is read there, for the stale green.
137 const status =
138 repo.autonomy === "autonomous" ? undefined : await pullStatus(dir, pull);
119139 const stale =
120 status.ahead === 0 ||
121 (!status.mergeable && status.conflicts.length > 0);
140 status !== undefined &&
141 (status.ahead === 0 ||
142 (!status.mergeable && status.conflicts.length > 0));
122143 const was = known.get(pull.id);
123144 const mergesSince =
124145 !stale && was?.stale === true && was.head === pull.headOid
@@ -130,7 +151,9 @@ export async function sweepStaleAsks(app: App): Promise<void> {
130151 between,
131152 head: pull.headOid,
132153 stale,
133 conflicts: status.mergeable ? 0 : status.conflicts.length,
154 base,
155 conflicts:
156 status === undefined || status.mergeable ? 0 : status.conflicts.length,
134157 ...(mergesSince === undefined ? {} : { mergesSince }),
135158 });
136159 } catch (error) {
src/tui/screens/pull.ts +34 −9
@@ -49,7 +49,8 @@ import { proposedByText } from "../../core/proposal_token.ts";
4949 import { getAppByRepo } from "../../core/apps.ts";
5050 import { getIssue, type Issue } from "../../core/issues.ts";
5151 import { isProposed, pullMayRun } from "../../core/pull_trust.ts";
52 import { checkStale } from "../../checks/stale.ts";
52 import { askCheck } from "../../checks/again.ts";
53 import { checkStale, checksHold } from "../../checks/stale.ts";
5354 import { resolveOid } from "../../git/merge.ts";
5455 import { getPull, pullDir, variantSiblings, type Pull } from "../../core/pulls.ts";
5556 import { voteVariant } from "../../actions/variants.ts";
@@ -471,7 +472,7 @@ export function pullScreen(
471472 * `checks_required` on its own.
472473 */
473474 const checksGated = (): boolean =>
474 repo !== undefined && requireChecks && check.state !== "ok";
475 repo !== undefined && checksHold(requireChecks, check.state, checkOld);
475476
476477 /** `O` — the preview, through a grant when the app is private. */
477478 // Production beside the preview: comparing the two is the review.
@@ -729,6 +730,7 @@ export function pullScreen(
729730 ? [
730731 binding(["c"], tab === 1 ? "review line" : "comment"),
731732 binding(["R"], "review"),
733 binding(["C"], "check again"),
732734 ...(mayVote() ? [binding(["+", "-"], "like/dislike a variant")] : []),
733735 ...(mayApproveHere()
734736 ? [
@@ -829,6 +831,7 @@ export function pullScreen(
829831 drawMergeBox(s, boxRow!, c, issue, facts, view.summary(), mirrored(), {
830832 gated: gated(),
831833 checksGated: checksGated(),
834 checkOld,
832835 check,
833836 checksLine,
834837 standing: standing(),
@@ -1016,8 +1019,24 @@ export function pullScreen(
10161019 );
10171020 return true;
10181021 }
1022 // `C` asks for the check again at the head, on the base as it is now.
1023 if (e.key === "C" && mayWrite() && repo !== undefined && f.user !== undefined) {
1024 const user = f.user;
1025 const target = repo;
1026 const row = getPull(f.db, target.id, number);
1027 if (row === undefined) return true;
1028 runAsync(ctx, async () => {
1029 const asked = await askCheck(f.app, user, target, row);
1030 ctx.toast(
1031 asked.ok ? "check asked for at the current head" : `no check was asked for: ${asked.message}`,
1032 asked.ok ? "success" : "danger",
1033 );
1034 if (asked.ok) await reload();
1035 });
1036 return true;
1037 }
10191038 if (e.key === "m" && mayWrite() && state() === "checks_required") {
1020 ctx.toast(checksRefusal(check), "attn");
1039 ctx.toast(checksRefusal(check, checkOld), "attn");
10211040 return true;
10221041 }
10231042 if (
@@ -1249,7 +1268,9 @@ export function checksText(
12491268 }
12501269
12511270 /** Why `m` is refused by the checks gate, in words a person can act on. */
1252 export function checksRefusal(check: CheckSummary): string {
1271 export function checksRefusal(check: CheckSummary, old = false): string {
1272 if (old && check.state === "ok")
1273 return "checked against an older base; press C to check again";
12531274 switch (check.state) {
12541275 case "failed":
12551276 return `the deploy of this head failed${check.run?.summary ? `: ${check.run.summary}` : ""}`;
@@ -1302,6 +1323,8 @@ function drawMergeBox(
13021323 o: {
13031324 gated: boolean;
13041325 checksGated: boolean;
1326 /** The green was checked against an older base. */
1327 checkOld: boolean;
13051328 check: CheckSummary;
13061329 /** The checks row, already worded; "" draws none. */
13071330 checksLine: string;
@@ -1351,11 +1374,13 @@ function drawMergeBox(
13511374 ? `Changes requested by ${o.standing.blocking.join(", ")}`
13521375 : "Needs an approval of this head before it can merge"
13531376 : state === "checks_required"
1354 ? o.check.state === "failed"
1355 ? "The deploy of this head failed; it cannot merge until one passes"
1356 : o.check.state === "running" || o.check.state === "queued"
1357 ? "Waiting for the deploy of this head to pass"
1358 : "Needs a passing deploy of this head before it can merge"
1377 ? o.check.state === "ok"
1378 ? "Waiting on checks: checked against an older base; press C to check again"
1379 : o.check.state === "failed"
1380 ? "The deploy of this head failed; it cannot merge until one passes"
1381 : o.check.state === "running" || o.check.state === "queued"
1382 ? "Waiting for the deploy of this head to pass"
1383 : "Needs a passing deploy of this head before it can merge"
13591384 : `Ready to merge · ${summary}`;
13601385 const x = badge(s, inner.x, inner.y, c, {
13611386 text:
src/web/pages/pull.ts +7 −3
@@ -35,7 +35,7 @@ import {
3535 } from "../../core/pulls.ts";
3636 import { isProposed, pullMayRun } from "../../core/pull_trust.ts";
3737 import { checkWords } from "../../checks/summary.ts";
38 import { checkStale } from "../../checks/stale.ts";
38 import { checkStale, checksHold } from "../../checks/stale.ts";
3939 import { checkSummary, type CheckSummary } from "../../core/runs.ts";
4040 import {
4141 anchorKey,
@@ -492,9 +492,13 @@ function mergeBox(
492492 // The check gate, `mergePull`'s `checks_required`: the deploy of the
493493 // commit that is head right now must have passed. Read live here so the
outdated

This gates the page only. The terminal merge (src/tui/screens/pull.ts:474) uses check.state alone, so the same stale green is mergeable there. Share one predicate between the two doors.

494494 // button is honest — the refusal itself happens in the merge.
495 if (repo.requireChecks && check.state !== "ok") {
495 if (checksHold(repo.requireChecks, check.state, stale)) {
496496 const why =
497 check.state === "failed"
497 check.state === "ok"
498 ? html`This head was checked against an older
499 <span class="mono">${pull.baseBranch}</span>. Use Check again to judge
500 it on the current base; the merge unlocks when that passes.`
501 : check.state === "failed"
498502 ? html`The deploy of this head failed. Push a fix — a passing deploy is
499503 required before merging.`
500504 : check.state === "running" || check.state === "queued"
src/web/studio/asks.ts +6 −3
@@ -58,7 +58,7 @@ import {
5858 import { breakerParked, WAITING_NOTES } from "../../crew/breaker.ts";
5959 import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts";
6060 import { handoverLines, handoverOf, type Handover } from "../../crew/guard.ts";
61 import { conflictsOf, isStalePull, mergesAgainSince } from "../../crew/stale_asks.ts";
61 import { conflictsOf, isStalePull, mergesAgainSince, staleGreen } from "../../crew/stale_asks.ts";
6262 import { reviewAttempts, waitsForPerson, waitsForReviewer, type ReviewWait } from "../../crew/review.ts";
6363 import type { Ctx } from "../../http/context.ts";
6464 import { repoBase } from "../chrome.ts";
@@ -379,7 +379,9 @@ function askCandidatesFor(
379379 repo.autonomy !== "autonomous" &&
380380 // A green pull that no longer merges as it stands cannot be shipped:
381381 // bringing it up to date is work, not a person's decision.
382 !isStalePull(db, pull.id, pull.headOid)
382 !isStalePull(db, pull.id, pull.headOid) &&
383 // A green checked against an older base is a check still to come.
384 !staleGreen(db, pull, check)
383385 ) {
384386 // An agent's green pull waits for its reviewer first: it is the
385387 // crew's to wait on, and reaches the owner with the verdict (#1602).
@@ -403,7 +405,8 @@ function askCandidatesFor(
403405 } else if (
404406 check?.state === "ok" &&
405407 repo.autonomy === "autonomous" &&
406 !isStalePull(db, pull.id, pull.headOid)
408 !isStalePull(db, pull.id, pull.headOid) &&
409 !staleGreen(db, pull, check)
407410 ) {
408411 // An autonomous repository lands its own green pulls once reviewed;
409412 // an agent's change with no reviewer, or whose review lapsed, waits
test/check-stale-gate.test.ts added +177
@@ -0,0 +1,177 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { openPull } from "../src/actions/pulls.ts";
6 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { claimRun, createRun, finishRun, setRunBase } from "../src/core/runs.ts";
9 import { getPull, type Pull } from "../src/core/pulls.ts";
10 import { updateRepoSettings, type Repo } from "../src/core/repos.ts";
11 import { createSession } from "../src/core/sessions.ts";
12 import { createUser, type User } from "../src/core/users.ts";
13 import { sweepStaleAsks } from "../src/crew/stale_asks.ts";
14 import { resolveOid } from "../src/git/merge.ts";
15 import { repoDir } from "../src/git/repos.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { askLinesFor } from "../src/web/studio/asks.ts";
18 import { createForgeApp } from "../src/tui/forge.ts";
19 import { pullScreen } from "../src/tui/screens/pull.ts";
20 import { local } from "../src/tui/screens/where.ts";
21 import { headlessTerminal } from "../src/tui/term/terminal.ts";
22 import { settleApp } from "./helpers/tui.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24
25 quietLogs();
26
27 describe("a green checked against an older main waits for its re-check", () => {
28 let dataDir: string;
29 let app: App;
30 let servers: Servers;
31 let alice: User;
32 let repo: Repo;
33 let pull: Pull;
34 let cookie: string;
35
36 const put = async (branch: string, path: string, content: string) => {
37 const done = await commitToBranch(app, alice, repo, {
38 branch,
39 message: `edit ${path}`,
40 files: [{ path, content }],
41 });
42 if (!done.ok) throw new Error(JSON.stringify(done.error));
43 };
44 const mainTip = async () =>
45 (await resolveOid(repoDir(app.reposRoot, "alice", "lib"), "refs/heads/main"))!;
46 const green = async (baseOid: string) => {
47 const run = createRun(app.db, {
48 repoId: repo.id,
49 kind: "check",
50 issueId: pull.id,
51 env: "pull-1",
52 commitOid: pull.headOid,
53 });
54 claimRun(app.db, run.id);
55 finishRun(app.db, run.id, { state: "ok", summary: "all green" });
56 setRunBase(app.db, run.id, baseOid);
57 };
58 const looks = () =>
59 askLinesFor(app.db, alice).some((r) => r.items.includes(`look:${pull.id}`));
60 const mergeButton = async () => {
61 const res = await fetch(`${servers.url}/alice/lib/pulls/${pull.number}/files`, {
62 headers: { cookie },
63 });
64 const page = await res.text();
65 const at = page.indexOf("Merge pull request");
66 expect(at).toBeGreaterThan(-1);
67 return { page, tag: page.slice(page.lastIndexOf("<button", at), at) };
68 };
69
70 // The terminal's pull screen, after the keys, as text.
71 const terminalText = async (keys: string[] = []) => {
72 const { app: tui, session } = createForgeApp(
73 app,
74 {
75 sshUsername: "alice",
76 remoteAddress: "127.0.0.1",
77 fingerprint: "SHA256:alice",
78 user: alice,
79 },
80 { terminal: headlessTerminal(140, 44), exitHooks: false },
81 );
82 tui.start();
83 try {
84 tui.ctx.push(pullScreen(session, tui.ctx, local(session, repo), pull.number));
85 const end = Date.now() + 20_000;
86 while (!tui.screen.toText().includes("Feat")) {
87 if (Date.now() > end) throw new Error(tui.screen.toText());
88 await settleApp(tui);
89 await Bun.sleep(20);
90 }
91 await settleApp(tui);
92 for (const key of keys) {
93 tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
94 await settleApp(tui);
95 }
96 return tui.screen.toText();
97 } finally {
98 tui.stop();
99 }
100 };
101
102 beforeAll(async () => {
103 dataDir = await mkdtemp(join(tmpdir(), "forge-stale-gate-"));
104 app = createApp({ dataDir, port: 0, host: "forge.test" });
105 servers = await serve(app);
106 const made = await createUser(app.db, {
107 trusted: true,
108 username: "alice",
109 email: "[email protected]",
110 password: "a-good-passphrase",
111 adminIfFirst: true,
112 });
113 if (!made.ok) throw new Error(made.error);
114 alice = made.value;
115 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
116 const r = await createRepo(app, alice, { name: "lib", visibility: "public" });
117 if (!r.ok) throw new Error(String(r.error));
118 repo = updateRepoSettings(app.db, r.value, { requireChecks: true });
119 await put("main", "code.txt", "top\nbase\n");
120 await put("feat", "feat.txt", "feature\n");
121 const opened = await openPull(app, alice, repo, {
122 title: "Feat",
123 baseBranch: "main",
124 headBranch: "feat",
125 });
126 if (!opened.ok) throw new Error(String(opened.error));
127 pull = getPull(app.db, repo.id, opened.value.number)!;
128 await green(await mainTip());
129 await sweepStaleAsks(app);
130 }, 120_000);
131
132 afterAll(async () => {
133 servers.stop();
134 app.close();
135 await rm(dataDir, { recursive: true, force: true });
136 });
137
138 test("a green on the current main asks for a look and offers the merge", async () => {
139 expect(looks()).toBe(true);
140 const { tag } = await mergeButton();
141 expect(tag).not.toContain("disabled");
142 });
143
144 test("main moves: no look ask, and the merge button is disabled", async () => {
145 await put("main", "other.txt", "moved\n");
146 await sweepStaleAsks(app);
147 expect(looks()).toBe(false);
148 const { page, tag } = await mergeButton();
149 expect(tag).toContain("disabled");
150 expect(page).toContain("Waiting on checks");
151 expect(page).toContain("checked against an older");
152 expect(page).toContain("Check again");
153 });
154
155 test("the terminal holds the merge too: m is refused on a stale green", async () => {
156 const text = await terminalText(["m"]);
157 expect(text).toContain("older base");
158 expect(text).toContain("press C to check again");
159 expect(text).not.toContain("Ready to merge");
160 expect(text).not.toContain("Merge pull request?");
161 expect(getPull(app.db, repo.id, pull.number)!.mergedAt).toBeNull();
162 }, 60_000);
163
164 test("the terminal's C asks for the check again", async () => {
165 // No check service runs in this test: the refusal proves C reached askCheck.
166 expect(await terminalText(["C"])).toContain("no check was asked for");
167 }, 60_000);
168
169 test("a re-check green against the new main brings both back", async () => {
170 await green(await mainTip());
171 await sweepStaleAsks(app);
172 expect(looks()).toBe(true);
173 const { tag } = await mergeButton();
174 expect(tag).not.toContain("disabled");
175 expect(await terminalText()).toContain("Ready to merge");
176 }, 60_000);
177 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs