Public: anyone can read this; the crew can change it.
The merge queue's "red alone" mark on a pull clears when main moves, so a green pull rejoins without a new head #6506
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6493-the-merge-queue-s-red-alone-mark-on-a-pu → main 1 commit fork
3 files
changed +59
−19
src/checks/queue.ts +16 −5
| @@ -179,6 +179,8 @@ interface Waiting { | ||
| 179 | 179 | /** A pull the line handed back red, while its head is the same (#4235). */ |
| 180 | 180 | interface Remembered { |
| 181 | 181 | readonly head: string; |
| 182 | /** The base tip it went red on: a moved base is a new try (#6493). */ | |
| 183 | readonly base: string; | |
| 182 | 184 | /** The pulls its red tree held besides it: a clash when there are any. */ |
| 183 | 185 | readonly together: readonly number[]; |
| 184 | 186 | } |
| @@ -329,19 +331,28 @@ function lineOf(repoId: string): Line { | ||
| 329 | 331 | * pull landed. A pull already in the line is answered `false` at once: the |
| 330 | 332 | * entry already there carries it. |
| 331 | 333 | */ |
| 332 | export function enqueue( | |
| 334 | export async function enqueue( | |
| 333 | 335 | repoId: string, |
| 334 | 336 | entries: readonly Queued[], |
| 335 | 337 | gate: GateCandidate, |
| 336 | 338 | options: QueueOptions = {}, |
| 337 | 339 | ): Promise<boolean[]> { |
| 338 | 340 | const line = lineOf(repoId); |
| 341 | // A red mark names the base it went red on; the tip is read only when a | |
| 342 | // mark is there to compare it with. | |
| 343 | const tips = new Map<string, string | undefined>(); | |
| 344 | for (const { pull, dir } of entries) { | |
| 345 | if (!line.reds.has(pull.id) || tips.has(pull.baseBranch + "\0" + dir)) continue; | |
| 346 | tips.set(pull.baseBranch + "\0" + dir, await resolveOid(dir, `refs/heads/${pull.baseBranch}`)); | |
| 347 | } | |
| 339 | 348 | const answers = entries.map((entry) => { |
| 340 | 349 | if (line.holds.has(entry.pull.id)) return Promise.resolve(false); |
| 341 | 350 | line.holds.add(entry.pull.id); |
| 342 | 351 | const red = line.reds.get(entry.pull.id); |
| 343 | if (red !== undefined && red.head !== entry.pull.headOid) line.reds.delete(entry.pull.id); | |
| 344 | const recheck = red !== undefined && red.head === entry.pull.headOid; | |
| 352 | const tip = tips.get(entry.pull.baseBranch + "\0" + entry.dir); | |
| 353 | const held = red !== undefined && red.head === entry.pull.headOid && (tip === undefined || red.base === tip); | |
| 354 | if (red !== undefined && !held) line.reds.delete(entry.pull.id); | |
| 355 | const recheck = held; | |
| 345 | 356 | if (line.suspects.get(entry.pull.id)?.head !== entry.pull.headOid) line.suspects.delete(entry.pull.id); |
| 346 | 357 | return new Promise<boolean>((resolve) => |
| 347 | 358 | line.waiting.push({ |
| @@ -634,7 +645,7 @@ async function solo(line: Line, gated: Gated, w: Waiting): Promise<{ readonly do | ||
| 634 | 645 | return; |
| 635 | 646 | } |
| 636 | 647 | line.suspects.delete(pull.id); |
| 637 | line.reds.set(pull.id, { head: pull.headOid, together: [] }); | |
| 648 | line.reds.set(pull.id, { head: pull.headOid, base: on.baseOid, together: [] }); | |
| 638 | 649 | log.info("red alone", { repo: line.repoId, pull: pull.number }); |
| 639 | 650 | w.entry.handBack(verdict, on.baseOid, []); |
| 640 | 651 | w.settle(false); |
| @@ -876,7 +887,7 @@ async function judged( | ||
| 876 | 887 | // with them, or red on its own — not gated alone on the base before |
| 877 | 888 | // them to tell which. Remembered with them either way (#4235). |
| 878 | 889 | const together = landed.map((q) => q.pull.number); |
| 879 | search.line.reds.set(culprit.pull.id, { head: culprit.pull.headOid, together }); | |
| 890 | search.line.reds.set(culprit.pull.id, { head: culprit.pull.headOid, base: candidate.baseOid, together }); | |
| 880 | 891 | search.line.suspects.delete(culprit.pull.id); |
| 881 | 892 | log.info(together.length === 0 ? "red on its own merge" : "red on top of its candidate", { |
| 882 | 893 | repo: search.line.repoId, |
test/merge-queue-clash.test.ts +32 −2
| @@ -72,6 +72,10 @@ describe("the merge queue remembers clashes", () => { | ||
| 72 | 72 | } |
| 73 | 73 | |
| 74 | 74 | const repoId = "clash-line"; |
| 75 | /** B red on its own merge on main as it is now: the mark names this base (#6493). */ | |
| 76 | const markedNow = async () => { | |
| 77 | expect(await enqueue(repoId, [entry(2, git("rev-parse", "b"))], gate)).toEqual([false]); | |
| 78 | }; | |
| 75 | 79 | const together = (a: number, b: number) => gates.filter((g) => g.pulls.includes(a) && g.pulls.includes(b)).length; |
| 76 | 80 | |
| 77 | 81 | test("A and B clash, C is fine: A and C land, B is handed back once on top of A", async () => { |
| @@ -90,6 +94,7 @@ describe("the merge queue remembers clashes", () => { | ||
| 90 | 94 | }); |
| 91 | 95 | |
| 92 | 96 | test("B joining again at the same head is re-checked alone, after the fresh pulls, and cuts nothing off", async () => { |
| 97 | await markedNow(); | |
| 93 | 98 | const b = entry(2, git("rev-parse", "b")); |
| 94 | 99 | const [d, e] = [entry(4, await headAdding("d", "d.txt")), entry(5, await headAdding("e", "e.txt"))]; |
| 95 | 100 | const before = gates.length; |
| @@ -104,11 +109,12 @@ describe("the merge queue remembers clashes", () => { | ||
| 104 | 109 | // Alone on main as it is now, B is red on its own merge. |
| 105 | 110 | expect(told.at(-1)).toEqual({ pull: 2, together: [] }); |
| 106 | 111 | expect(queueClashes(repoId)).toBe(0); |
| 107 | // A red re-check is no red batch. | |
| 108 | expect(redBatchRate(repoId)).toEqual({ batches: 3, red: 1 }); | |
| 112 | // A red re-check is no red batch; the fresh try before it was one. | |
| 113 | expect(redBatchRate(repoId)).toEqual({ batches: 4, red: 2 }); | |
| 109 | 114 | }); |
| 110 | 115 | |
| 111 | 116 | test("a fresh pull joining behind a re-check is not stacked on it", async () => { |
| 117 | await markedNow(); | |
| 112 | 118 | const b = entry(2, git("rev-parse", "b")); |
| 113 | 119 | let release = (): void => {}; |
| 114 | 120 | const held = new Promise<void>((r) => (release = r)); |
| @@ -135,6 +141,30 @@ describe("the merge queue remembers clashes", () => { | ||
| 135 | 141 | ]); |
| 136 | 142 | }); |
| 137 | 143 | |
| 144 | test("red alone, then main moves: the pull gates again as a fresh entry", async () => { | |
| 145 | await markedNow(); | |
| 146 | const h = entry(8, await headAdding("h", "h.txt")); | |
| 147 | expect(await enqueue(repoId, [h], gate)).toEqual([true]); // main moves | |
| 148 | const b = entry(2, git("rev-parse", "b")); | |
| 149 | const before = gates.length; | |
| 150 | // Fresh: it rides with the new pull in one candidate, not behind it as a re-check. | |
| 151 | const i = entry(9, await headAdding("i", "i.txt")); | |
| 152 | expect(await enqueue(repoId, [b, i], gate)).toEqual([false, true]); | |
| 153 | expect(gates.slice(before)[0]).toEqual({ pulls: [2, 9], ok: false }); | |
| 154 | }); | |
| 155 | ||
| 156 | test("red alone, main unmoved: the mark still holds it for a re-check", async () => { | |
| 157 | await markedNow(); | |
| 158 | const before = gates.length; | |
| 159 | const j = entry(10, await headAdding("j", "j.txt")); | |
| 160 | const b = entry(2, git("rev-parse", "b")); | |
| 161 | expect(await enqueue(repoId, [b, j], gate)).toEqual([false, true]); | |
| 162 | expect(gates.slice(before)).toEqual([ | |
| 163 | { pulls: [10], ok: true }, | |
| 164 | { pulls: [2], ok: false }, | |
| 165 | ]); | |
| 166 | }); | |
| 167 | ||
| 138 | 168 | test("a push is a new head: B is forgotten, batched with the rest, and lands", async () => { |
| 139 | 169 | const pushed = entry(2, await headAdding("b2", "b2.txt")); |
| 140 | 170 | const g = entry(7, await headAdding("g", "g.txt")); |
test/merge-queue.test.ts +11 −12
| @@ -576,7 +576,7 @@ describe("the merge queue", () => { | ||
| 576 | 576 | expect(gatesOf(repo)).toHaveLength(before); |
| 577 | 577 | }); |
| 578 | 578 | |
| 579 | test("a red pull stays out of the queue when main moves, and the re-gate checks it alone after the fresh pulls (#4235)", async () => { | |
| 579 | test("a red pull stays out of the queue when main moves, and the re-gate gates it fresh with the pulls then waiting (#4235, #6493)", async () => { | |
| 580 | 580 | const repo = await autonomousRepo("red-stays-out"); |
| 581 | 581 | const a = await greenPull(repo, "a.ts"); |
| 582 | 582 | const red = await greenPull(repo, "red-x.ts"); |
| @@ -596,19 +596,18 @@ describe("the merge queue", () => { | ||
| 596 | 596 | before = gatesOf(repo).length; |
| 597 | 597 | expect(await sweepGreen(app, repo)).toBe(1); |
| 598 | 598 | expect(merged(repo, c)).toBe(true); |
| 599 | const [fresh, recheck] = gatesOf(repo).slice(before); | |
| 600 | expect(fresh?.ok).toBe(true); | |
| 601 | expect(git(repo, "ls-tree", "--name-only", fresh!.commit)).not.toContain("red-x.ts"); | |
| 602 | expect(recheck?.ok).toBe(false); | |
| 603 | // Gated on main once c had landed: c is under it, and nothing else rode with it. | |
| 604 | expect(git(repo, "ls-tree", "--name-only", recheck!.commit).split("\n").sort()).toEqual( | |
| 605 | ["README.md", "a.ts", "b.ts", "c.ts", "forge.json", "red-x.ts"], | |
| 606 | ); | |
| 607 | // /boards: one red candidate of three in the hour; the re-check is none. | |
| 599 | // Main moved since it went red: it gates again as a fresh entry (#6493), | |
| 600 | // in the candidate with c, not behind c as a re-check. | |
| 601 | const [batch, ...rest] = gatesOf(repo).slice(before); | |
| 602 | expect(batch?.ok).toBe(false); | |
| 603 | expect(git(repo, "ls-tree", "--name-only", batch!.commit)).toContain("c.ts"); | |
| 604 | expect(git(repo, "ls-tree", "--name-only", batch!.commit)).toContain("red-x.ts"); | |
| 605 | expect(rest.at(-1)?.ok).toBe(true); | |
| 606 | // /boards: two red candidates of four in the hour. | |
| 608 | 607 | const page = await (await fetch(`${servers.url}/boards`, { headers: { cookie: await login() } })).text(); |
| 609 | expect(page).toMatch(/data-queue="alice\/red-stays-out">[\s\S]*?data-red-batches>1 of 3</); | |
| 608 | expect(page).toMatch(/data-queue="alice\/red-stays-out">[\s\S]*?data-red-batches>2 of 4</); | |
| 610 | 609 | // The terminal's Flow dialog says the same, on a line of its own under the table. |
| 611 | expect(flowLines(app.db).join(" ").replace(/\s+/g, " ")).toMatch(/Red batches, last hour: [^.]*alice\/red-stays-out 1 of 3/); | |
| 610 | expect(flowLines(app.db).join(" ").replace(/\s+/g, " ")).toMatch(/Red batches, last hour: [^.]*alice\/red-stays-out 2 of 4/); | |
| 612 | 611 | // The MCP rows keep their shape: the rate is the page's and the terminal's. |
| 613 | 612 | expect(Object.keys((await mcpQueue()).find((q) => (q as { repo: string }).repo === "alice/red-stays-out") as object)).not.toContain("batches"); |
| 614 | 613 | }); |
new above
offline