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

A pull the queue holds as red alone says so on its page: "red alone at <time>, waiting for a new head or a new main" #6554

Merged opened this

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

Merged

scout/forge:scout/6494-a-pull-the-queue-holds-as-red-alone-says → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/6494-a-pull-the-queue-holds-as-red-alone-says was merged into main as 6564c87.

Review

Sign in to review this change.
9 files changed +234 −14
README.md +2
@@ -42,6 +42,8 @@ issue — use the link on the page). Both are a forge admin's only.
4242
4343 A hand-over card on `/asks` (an agent's pull the reviewer stopped on after three rounds) has "Give back to the crew": one press assigns the pull, and the open issue it closes if a person holds it, to the agent that opened it, writes "Given back to the crew by @who", and the card leaves; the next tick starts its run. It is for a writer of the repository, and a pull a person opened, or by an agent gone from the crew, gets no button. In the terminal, `b` on a handed-over row of the On you pane does the same (`src/actions/give_back.ts`).
4444
45 A pull the merge queue holds as red alone (red on its own merge at its current head; cleared by a new head or a moved main) says so under its checks line: "red alone at <time>, waiting for a new head or a new main", on the pull page, its live checks line and the studio change step, and in the checks row of the pull's terminal screen (`redMark` in `src/checks/queue.ts`). Nothing is drawn when it is not held.
46
4547 On a sealed issue, a writer gets the private repository to fix it in: `POST /api/v1/repos/:owner/:name/issues/:number/private`, the issue page's button, and `p` on the issue's terminal screen, which shows the repository and its clone URL (built from the forge's origin). All three refuse alike: not found for anyone who may not see sealed reports, `not_sealed`, and `unverified`.
4648
4749 Under the agents, each team that has a pull has one pooled row (its members'
src/checks/queue.ts +30 −2
@@ -257,6 +257,8 @@ export interface Remembered {
257257 readonly base: string;
258258 /** The pulls its red tree held besides it: a clash when there are any. */
259259 readonly together: readonly number[];
260 /** When the line handed it back (epoch ms). */
261 readonly at: number;
260262 }
261263
262264 /** A pull in red batches at this head (#4999): gated alone once it is a suspect. */
@@ -585,6 +587,32 @@ export function queueClashes(repoId: string): number {
585587 return n;
586588 }
587589
590 /** A pull's red mark, as the page says it: when, at which head, on which base. */
591 export interface RedMark {
592 readonly at: string;
593 readonly head: string;
594 readonly base: string;
595 }
596
597 /**
598 * The mark the line holds on a pull that was red alone — on its own merge,
599 * not on top of others — at this `head`; undefined when it holds none.
600 */
601 export function redMark(repoId: string, pullId: string, head: string): RedMark | undefined {
602 const red = lines.get(repoId)?.reds.get(pullId);
603 if (red === undefined || red.head !== head || red.together.length > 0) return undefined;
604 return { at: new Date(red.at).toISOString(), head: red.head, base: red.base };
605 }
606
607 /** What the line waits for on a pull it holds as red alone. */
608 export const RED_ALONE_WAIT = "waiting for a new head or a new main";
609
610 /** A mark's time as both doors say it: `2026-10-26 20:41 UTC`; empty when it does not parse. */
611 export function redAloneAt(mark: RedMark): string {
612 const at = new Date(mark.at);
613 return Number.isNaN(at.getTime()) ? "" : `${at.toISOString().slice(0, 16).replace("T", " ")} UTC`;
614 }
615
588616 const HOUR_MS = 60 * 60_000;
589617
590618 /** The last hour's first candidates of a line, and how many of them were red (#4235). */
@@ -876,7 +904,7 @@ async function solo(line: Line, gated: Gated, w: Waiting): Promise<{ readonly do
876904 return;
877905 }
878906 line.suspects.delete(pull.id);
879 markRed(line, pull.id, { head: pull.headOid, base: on.baseOid, together: [] });
907 markRed(line, pull.id, { head: pull.headOid, base: on.baseOid, together: [], at: Date.now() });
880908 log.info("red alone", { repo: line.repoId, pull: pull.number });
881909 w.entry.handBack(verdict, on.baseOid, []);
882910 w.settle(false);
@@ -1193,7 +1221,7 @@ async function judged(
11931221 // with them, or red on its own — not gated alone on the base before
11941222 // them to tell which. Remembered with them either way (#4235).
11951223 const together = landed.map((q) => q.pull.number);
1196 markRed(search.line, culprit.pull.id, { head: culprit.pull.headOid, base: candidate.baseOid, together });
1224 markRed(search.line, culprit.pull.id, { head: culprit.pull.headOid, base: candidate.baseOid, together, at: Date.now() });
11971225 search.line.suspects.delete(culprit.pull.id);
11981226 log.info(together.length === 0 ? "red on its own merge" : "red on top of its candidate", {
11991227 repo: search.line.repoId,
src/checks/queue_store.ts +4 −4
@@ -167,14 +167,14 @@ export function keepRed(
167167 if (mark === undefined) return;
168168 db.query(
169169 "INSERT INTO queue_red (repo_id, pull_id, head_oid, base_oid, together, at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
170 ).run(repoId, pullId, mark.head, mark.base, JSON.stringify(mark.together), now.toISOString());
170 ).run(repoId, pullId, mark.head, mark.base, JSON.stringify(mark.together), new Date(mark.at ?? now.getTime()).toISOString());
171171 }
172172
173173 /** The repository's red marks, by pull id. */
174174 export function redMarks(db: Database, repoId: string): Map<string, Remembered> {
175175 const rows = db
176 .query<{ pull_id: string; head_oid: string; base_oid: string; together: string }, [string]>(
177 "SELECT pull_id, head_oid, base_oid, together FROM queue_red WHERE repo_id = ?1 ORDER BY at",
176 .query<{ pull_id: string; head_oid: string; base_oid: string; together: string; at: string }, [string]>(
177 "SELECT pull_id, head_oid, base_oid, together, at FROM queue_red WHERE repo_id = ?1 ORDER BY at",
178178 )
179179 .all(repoId);
180180 const marks = new Map<string, Remembered>();
@@ -182,7 +182,7 @@ export function redMarks(db: Database, repoId: string): Map<string, Remembered>
182182 const together = parsed(row.together);
183183 const numbers =
184184 Array.isArray(together) && together.every((n) => Number.isSafeInteger(n)) ? (together as number[]) : [];
185 marks.set(row.pull_id, { head: row.head_oid, base: row.base_oid, together: numbers });
185 marks.set(row.pull_id, { head: row.head_oid, base: row.base_oid, together: numbers, at: Date.parse(row.at) || 0 });

A bad stored time becomes at: 0, which redAloneAt renders as 1970-01-01. Skip the mark or omit the time instead.

186186 }
187187 return marks;
188188 }
src/tui/screens/pull.ts +12 −2
@@ -26,6 +26,7 @@
2626 * `n`/`p` walk the unsettled ones while the cursor is inside a thread.
2727 */
2828
29 import { RED_ALONE_WAIT, redAloneAt, redMark } from "../../checks/queue.ts";
2930 import {
3031 addCommentThrough,
3132 setIssueStateThrough,
@@ -253,6 +254,7 @@ export function pullScreen(
253254 let checkSteps = "";
254255 /** `findingsWords` of the preview's website-spec findings; empty when none were recorded. */
255256 let specWords = "";
257 let redWords = "";
256258 /** The check's green was against a base that has since moved. */
257259 let checkOld = false;
258260 /** Agent runs about this pull, newest first. */
@@ -316,6 +318,11 @@ export function pullScreen(
316318 ? null
317319 : pullPreviewFindings(f.db, repo.id, previewEnv(number));
318320 specWords = spec === null ? "" : findingsWords(spec);
321 const mark =
322 repo === undefined || row === undefined
323 ? undefined
324 : redMark(repo.id, row.id, row.headOid || (facts?.headOid ?? ""));
325 redWords = mark === undefined ? "" : `red alone at ${redAloneAt(mark)}, ${RED_ALONE_WAIT}`;
319326 checkSteps =
320327 settings === undefined
321328 ? ""
@@ -833,7 +840,7 @@ export function pullScreen(
833840 // The box is exactly as tall as what is in it: the state, then a row
834841 // for the verdicts when there are any, then a row for the checks when
835842 // a deploy has run or the gate demands one.
836 const checksLine = checksText(check, requireChecks, isApp, checkBase, checkOld, checkProgress, checkSteps, specWords);
843 const checksLine = checksText(check, requireChecks, isApp, checkBase, checkOld, checkProgress, checkSteps, specWords, redWords);
837844 const boxH =
838845 3 +
839846 (standingText(standing(), requireReview) === "" ? 0 : 1) +
@@ -1300,10 +1307,13 @@ export function checksText(
13001307 steps = "",
13011308 /** `findingsWords` of the preview; empty when none were recorded. */
13021309 spec = "",
1310 /** The queue holds the pull as red alone: its words; empty when it does not. */
1311 red = "",
13031312 ): string {
13041313 const line = checksLine(check, required, isApp, baseBranch, stale, progress);
13051314 const withSteps = line === "" || steps === "" ? line : `${line} · ${steps}`;
1306 return withSteps === "" || spec === "" ? withSteps : `${withSteps} · ${spec}`;
1315 const withSpec = withSteps === "" || spec === "" ? withSteps : `${withSteps} · ${spec}`;
1316 return withSpec === "" || red === "" ? `${withSpec}${red}` : `${withSpec} · ${red}`;
13071317 }
13081318
13091319 function checksLine(
src/web/pages/pull.ts +14 −1
@@ -25,6 +25,7 @@ import { CHECK_ASKED } from "../../actions/pull_ask_words.ts";
2525 import { reviewWords } from "../../actions/review_words.ts";
2626 import { readFileAt } from "../../apps/checkout.ts";
2727 import { parseCheck } from "../../apps/manifest.ts";
28 import { RED_ALONE_WAIT, redAloneAt, redMark, type RedMark } from "../../checks/queue.ts";
2829 import { tierLine, type TierState } from "../../checks/tiers.ts";
2930 import { getAppByRepo } from "../../core/apps.ts";
3031 import { labelsFor, listLabels } from "../../core/issues.ts";
@@ -304,6 +305,17 @@ export function checksLine(
304305 </p>`;
305306 }
306307
308 /** One muted line when the merge queue holds the pull as red alone; nothing otherwise. */
309 export function redAloneLine(mark: RedMark | undefined): Html {
310 if (mark === undefined) return html``;
311 const exact = redAloneAt(mark);
312 if (exact === "") return html``;
313 return html`<p class="muted small">
314 ${icon("clock")}red alone at

Hand-rolled paragraph beside checksLine; prefer the existing checks-line markup or name this in docs/design/areas/.

315 <time datetime="${new Date(mark.at).toISOString()}">${exact}</time>, ${RED_ALONE_WAIT}
316 </p>`;
317 }
318
307319 /** Whether a head declares a fast tier: a commit's files never change. */
308320 const fastByHead = new Map<string, boolean>();
309321 export async function declaresFast(dir: string, head: string): Promise<boolean> {
@@ -392,7 +404,8 @@ function pullChecksLine(
392404 `${thread}/checks-line`,
393405 );
394406 const found = !isApp ? null : pullPreviewFindings(ctx.app.db, repo.id, previewEnv(pull.number));
395 return html`${line}${found === null ? "" : specFindings(found)}`;
407 const red = redAloneLine(redMark(repo.id, pull.id, status.headOid));
408 return html`${line}${red}${found === null ? "" : specFindings(found)}`;
396409 }
397410
398411 /** One §5 row in a toned card; `.setting-list` wraps the sentence and drops the action under it on a phone. */
test/merge-queue-clash.test.ts +13 −1
@@ -11,7 +11,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1111 import { mkdtemp, rm } from "node:fs/promises";
1212 import { tmpdir } from "node:os";
1313 import { join } from "node:path";
14 import { type Candidate, enqueue, type Expect, queueClashes, type Queued, redBatchRate } from "../src/checks/queue.ts";
14 import { type Candidate, enqueue, type Expect, queueClashes, type Queued, redBatchRate, redMark } from "../src/checks/queue.ts";
1515 import type { Pull } from "../src/core/pulls.ts";
1616
1717 describe("the merge queue remembers clashes", () => {
@@ -165,6 +165,18 @@ describe("the merge queue remembers clashes", () => {
165165 ]);
166166 });
167167
168 test("red alone is readable as a mark at its head and base, and nowhere else", async () => {
169 await markedNow();
170 const head = git("rev-parse", "b");
171 const mark = redMark(repoId, "clash-2", head);
172 expect(mark?.head).toBe(head);
173 expect(mark?.base).toBe(git("rev-parse", "refs/heads/main"));
174 expect(new Date(mark!.at).toISOString()).toBe(mark!.at);
175 expect(redMark(repoId, "clash-2", "0".repeat(40))).toBeUndefined();
176 expect(redMark(repoId, "clash-99", head)).toBeUndefined();
177 expect(redMark("no-such-line", "clash-2", head)).toBeUndefined();
178 });
179
168180 test("a push is a new head: B is forgotten, batched with the rest, and lands", async () => {
169181 const pushed = entry(2, await headAdding("b2", "b2.txt"));
170182 const g = entry(7, await headAdding("g", "g.txt"));
test/merge-queue-restart.test.ts +4 −4
@@ -214,11 +214,11 @@ describe("the merge queue across a restart", () => {
214214 const head = pull.headOid;
215215 const base = git(repo, "rev-parse", "main");
216216 const store = queueStore(app.db);
217 store.red(repo.id, pull.id, { head, base, together: [4, 5] });
218 expect(redMarks(app.db, repo.id).get(pull.id)).toEqual({ head, base, together: [4, 5] });
217 store.red(repo.id, pull.id, { head, base, together: [4, 5], at: 1000 });
218 expect(redMarks(app.db, repo.id).get(pull.id)).toEqual({ head, base, together: [4, 5], at: 1000 });
219219 // A new mark replaces the old: the line remembers one per pull.
220 keepRed(app.db, repo.id, pull.id, { head, base: head, together: [] });
221 expect([...redMarks(app.db, repo.id).values()]).toEqual([{ head, base: head, together: [] }]);
220 keepRed(app.db, repo.id, pull.id, { head, base: head, together: [], at: 2000 });
221 expect([...redMarks(app.db, repo.id).values()]).toEqual([{ head, base: head, together: [], at: 2000 }]);
222222 store.red(repo.id, pull.id, undefined);
223223 expect(redMarks(app.db, repo.id).size).toBe(0);
224224 });
test/pull-red-alone-line.test.ts added +16
@@ -0,0 +1,16 @@
1 import { expect, test } from "bun:test";
2 import { redAloneLine } from "../src/web/pages/pull.ts";
3
4 const mark = { at: "2026-10-26T20:41:30.000Z", head: "a".repeat(40), base: "b".repeat(40) };
5
6 test("a pull held as red alone says so, with a <time> element", () => {
7 const line = redAloneLine(mark).text;
8 expect(line).toContain("red alone at");
9 expect(line).toContain('<time datetime="2026-10-26T20:41:30.000Z">2026-10-26 20:41 UTC</time>');
10 expect(line).toContain("waiting for a new head or a new main");
11 });
12
13 test("an unmarked pull draws nothing, and a mark with a broken time neither", () => {
14 expect(redAloneLine(undefined).text).toBe("");
15 expect(redAloneLine({ ...mark, at: "<script>" }).text).toBe("");
16 });
test/pull-red-alone-path.test.ts added +139
@@ -0,0 +1,139 @@
1 /**
2 * A pull the merge queue hands back red alone says so on every door that
3 * draws its checks (#6494): the page, its live checks line, the terminal.
4 * The mark is made the real way — a red gate in the queue — and read back.
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 { openPull } from "../src/actions/pulls.ts";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { enqueue } from "../src/checks/queue.ts";
14 import { getPull, pullDir, type Pull } from "../src/core/pulls.ts";
15 import { createSession } from "../src/core/sessions.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 { 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 { quietLogs } from "./helpers/globals.ts";
23 import { settleApp } from "./helpers/tui.ts";
24
25 quietLogs();
26
27 let dataDir: string;
28 let app: App;
29 let servers: Servers;
30 let alice: User;
31 let cookie = "";
32 const made: { repo: Awaited<ReturnType<typeof setup>>["repo"]; marked: Pull; plain: Pull }[] = [];
33 const cells = process.env["FORGE_CELLS"];
34
35 async function setup() {
36 const r = await createRepo(app, alice, { name: "lib", visibility: "public" });
37 if (!r.ok) throw new Error(String(r.error));
38 const repo = r.value;
39 const base = await commitToBranch(app, alice, repo, { message: "base", files: [{ path: "forge.json", content: "{}" }] });
40 if (!base.ok) throw new Error(JSON.stringify(base.error));
41 const pulls: Pull[] = [];
42 for (const branch of ["red", "fine"]) {
43 const done = await commitToBranch(app, alice, repo, {
44 branch,
45 message: branch,
46 files: [{ path: `${branch}.txt`, content: branch }],
47 });
48 if (!done.ok) throw new Error(JSON.stringify(done.error));
49 const opened = await openPull(app, alice, repo, { title: branch, body: "", baseBranch: "main", headBranch: branch });
50 if (!opened.ok) throw new Error(JSON.stringify(opened.error));
51 pulls.push(getPull(app.db, repo.id, opened.value.number)!);
52 }
53 return { repo, marked: pulls[0]!, plain: pulls[1]! };
54 }
55
56 beforeAll(async () => {
57 process.env["FORGE_CELLS"] = "off";
58 dataDir = await mkdtemp(join(tmpdir(), "forge-red-alone-"));
59 app = createApp({ dataDir, port: 0, host: "forge.test" });
60 servers = await serve(app);
61 const u = await createUser(app.db, {
62 origin: "seed",
63 trusted: true,
64 username: "alice",
65 email: "[email protected]",
66 password: "a-good-passphrase",
67 adminIfFirst: true,
68 });
69 if (!u.ok) throw new Error(String(u.error));
70 alice = u.value;
71 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
72 made.push(await setup());
73 const { repo, marked } = made[0]!;
74 const dir = await pullDir(app.db, app.reposRoot, repo, marked);
75 const verdicts = await enqueue(
76 repo.id,
77 [
78 {
79 pull: marked,
80 dir,
81 land: async () => undefined,
82 release: () => {},
83 handBack: () => {},
84 },
85 ],
86 async () => ({ lands: false, reopen: true, comment: "red", learn: [] }),
87 );
88 expect(verdicts).toEqual([false]);
89 });
90
91 afterAll(async () => {
92 await servers.stop();
93 app.close();
94 if (cells === undefined) delete process.env["FORGE_CELLS"];
95 else process.env["FORGE_CELLS"] = cells;
96 await rm(dataDir, { recursive: true, force: true });
97 });
98
99 const text = async (path: string): Promise<string> =>
100 (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
101
102 test("the pull page and its live checks line carry the line for the marked pull only", async () => {
103 const { marked, plain } = made[0]!;
104 for (const suffix of ["", "/checks-line"]) {
105 const on = await text(`/alice/lib/pulls/${marked.number}${suffix}`);
106 expect(on).toContain("red alone at");
107 expect(on).toContain("<time datetime=");
108 expect(on).toContain("waiting for a new head or a new main");
109 const off = await text(`/alice/lib/pulls/${plain.number}${suffix}`);
110 expect(off).not.toContain("red alone");
111 }
112 });
113
114 test("the terminal's checks row says it too, and not for the other pull", async () => {
115 const { repo, marked, plain } = made[0]!;
116 const { app: tui, session } = createForgeApp(
117 app,
118 { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:alice", user: alice },
119 { terminal: headlessTerminal(200, 44), exitHooks: false },
120 );
121 tui.start();
122 try {
123 for (const [pull, says] of [[marked, true], [plain, false]] as const) {
124 tui.ctx.push(pullScreen(session, tui.ctx, local(session, repo), pull.number));
125 const end = Date.now() + 10_000;
126 while (!tui.screen.toText().includes(pull.title) && Date.now() < end) {
127 await settleApp(tui);
128 await Bun.sleep(20);
129 }
130 await settleApp(tui);
131 const screen = tui.screen.toText();
132 expect(screen.includes("red alone at")).toBe(says);
133 if (says) expect(screen).toContain("waiting for a new head or a new main");
134 tui.ctx.pop();
135 }
136 } finally {
137 tui.stop();
138 }
139 }, 30_000);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs