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

The merge queue remembers clashes: two pulls that are green alone but red together are never batched together again until one lands #4286

Merged opened this in answer to

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

Merged

mason/forge:mason/4235-the-merge-queue-remembers-clashes-two-pu → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/4235-the-merge-queue-remembers-clashes-two-pu was merged into main as 9bf61d8.

Review

Sign in to review this change.
10 files changed +387 −57
README.md +16 −4
@@ -1262,9 +1262,16 @@ it does not, the next is formed again on main. A gate beyond a line's first
12621262 starts only on a free check slot and holds it, and two slots are always kept
12631263 for pull checks (`PULL_RESERVE`); with no slot spare the queue gates one
12641264 candidate at a time and halves a red one, as it always did. A red
1265 waits for a push or a new main, except once: after 30 minutes with neither
1266 side moved it is gated again, and lands if green; a red that reproduces
1267 stays red and is not told twice. A gate cut off by the forge stopping (a
1265 waits for a push, not a new main (a pull red on main would otherwise ride a
1266 batch after every landing), except once: after 30 minutes on the same head
1267 it is gated again, and lands if green; a red that reproduces stays red and
1268 is not told twice. The queue remembers what its searches found: a pull
1269 handed back red on top of others of its candidate that landed first is a
1270 clash (the comment names them; the log says `[queue] red on top of its
1271 candidate`, or `red on its own merge`), and the tick line counts them as
1272 `clashes=N`. A remembered pull that joins again at the same head is gated
1273 alone, only once no fresh pull waits, and nothing is stacked on it, so a red
1274 re-check never cuts off a batch of fresh pulls; a push forgets it. A gate cut off by the forge stopping (a
12681275 roll, a drain) or by the cluster losing its Job is no red at all: the next
12691276 sweep gates it again. So is a gate that has not answered in 15 minutes
12701277 (`GATE_DEADLINE_MS`): it is abandoned and its check aborted, the log says
@@ -1273,7 +1280,12 @@ that turn green while a candidate is gating wait for the next one. Each
12731280 queue's numbers — how many pulls are queued, how many are gating, how many
12741281 gates are in flight, how many landed in the last hour, and their median
12751282 minutes from approval to landing — show in three places: the flow panel on `/boards`, the
1276 terminal's Flow command, and the MCP `flow` tool's `queue` field.
1283 terminal's Flow command, and the MCP `flow` tool's `queue` field. The flow
1284 panel and the terminal's Flow command also show the last hour's red batches
1285 (`1 of 3`: candidates gated red, of those gated; search prefixes and
1286 re-checks are not counted) — the panel as a column, the terminal as a
1287 `Red batches, last hour:` line under its table. The MCP `queue` rows do not
1288 carry it: their shape is unchanged.
12771289 When a repository requires checks, an approved pull that is green only
12781290 against an older main joins the queue as it is: the candidate's full gate
12791291 runs on main's tip with that pull merged, which is the proof a re-check of
src/checks/queue.ts +128 −32
@@ -23,9 +23,19 @@
2323 * cut off, logged, and the line re-formed by the next sweep, so one gate
2424 * that never returns cannot hold the queue for good (#3430).
2525 *
26 * The line remembers what its searches found (#4235): a pull handed back
27 * red is remembered at its head, with the pulls its red tree held besides
28 * it — a clash when there are any: red on top of them (green alone, or red
29 * on its own too; the search does not spend a gate to tell which). A
30 * remembered pull that joins again at the same head is re-checked in a
31 * candidate of its own, formed only when no fresh pull waits and never
32 * stacked on, so a red re-check cuts off no batch of fresh pulls. It is
33 * forgotten when it lands or its head changes.
34 *
2635 * What is queued is not stored: it is the pulls `landIfGreen` admitted, and
2736 * every sweep admits them again. The queue only orders them and remembers
28 * what is at the gate, for the flow panel on /boards.
37 * what is at the gate and what its searches found, for the tick line and
38 * the flow panel on /boards.
2939 */
3040
3141 import type { Pull } from "../core/pulls.ts";
@@ -58,8 +68,12 @@ export interface Queued {
5868 * now at `tip`: called once per candidate that landed anything.
5969 */
6070 release(tip: string): void;
61 /** The gate found this pull to be the red one, on this base. */
62 handBack(red: Red, baseOid: string): void;
71 /**
72 * The gate found this pull to be the red one, on this base. `together`
73 * names the pulls of its candidate that landed onto that base first and
74 * that its red tree held: none when it was red on its own merge.
75 */
76 handBack(red: Red, baseOid: string, together: readonly number[]): void;
6377 }
6478
6579 /** One pull's squash on top of the steps before it. */
@@ -134,6 +148,21 @@ type Room = () => number;
134148 interface Waiting {
135149 readonly entry: Queued;
136150 readonly settle: (landed: boolean) => void;
151 /** Handed back red at this head before: gated alone, after the fresh pulls. */
152 readonly recheck: boolean;
153 }
154
155 /** A pull the line handed back red, while its head is the same (#4235). */
156 interface Remembered {
157 readonly head: string;
158 /** The pulls its red tree held besides it: a clash when there are any. */
159 readonly together: readonly number[];
160 }
161
162 /** A first candidate's verdict, for the red-batch rate. */
163 interface BatchVerdict {
164 readonly at: number;
165 readonly red: boolean;
137166 }
138167
139168 /** One gate in flight: the pulls its candidate carries, and since when. */
@@ -151,6 +180,10 @@ interface Line {
151180 readonly flights: Set<InFlight>;
152181 /** The gate runs this line has made, for a test and the log. */
153182 gates: number;
183 /** Pulls handed back red, by pull id: re-checked alone if they join again. */
184 readonly reds: Map<string, Remembered>;
185 /** The last hour's candidate verdicts, search prefixes and re-checks not counted. */
186 batches: BatchVerdict[];
154187 draining: boolean;
155188 /** Wakes a drain waiting on a gate when pulls join the line. */
156189 wake: (() => void) | undefined;
@@ -162,6 +195,8 @@ interface Flight {
162195 readonly candidate: Candidate;
163196 /** Built on the flight before it, not on the base's tip. */
164197 readonly speculative: boolean;
198 /** A remembered red gated alone: nothing stacks on it. */
199 readonly recheck: boolean;
165200 readonly branch: string;
166201 readonly verdict: Promise<LandVerdict>;
167202 readonly abort: AbortController;
@@ -194,7 +229,17 @@ const UNDECIDED: LandVerdict = { lands: false, interrupted: true };
194229 function lineOf(repoId: string): Line {
195230 let line = lines.get(repoId);
196231 if (line === undefined) {
197 line = { repoId, waiting: [], holds: new Set(), flights: new Set(), gates: 0, draining: false, wake: undefined };
232 line = {
233 repoId,
234 waiting: [],
235 holds: new Set(),
236 flights: new Set(),
237 gates: 0,
238 reds: new Map(),
239 batches: [],
240 draining: false,
241 wake: undefined,
242 };
198243 lines.set(repoId, line);
199244 }
200245 return line;
@@ -215,11 +260,16 @@ export function enqueue(
215260 const answers = entries.map((entry) => {
216261 if (line.holds.has(entry.pull.id)) return Promise.resolve(false);
217262 line.holds.add(entry.pull.id);
263 const red = line.reds.get(entry.pull.id);
264 if (red !== undefined && red.head !== entry.pull.headOid) line.reds.delete(entry.pull.id);
265 const recheck = red !== undefined && red.head === entry.pull.headOid;
218266 return new Promise<boolean>((resolve) =>
219267 line.waiting.push({
220268 entry,
269 recheck,
221270 settle: (landed) => {
222271 line.holds.delete(entry.pull.id);
272 if (landed) line.reds.delete(entry.pull.id);
223273 resolve(landed);
224274 },
225275 }),
@@ -276,6 +326,31 @@ export function queueWords(repoId: string): string {
276326 return `gating ${pullsWord(s.gating)} since ${hhmm(s.since)}${gates}${waiting === "" ? "" : `, ${waiting}`}`;
277327 }
278328
329 /**
330 * Clashes the line remembers, for the tick line (#4235): pulls handed back
331 * red on top of others of their candidate, at the head they were red on.
332 */
333 export function queueClashes(repoId: string): number {
334 let n = 0;
335 for (const red of lines.get(repoId)?.reds.values() ?? []) if (red.together.length > 0) n++;
336 return n;
337 }
338
339 const HOUR_MS = 60 * 60_000;
340
341 /** The last hour's first candidates of a line, and how many of them were red (#4235). */
342 export interface BatchRate {
343 readonly batches: number;
344 readonly red: number;
345 }
346
347 export function redBatchRate(repoId: string, now = Date.now()): BatchRate {
348 const line = lines.get(repoId);
349 if (line === undefined) return { batches: 0, red: 0 };
350 line.batches = line.batches.filter((b) => now - b.at < HOUR_MS);
351 return { batches: line.batches.length, red: line.batches.filter((b) => b.red).length };
352 }
353
279354 /** `1 pull`, `8 pulls`. */
280355 function pullsWord(n: number): string {
281356 return n === 1 ? "1 pull" : `${n} pulls`;
@@ -393,7 +468,7 @@ async function drain(line: Line, gate: GateCandidate, options: QueueOptions): Pr
393468 ahead.shift();
394469 let left: Waiting[];
395470 try {
396 left = await settleHead(head, verdict, ahead, gated, room);
471 left = await settleHead({ line, gated, room }, head, verdict, ahead);
397472 } catch (error) {
398473 log.warn("merge queue round failed", { error: String(error) });
399474 for (const w of head.steps) w.settle(false);
@@ -409,17 +484,21 @@ async function drain(line: Line, gate: GateCandidate, options: QueueOptions): Pr
409484
410485 /**
411486 * Take the next batch from the front of the line, build it — on `on`'s tip
412 * when there is a flight to stack on — and start its gate. `apart` when
413 * the front of the line cannot stack on `on`; undefined when nothing of
414 * the batch could be built.
487 * when there is a flight to stack on — and start its gate. Fresh pulls go
488 * first; a re-check is a batch of one, once no fresh pull waits. `apart`
489 * when the front of the line cannot stack on `on`, or `on` is a re-check;
490 * undefined when nothing of the batch could be built.
415491 */
416492 async function launch(line: Line, gated: Gated, on: Flight | undefined): Promise<Flight | undefined | "apart"> {
417 const first = line.waiting[0]!.entry;
493 const front = line.waiting.find((w) => !w.recheck) ?? line.waiting[0]!;
494 const first = front.entry;
418495 const branch = first.pull.baseBranch;
419 if (on !== undefined && (first.dir !== on.candidate.dir || branch !== on.branch)) return "apart";
420 const batch = line.waiting
421 .filter((w) => w.entry.dir === first.dir && w.entry.pull.baseBranch === branch)
422 .slice(0, MAX_CANDIDATE);
496 if (on !== undefined && (on.recheck || first.dir !== on.candidate.dir || branch !== on.branch)) return "apart";
497 const batch = front.recheck
498 ? [front]
499 : line.waiting
500 .filter((w) => !w.recheck && w.entry.dir === first.dir && w.entry.pull.baseBranch === branch)
501 .slice(0, MAX_CANDIDATE);
423502 line.waiting = line.waiting.filter((w) => !batch.includes(w));
424503 let candidate: Candidate | undefined;
425504 try {
@@ -437,7 +516,7 @@ async function launch(line: Line, gated: Gated, on: Flight | undefined): Promise
437516 log.warn("merge queue round failed", { error: String(error) });
438517 return UNDECIDED;
439518 });
440 return { steps, candidate, speculative: on !== undefined, branch, verdict, abort };
519 return { steps, candidate, speculative: on !== undefined, recheck: front.recheck, branch, verdict, abort };
441520 }
442521
443522 /**
@@ -459,13 +538,7 @@ function inCandidate(batch: readonly Waiting[], candidate: Candidate): Waiting[]
459538 * the tree it was built on — the flight before it landed whole. Returns
460539 * what waits for the next candidate.
461540 */
462 async function settleHead(
463 head: Flight,
464 verdict: LandVerdict,
465 ahead: Flight[],
466 gated: Gated,
467 room: Room,
468 ): Promise<Waiting[]> {
541 async function settleHead(search: Search, head: Flight, verdict: LandVerdict, ahead: Flight[]): Promise<Waiting[]> {
469542 let candidate = head.candidate;
470543 if (head.speculative) {
471544 const tip = await onTip(candidate, head.branch);
@@ -475,6 +548,7 @@ async function settleHead(
475548 }
476549 candidate = { ...candidate, baseOid: tip };
477550 }
551 if (!head.recheck && !("interrupted" in verdict)) search.line.batches.push({ at: Date.now(), red: !verdict.lands });
478552 if (verdict.lands) {
479553 if (await land(candidate, head.steps)) return [];
480554 return discard(ahead);
@@ -486,7 +560,7 @@ async function settleHead(
486560 for (const w of head.steps) w.settle(false);
487561 return behind;
488562 }
489 return [...(await judged(candidate, head.steps, verdict, gated, room)), ...behind];
563 return [...(await judged(search, candidate, head.steps, verdict, [])), ...behind];
490564 }
491565
492566 /**
@@ -515,18 +589,27 @@ async function discard(ahead: Flight[]): Promise<Waiting[]> {
515589 return gone.flatMap((f) => [...f.steps]);
516590 }
517591
592 /** What a red candidate's search works with: its line, the gate, the free slots. */
593 interface Search {
594 readonly line: Line;
595 readonly gated: Gated;
596 readonly room: Room;
597 }
598
518599 /**
519600 * Build, gate and land one candidate of `batch` on the base's tip. `known`
520601 * is a red verdict already read for a tree: a candidate that builds to
521 * that same tree is not gated again. Returns what waits for the next
602 * that same tree is not gated again. `landed` are the pulls of this search
603 * that landed onto the base before it. Returns what waits for the next
522604 * candidate.
523605 */
524606 async function round(
607 search: Search,
525608 batch: readonly Waiting[],
526 gated: Gated,
527 room: Room,
528 known?: { treeOid: string; red: Red },
609 known: { treeOid: string; red: Red },
610 landed: readonly Queued[],
529611 ): Promise<Waiting[]> {
612 const { gated } = search;
530613 const candidate = await build(batch);
531614 if (candidate === undefined || candidate.steps.length === 0) {
532615 for (const w of batch) w.settle(false);
@@ -534,7 +617,7 @@ async function round(
534617 }
535618 const steps = inCandidate(batch, candidate);
536619 const verdict: LandVerdict =
537 known !== undefined && known.treeOid === candidate.treeOid ? known.red : await gated(candidate);
620 known.treeOid === candidate.treeOid ? known.red : await gated(candidate);
538621 if (verdict.lands) {
539622 await land(candidate, steps);
540623 return [];
@@ -543,7 +626,7 @@ async function round(
543626 for (const w of steps) w.settle(false);
544627 return [];
545628 }
546 return judged(candidate, steps, verdict, gated, room);
629 return judged(search, candidate, steps, verdict, landed);
547630 }
548631
549632 /**
@@ -557,14 +640,26 @@ async function round(
557640 * candidate.
558641 */
559642 async function judged(
643 search: Search,
560644 candidate: Candidate,
561645 steps: readonly Waiting[],
562646 red: Red,
563 gated: Gated,
564 room: Room,
647 landed: readonly Queued[],
565648 ): Promise<Waiting[]> {
649 const { gated, room } = search;
566650 if (steps.length === 1) {
567 steps[0]!.entry.handBack(red, candidate.baseOid);
651 const culprit = steps[0]!.entry;
652 // Red on top of pulls of its own candidate that landed first: a clash
653 // with them, or red on its own — not gated alone on the base before
654 // them to tell which. Remembered with them either way (#4235).
655 const together = landed.map((q) => q.pull.number);
656 search.line.reds.set(culprit.pull.id, { head: culprit.pull.headOid, together });
657 log.info(together.length === 0 ? "red on its own merge" : "red on top of its candidate", {
658 repo: search.line.repoId,
659 pull: culprit.pull.number,
660 ...(together.length === 0 ? {} : { with: together.map((n) => `#${n}`).join(",") }),
661 });
662 culprit.handBack(red, candidate.baseOid, together);
568663 steps[0]!.settle(false);
569664 return [];
570665 }
@@ -605,7 +700,8 @@ async function judged(
605700 return [];
606701 }
607702 const known = stop === undefined ? { treeOid: candidate.treeOid, red } : { treeOid: prefix(stopAt).treeOid, red: stop };
608 const left = await round(steps.slice(green, stopAt), gated, room, known);
703 const now = [...landed, ...steps.slice(0, green).map((w) => w.entry)];
704 const left = await round(search, steps.slice(green, stopAt), known, now);
609705 return [...left, ...steps.slice(stopAt)];
610706 }
611707
src/crew/autonomy.ts +24 −10
@@ -23,7 +23,7 @@ import {
2323 import type { AccessError } from "../actions/access.ts";
2424 import { gateCandidate, releaseOnLand } from "../checks/landing.ts";
2525 import { addLearned } from "../checks/learned.ts";
26 import { enqueue, type GateCandidate, type Queued, type QueueOptions, queueWords } from "../checks/queue.ts";
26 import { enqueue, type GateCandidate, type Queued, type QueueOptions, queueClashes, queueWords } from "../checks/queue.ts";
2727 import { checksFor } from "../checks/service.ts";
2828 import { queueDepth } from "../core/capacity.ts";
2929 import { mergeable, resolveOid, treeOf } from "../git/merge.ts";
@@ -1277,6 +1277,8 @@ export async function autonomyTick(
12771277 ...(approved === undefined ? {} : { approved }),
12781278 // What the merge queue is doing now, not what the sweep admitted (#3430).
12791279 queue: queueWords(current.id),
1280 // Pulls the queue found red on top of others of their candidate (#4235).
1281 clashes: queueClashes(current.id),
12801282 spent: `${status.spentTodayUsd.toFixed(2)}/${status.ceilingUsd}`,
12811283 ...(status.paused === null ? {} : { paused: status.paused }),
12821284 // Starts after the first, and how many followed a crash (#3708).
@@ -1867,13 +1869,14 @@ async function admit(
18671869 const review = await reviewed(app, repo, pull);
18681870 if (review === "hold") return err("review");
18691871 // The full tier, once, on the queue's candidate (#1198, #2036). Red is
1870 // told to the pull once per head and base, and the pull waits for a push
1871 // — but a red neither side has moved off is gated once more after
1872 // REGATE_RED_MS, so a red that was the machine's and not the code's does
1873 // not hold the queue for good; a red that reproduces stays red (#3155).
1872 // told to the pull once per head and base, and the pull is out of the
1873 // queue until a push — a base that moved is no new try, or a pull red on
1874 // main rides every batch after each landing (#4235). A red the pull has
1875 // not moved off is gated once more after REGATE_RED_MS, alone (the queue
1876 // remembers it), so a red that was the machine's and not the code's does
1877 // not hold it for good; a red that reproduces stays red (#3155).
18741878 const told = latestDecision(app.db, pull.id, "land");
1875 const gatedAt = `${pull.headOid}:${baseTip}`;
1876 if (redAt(told, gatedAt) && !regateDue(told!, Date.now())) return err("red");
1879 if (redOf(told)?.head === pull.headOid && !regateDue(told!, Date.now())) return err("red");
18771880 // Green only against a base that has moved since is admitted as it is:
18781881 // the candidate's full gate runs on the base tip with this pull merged,
18791882 // which is the proof a re-check of its own would give, and a red one
@@ -1894,7 +1897,7 @@ async function admit(
18941897 why: error instanceof Error ? error.message : String(error),
18951898 }),
18961899 ),
1897 handBack: (red, baseOid) => {
1900 handBack: (red, baseOid, together) => {
18981901 // Told on the base it was found red on: a new base is a new try. Red
18991902 // again on the same head and base is the re-gate reproducing it: it
19001903 // stays red for good, and the pull is not told twice.
@@ -1912,12 +1915,23 @@ async function admit(
19121915 ms: 0,
19131916 });
19141917 addLearned(app.config.dataDir, pull.id, red.learn);
1915 if (!again) addComment(app, by, repo, getIssueById(app.db, pull.id) ?? pull, red.comment);
1918 if (!again) addComment(app, by, repo, getIssueById(app.db, pull.id) ?? pull, clashSaid(red.comment, together));
19161919 },
19171920 });
19181921 }
19191922
1920 /** How long a red neither side has moved off waits before it is gated once more (#3155). */
1923 /**
1924 * A red's comment, and when it was red on top of pulls of its candidate
1925 * that landed first, a line naming them (#4235). The search does not gate
1926 * the pull alone on the base before them, so it says both readings.
1927 */
1928 function clashSaid(comment: string, together: readonly number[]): string {
1929 if (together.length === 0) return comment;
1930 const names = together.map((n) => `#${n}`).join(", ");
1931 return `${comment}\n\nRed on top of ${names}, which landed first from the same merge-queue candidate: red on its own on that base, or only together with them. Bring this pull up to date with the base and push.`;
1932 }
1933
1934 /** How long a red the pull has not moved off waits before it is gated once more (#3155). */
19211935 export const REGATE_RED_MS = 30 * 60_000;
19221936
19231937 /**
src/flow/queue.ts +19 −2
@@ -5,10 +5,12 @@
55 * last hour, and their median minutes from approval to landing. Queued,
66 * gating and gates are this process's line (`checks/queue.ts`); landed is
77 * the rows. A repository with nothing queued, gating or landed is not listed.
8 * /boards and the terminal also read the last hour's red-batch rate (#4235),
9 * the line's too; the MCP rows keep their shape without it.
810 */
911
1012 import type { Database } from "bun:sqlite";
11 import { queueStatus } from "../checks/queue.ts";
13 import { type BatchRate, queueStatus, redBatchRate } from "../checks/queue.ts";
1214
1315 export interface QueueRow {
1416 /** `owner/name`. */
@@ -23,12 +25,20 @@ export interface QueueRow {
2325 * over the last hour's; null when none of them was approved.
2426 */
2527 readonly approvedToLanded: number | null;
28 /** The last hour's candidates and how many were red: only when asked for. */
29 readonly batches?: BatchRate;
30 }
31
32 /** What the caller wants beyond the counts every door shows. */
33 export interface QueueReading {
34 /** The red-batch rate, for /boards. */
35 readonly batches?: boolean;
2636 }
2737
2838 const HOUR_MS = 60 * 60 * 1000;
2939
3040 /** Three queries, however many repositories have a line. */
31 export function readQueues(db: Database, now: Date): QueueRow[] {
41 export function readQueues(db: Database, now: Date, reading: QueueReading = {}): QueueRow[] {
3242 const since = new Date(now.getTime() - HOUR_MS).toISOString();
3343 const landed = db
3444 .query<{ id: string; full: string; n: number }, [string, string]>(
@@ -75,10 +85,17 @@ export function readQueues(db: Database, now: Date): QueueRow[] {
7585 gates: lines.get(id)?.gates ?? 0,
7686 landedHour: count.get(id) ?? 0,
7787 approvedToLanded: medianMinutes(waits.get(id) ?? []),
88 ...(reading.batches === true ? { batches: redBatchRate(id, now.getTime()) } : {}),
7889 }))
7990 .sort((a, b) => b.gating + b.queued - (a.gating + a.queued) || a.repo.localeCompare(b.repo));
8091 }
8192
93 /** `2 of 9` red candidates in the last hour; a dash when none was gated or none was asked for. */
94 export function redBatchWords(row: QueueRow): string {
95 const b = row.batches;
96 return b === undefined || b.batches === 0 ? "—" : `${b.red} of ${b.batches}`;
97 }
98
8299 /** The median of `ms`, in whole minutes; null for none. */
83100 function medianMinutes(ms: readonly number[]): number | null {
84101 if (ms.length === 0) return null;
src/system/telemetry.ts +3 −3
@@ -9,7 +9,7 @@ import type { App } from "../app.ts";
99 import { SOURCES } from "../boards/sources.ts";
1010 import { conflictedPulls, conflictedSays } from "../flow/conflicted.ts";
1111 import { readPipeline, verdict } from "../flow/pipeline.ts";
12 import { readQueues } from "../flow/queue.ts";
12 import { type QueueReading, readQueues } from "../flow/queue.ts";
1313 import { requestedSays, requestedWaits } from "../flow/requested.ts";
1414 import { readThroughput, stageOut, throughputSays } from "../flow/throughput.ts";
1515 import type { Reader } from "../actions/telemetry.ts";
@@ -48,8 +48,8 @@ export function flowFor(app: App, reader: Reader, now = new Date()) {
4848 }
4949
5050 /** The merge queue as `reader` may see it: an agent's has private repositories cut out. */
51 export function queuesFor(app: App, reader: Reader, now = new Date()) {
52 return readQueues(app.db, now).filter((q) => reader.kind === "owner" || publicRepo(app, q.repo));
51 export function queuesFor(app: App, reader: Reader, now = new Date(), reading: QueueReading = {}) {
52 return readQueues(app.db, now, reading).filter((q) => reader.kind === "owner" || publicRepo(app, q.repo));
5353 }
5454
5555 function publicRepo(app: App, full: string): boolean {
src/tui/forge.ts +1 −1
@@ -287,7 +287,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
287287 session.db,
288288 new Date(),
289289 systemFor(session.app, flowReader).duplicates,
290 flowReader.kind === "agent" ? { queues: queuesFor(session.app, flowReader) } : undefined,
290 flowReader.kind === "agent" ? { queues: queuesFor(session.app, flowReader, new Date(), { batches: true }) } : undefined,
291291 readHeld(session.app, flowReader.kind === "owner"),
292292 )));
293293 },
src/tui/screens/flow.ts +7 −2
@@ -18,7 +18,7 @@ import { HOLDS } from "../../crew/held.ts";
1818 import type { HeldRow } from "../../flow/held.ts";
1919 import { conflictedPulls, conflictedSays } from "../../flow/conflicted.ts";
2020 import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts";
21 import { readQueues } from "../../flow/queue.ts";
21 import { readQueues, redBatchWords } from "../../flow/queue.ts";
2222 import { requestedSays, requestedWaits } from "../../flow/requested.ts";
2323 import { readThroughput, stageOut, throughputSays } from "../../flow/throughput.ts";
2424 import type { Ctx, Screen } from "../app/app.ts";
@@ -43,7 +43,9 @@ export function flowLines(
4343 const stages = readPipeline(db, now);
4444 const flow = readThroughput(db, stages.map((s) => s.key), now);
4545 const inner = WIDTH - 6;
46 const queues = agent?.queues ?? readQueues(db, now);
46 const queues = agent?.queues ?? readQueues(db, now, { batches: true });
47 // The row is full at 58 columns: the red-batch rate (#4235) is a line under it.
48 const red = queues.filter((q) => (q.batches?.batches ?? 0) > 0);
4749 const conflicted = conflictedSays(conflictedPulls(db, agent !== undefined), now.getTime());
4850 const requested = requestedWaits(db, agent !== undefined);
4951 const waits = [
@@ -68,6 +70,9 @@ export function flowLines(
6870 (q) =>
6971 `${pad(truncate(q.repo, 15), 15)} ${pad(String(q.queued), 6, "right")} ${pad(String(q.gating), 6, "right")} ${pad(String(q.gates), 5, "right")} ${pad(String(q.landedHour), 9, "right")} ${pad(q.approvedToLanded === null ? "—" : String(q.approvedToLanded), 12, "right")}`,
7072 )),
73 ...(red.length === 0
74 ? []
75 : wrap(`Red batches, last hour: ${red.map((q) => `${q.repo} ${redBatchWords(q)}`).join(", ")}.`, inner)),
7176 ...(conflicted === null ? [] : ["", ...wrap(conflicted, inner)]),
7277 ...waits,
7378 ...(held === undefined ? [] : ["", ...heldLines(held, inner)]),
src/web/pages/flow-panel.ts +6 −3
@@ -30,7 +30,7 @@ import {
3030 stageOf,
3131 type StageThroughput,
3232 } from "../../flow/throughput.ts";
33 import type { QueueRow } from "../../flow/queue.ts";
33 import { type QueueRow, redBatchWords } from "../../flow/queue.ts";
3434 import { readHeld } from "../../flow/held.ts";
3535 import type { App } from "../../app.ts";
3636 import { html, type Html } from "../html.ts";
@@ -162,7 +162,7 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
162162 { head: html`<h2>Pipeline</h2><a class="muted small" href="/settings/capacity">Capacity</a>`, id: "pipeline", pad: false },
163163 )}
164164 ${heldCard(readHeld(app, reader.kind === "owner", now))}
165 ${queueCard(queuesFor(app, reader, new Date(now)))}
165 ${queueCard(queuesFor(app, reader, new Date(now), { batches: true }))}

Only the web door asks for batches. Pass it from the terminal Flow command too (src/tui/forge.ts:290) and render the same N of M there.

166166 ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`;
167167 }
168168
@@ -174,7 +174,8 @@ function queueCard(rows: readonly QueueRow[]): Html {
174174 Green, approved pulls join their repository's queue; each candidate
175175 is gated once and lands whole, or is searched for the red one. Where
176176 check slots are free the next candidate gates on top of this one, and
177 a red one's halves gate at once.
177 a red one's halves gate at once. A pull found red is re-checked alone
178 before it rides with others again.
178179 </p>
179180 ${rows.length === 0
180181 ? html`<p class="muted">Nothing queued, gating or landed in the last hour.</p>`
@@ -188,6 +189,7 @@ function queueCard(rows: readonly QueueRow[]): Html {
188189 <th scope="col">Gates</th>
189190 <th scope="col">Landed, last hour</th>
190191 <th scope="col">Approved to landed</th>
192 <th scope="col">Red batches, last hour</th>
191193 </tr>
192194 </thead>
193195 <tbody>
@@ -200,6 +202,7 @@ function queueCard(rows: readonly QueueRow[]): Html {
200202 <td class="mono" data-gates>${String(r.gates)}</td>
201203 <td class="mono" data-landed>${String(r.landedHour)}</td>
202204 <td class="mono" data-approved-to-landed>${r.approvedToLanded === null ? "—" : `${r.approvedToLanded} min`}</td>
205 <td class="mono" data-red-batches>${redBatchWords(r)}</td>
203206 </tr>`,
204207 )}
205208 </tbody>
test/merge-queue-clash.test.ts added +146
@@ -0,0 +1,146 @@
1 /**
2 * The merge queue remembers what its searches found (#4235). Three pulls
3 * where A and B clash — each green alone, red together — and C is fine:
4 * the search lands A and C and hands B back once, remembered on top of A.
5 * B joining again at the same head is re-checked alone, after the fresh
6 * pulls and stacked on by nothing, so it never rides with A or with fresh
7 * pulls again; a push forgets it. The gate here reads the candidate's
8 * tree: red when it holds both `a.txt` and `b.txt`.
9 */
10 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { type Candidate, enqueue, type Expect, queueClashes, type Queued, redBatchRate } from "../src/checks/queue.ts";
15 import type { Pull } from "../src/core/pulls.ts";
16
17 describe("the merge queue remembers clashes", () => {
18 let dir: string;
19 /** Every gate: the pull numbers its candidate carried, and its verdict. */
20 const gates: { pulls: number[]; ok: boolean }[] = [];
21 /** Every hand-back: the pull, and the pulls it was red on top of. */
22 const told: { pull: number; together: number[] }[] = [];
23
24 const git = (...args: string[]) =>
25 Bun.spawnSync(["git", "-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", ...args]).stdout.toString().trim();
26
27 beforeAll(async () => {
28 dir = await mkdtemp(join(tmpdir(), "forge-merge-queue-clash-"));
29 Bun.spawnSync(["git", "init", "-q", "-b", "main", dir]);
30 git("commit", "-q", "--allow-empty", "-m", "start");
31 });
32
33 afterAll(async () => {
34 await rm(dir, { recursive: true, force: true });
35 });
36
37 /** A pull's head: one file added on main as it is now. */
38 async function headAdding(branch: string, file: string): Promise<string> {
39 // Off main, and reset to it: landings move main under the work tree.
40 git("checkout", "-q", "-B", branch, "main");
41 git("reset", "-q", "--hard", "main");
42 await Bun.write(join(dir, file), `${file}\n`);
43 git("add", file);
44 git("commit", "-q", "-m", file);
45 git("checkout", "-q", "--detach");
46 return git("rev-parse", branch);
47 }
48
49 const gate = async (candidate: Candidate) => {
50 const files = git("ls-tree", "--name-only", candidate.tipOid).split("\n");
51 const ok = !(files.includes("a.txt") && files.includes("b.txt"));
52 gates.push({ pulls: candidate.steps.map((s) => s.entry.pull.number), ok });
53 return ok
54 ? ({ lands: true } as const)
55 : ({ lands: false, reopen: true, comment: "red", learn: [] } as const);
56 };
57
58 /** A queued pull that lands the way the forge does: only on the base it was gated on. */
59 function entry(number: number, headOid: string): Queued {
60 return {
61 pull: { id: `clash-${number}`, number, baseBranch: "main", headOid } as Pull,
62 dir,
63 land: async (expect: Expect) => {
64 if (git("rev-parse", "refs/heads/main") !== expect.baseOid) return undefined;
65 const tip = git("commit-tree", expect.treeOid, "-p", expect.baseOid, "-m", `#${number}`);
66 git("update-ref", "refs/heads/main", tip);
67 return tip;
68 },
69 release: () => {},
70 handBack: (_red, _base, together) => told.push({ pull: number, together: [...together] }),
71 };
72 }
73
74 const repoId = "clash-line";
75 const together = (a: number, b: number) => gates.filter((g) => g.pulls.includes(a) && g.pulls.includes(b)).length;
76
77 test("A and B clash, C is fine: A and C land, B is handed back once on top of A", async () => {
78 const [a, b, c] = [
79 entry(1, await headAdding("a", "a.txt")),
80 entry(2, await headAdding("b", "b.txt")),
81 entry(3, await headAdding("c", "c.txt")),
82 ];
83 expect(await enqueue(repoId, [a, b, c], gate)).toEqual([true, false, true]);
84 expect(told).toEqual([{ pull: 2, together: [1] }]);
85 // The red candidate and the search's prefix that found it: never again.
86 expect(together(1, 2)).toBe(2);
87 expect(queueClashes(repoId)).toBe(1);
88 expect(redBatchRate(repoId)).toEqual({ batches: 2, red: 1 });
89 expect(git("ls-tree", "--name-only", "refs/heads/main").split("\n").sort()).toEqual(["a.txt", "c.txt"]);
90 });
91
92 test("B joining again at the same head is re-checked alone, after the fresh pulls, and cuts nothing off", async () => {
93 const b = entry(2, git("rev-parse", "b"));
94 const [d, e] = [entry(4, await headAdding("d", "d.txt")), entry(5, await headAdding("e", "e.txt"))];
95 const before = gates.length;
96 // B is first in line; the fresh pulls still gate first, together.
97 expect(await enqueue(repoId, [b, d, e], gate, { depth: 2, room: () => 4 })).toEqual([false, true, true]);
98 const mine = gates.slice(before);
99 expect(mine).toEqual([
100 { pulls: [4, 5], ok: true },
101 { pulls: [2], ok: false },
102 ]);
103 expect(together(1, 2)).toBe(2);
104 // Alone on main as it is now, B is red on its own merge.
105 expect(told.at(-1)).toEqual({ pull: 2, together: [] });
106 expect(queueClashes(repoId)).toBe(0);
107 // A red re-check is no red batch.
108 expect(redBatchRate(repoId)).toEqual({ batches: 3, red: 1 });
109 });
110
111 test("a fresh pull joining behind a re-check is not stacked on it", async () => {
112 const b = entry(2, git("rev-parse", "b"));
113 let release = (): void => {};
114 const held = new Promise<void>((r) => (release = r));
115 const before = gates.length;
116 let open = 0;
117 const holding = async (candidate: Candidate) => {
118 open++;
119 await held;
120 return gate(candidate);
121 };
122 const options = { depth: 2, room: () => 4 };
123 const first = enqueue(repoId, [b], holding, options);
124 for (let i = 0; i < 2000 && open < 1; i++) await Bun.sleep(5);
125 const f = entry(6, await headAdding("f", "f.txt"));
126 const second = enqueue(repoId, [f], holding, options);
127 for (let i = 0; i < 40; i++) await Bun.sleep(5);
128 // The fresh pull waits for the re-check rather than gate on top of it.
129 expect(open).toBe(1);
130 release();
131 expect([...(await first), ...(await second)]).toEqual([false, true]);
132 expect(gates.slice(before)).toEqual([
133 { pulls: [2], ok: false },
134 { pulls: [6], ok: true },
135 ]);
136 });
137
138 test("a push is a new head: B is forgotten, batched with the rest, and lands", async () => {
139 const pushed = entry(2, await headAdding("b2", "b2.txt"));
140 const g = entry(7, await headAdding("g", "g.txt"));
141 const before = gates.length;
142 expect(await enqueue(repoId, [pushed, g], gate)).toEqual([true, true]);
143 expect(gates.slice(before)).toEqual([{ pulls: [2, 7], ok: true }]);
144 expect(queueClashes(repoId)).toBe(0);
145 });
146 });
test/merge-queue.test.ts +37
@@ -533,4 +533,41 @@ describe("the merge queue", () => {
533533 expect(await sweepGreen(app, repo)).toBe(0);
534534 expect(gatesOf(repo)).toHaveLength(before);
535535 });
536
537 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 () => {
538 const repo = await autonomousRepo("red-stays-out");
539 const a = await greenPull(repo, "a.ts");
540 const red = await greenPull(repo, "red-x.ts");
541 expect(await sweepGreen(app, repo)).toBe(1);
542 expect(merged(repo, a)).toBe(true);
543 // Red on top of a, which landed first from the same candidate: it says so.
544 expect(listComments(app.db, red.id).at(-1)?.body).toContain(`Red on top of #${a.number}`);
545 // Main moves: a new base is no new try, so the red does not ride b's batch.
546 const b = await greenPull(repo, "b.ts");
547 let before = gatesOf(repo).length;
548 expect(await sweepGreen(app, repo)).toBe(1);
549 expect(merged(repo, b)).toBe(true);
550 expect(gatesOf(repo)).toHaveLength(before + 1);
551 // Past the re-gate wait it joins again, behind the fresh pull and alone.
552 age(red);
553 const c = await greenPull(repo, "c.ts");
554 before = gatesOf(repo).length;
555 expect(await sweepGreen(app, repo)).toBe(1);
556 expect(merged(repo, c)).toBe(true);
557 const [fresh, recheck] = gatesOf(repo).slice(before);
558 expect(fresh?.ok).toBe(true);
559 expect(git(repo, "ls-tree", "--name-only", fresh!.commit)).not.toContain("red-x.ts");
560 expect(recheck?.ok).toBe(false);
561 // Gated on main once c had landed: c is under it, and nothing else rode with it.
562 expect(git(repo, "ls-tree", "--name-only", recheck!.commit).split("\n").sort()).toEqual(
563 ["README.md", "a.ts", "b.ts", "c.ts", "forge.json", "red-x.ts"],
564 );
565 // /boards: one red candidate of three in the hour; the re-check is none.
566 const page = await (await fetch(`${servers.url}/boards`, { headers: { cookie: await login() } })).text();
567 expect(page).toMatch(/data-queue="alice\/red-stays-out">[\s\S]*?data-red-batches>1 of 3</);
568 // The terminal's Flow dialog says the same, on a line of its own under the table.
569 expect(flowLines(app.db).join(" ").replace(/\s+/g, " ")).toMatch(/Red batches, last hour: [^.]*alice\/red-stays-out 1 of 3/);
570 // The MCP rows keep their shape: the rate is the page's and the terminal's.
571 expect(Object.keys((await mcpQueue()).find((q) => (q as { repo: string }).repo === "alice/red-stays-out") as object)).not.toContain("batches");
572 });
536573 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs