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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/4235-the-merge-queue-remembers-clashes-two-pu → main 3 commits fork 1 thread · 1 unresolved
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 | ||
| 1262 | 1262 | starts only on a free check slot and holds it, and two slots are always kept |
| 1263 | 1263 | for pull checks (`PULL_RESERVE`); with no slot spare the queue gates one |
| 1264 | 1264 | 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 | |
| 1268 | 1275 | roll, a drain) or by the cluster losing its Job is no red at all: the next |
| 1269 | 1276 | sweep gates it again. So is a gate that has not answered in 15 minutes |
| 1270 | 1277 | (`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 | ||
| 1273 | 1280 | queue's numbers — how many pulls are queued, how many are gating, how many |
| 1274 | 1281 | gates are in flight, how many landed in the last hour, and their median |
| 1275 | 1282 | 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. | |
| 1277 | 1289 | When a repository requires checks, an approved pull that is green only |
| 1278 | 1290 | against an older main joins the queue as it is: the candidate's full gate |
| 1279 | 1291 | 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 @@ | ||
| 23 | 23 | * cut off, logged, and the line re-formed by the next sweep, so one gate |
| 24 | 24 | * that never returns cannot hold the queue for good (#3430). |
| 25 | 25 | * |
| 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 | * | |
| 26 | 35 | * What is queued is not stored: it is the pulls `landIfGreen` admitted, and |
| 27 | 36 | * 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. | |
| 29 | 39 | */ |
| 30 | 40 | |
| 31 | 41 | import type { Pull } from "../core/pulls.ts"; |
| @@ -58,8 +68,12 @@ export interface Queued { | ||
| 58 | 68 | * now at `tip`: called once per candidate that landed anything. |
| 59 | 69 | */ |
| 60 | 70 | 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; | |
| 63 | 77 | } |
| 64 | 78 | |
| 65 | 79 | /** One pull's squash on top of the steps before it. */ |
| @@ -134,6 +148,21 @@ type Room = () => number; | ||
| 134 | 148 | interface Waiting { |
| 135 | 149 | readonly entry: Queued; |
| 136 | 150 | 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; | |
| 137 | 166 | } |
| 138 | 167 | |
| 139 | 168 | /** One gate in flight: the pulls its candidate carries, and since when. */ |
| @@ -151,6 +180,10 @@ interface Line { | ||
| 151 | 180 | readonly flights: Set<InFlight>; |
| 152 | 181 | /** The gate runs this line has made, for a test and the log. */ |
| 153 | 182 | 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[]; | |
| 154 | 187 | draining: boolean; |
| 155 | 188 | /** Wakes a drain waiting on a gate when pulls join the line. */ |
| 156 | 189 | wake: (() => void) | undefined; |
| @@ -162,6 +195,8 @@ interface Flight { | ||
| 162 | 195 | readonly candidate: Candidate; |
| 163 | 196 | /** Built on the flight before it, not on the base's tip. */ |
| 164 | 197 | readonly speculative: boolean; |
| 198 | /** A remembered red gated alone: nothing stacks on it. */ | |
| 199 | readonly recheck: boolean; | |
| 165 | 200 | readonly branch: string; |
| 166 | 201 | readonly verdict: Promise<LandVerdict>; |
| 167 | 202 | readonly abort: AbortController; |
| @@ -194,7 +229,17 @@ const UNDECIDED: LandVerdict = { lands: false, interrupted: true }; | ||
| 194 | 229 | function lineOf(repoId: string): Line { |
| 195 | 230 | let line = lines.get(repoId); |
| 196 | 231 | 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 | }; | |
| 198 | 243 | lines.set(repoId, line); |
| 199 | 244 | } |
| 200 | 245 | return line; |
| @@ -215,11 +260,16 @@ export function enqueue( | ||
| 215 | 260 | const answers = entries.map((entry) => { |
| 216 | 261 | if (line.holds.has(entry.pull.id)) return Promise.resolve(false); |
| 217 | 262 | 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; | |
| 218 | 266 | return new Promise<boolean>((resolve) => |
| 219 | 267 | line.waiting.push({ |
| 220 | 268 | entry, |
| 269 | recheck, | |
| 221 | 270 | settle: (landed) => { |
| 222 | 271 | line.holds.delete(entry.pull.id); |
| 272 | if (landed) line.reds.delete(entry.pull.id); | |
| 223 | 273 | resolve(landed); |
| 224 | 274 | }, |
| 225 | 275 | }), |
| @@ -276,6 +326,31 @@ export function queueWords(repoId: string): string { | ||
| 276 | 326 | return `gating ${pullsWord(s.gating)} since ${hhmm(s.since)}${gates}${waiting === "" ? "" : `, ${waiting}`}`; |
| 277 | 327 | } |
| 278 | 328 | |
| 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 | ||
| 279 | 354 | /** `1 pull`, `8 pulls`. */ |
| 280 | 355 | function pullsWord(n: number): string { |
| 281 | 356 | return n === 1 ? "1 pull" : `${n} pulls`; |
| @@ -393,7 +468,7 @@ async function drain(line: Line, gate: GateCandidate, options: QueueOptions): Pr | ||
| 393 | 468 | ahead.shift(); |
| 394 | 469 | let left: Waiting[]; |
| 395 | 470 | try { |
| 396 | left = await settleHead(head, verdict, ahead, gated, room); | |
| 471 | left = await settleHead({ line, gated, room }, head, verdict, ahead); | |
| 397 | 472 | } catch (error) { |
| 398 | 473 | log.warn("merge queue round failed", { error: String(error) }); |
| 399 | 474 | for (const w of head.steps) w.settle(false); |
| @@ -409,17 +484,21 @@ async function drain(line: Line, gate: GateCandidate, options: QueueOptions): Pr | ||
| 409 | 484 | |
| 410 | 485 | /** |
| 411 | 486 | * 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. | |
| 415 | 491 | */ |
| 416 | 492 | 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; | |
| 418 | 495 | 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); | |
| 423 | 502 | line.waiting = line.waiting.filter((w) => !batch.includes(w)); |
| 424 | 503 | let candidate: Candidate | undefined; |
| 425 | 504 | try { |
| @@ -437,7 +516,7 @@ async function launch(line: Line, gated: Gated, on: Flight | undefined): Promise | ||
| 437 | 516 | log.warn("merge queue round failed", { error: String(error) }); |
| 438 | 517 | return UNDECIDED; |
| 439 | 518 | }); |
| 440 | return { steps, candidate, speculative: on !== undefined, branch, verdict, abort }; | |
| 519 | return { steps, candidate, speculative: on !== undefined, recheck: front.recheck, branch, verdict, abort }; | |
| 441 | 520 | } |
| 442 | 521 | |
| 443 | 522 | /** |
| @@ -459,13 +538,7 @@ function inCandidate(batch: readonly Waiting[], candidate: Candidate): Waiting[] | ||
| 459 | 538 | * the tree it was built on — the flight before it landed whole. Returns |
| 460 | 539 | * what waits for the next candidate. |
| 461 | 540 | */ |
| 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[]> { | |
| 469 | 542 | let candidate = head.candidate; |
| 470 | 543 | if (head.speculative) { |
| 471 | 544 | const tip = await onTip(candidate, head.branch); |
| @@ -475,6 +548,7 @@ async function settleHead( | ||
| 475 | 548 | } |
| 476 | 549 | candidate = { ...candidate, baseOid: tip }; |
| 477 | 550 | } |
| 551 | if (!head.recheck && !("interrupted" in verdict)) search.line.batches.push({ at: Date.now(), red: !verdict.lands }); | |
| 478 | 552 | if (verdict.lands) { |
| 479 | 553 | if (await land(candidate, head.steps)) return []; |
| 480 | 554 | return discard(ahead); |
| @@ -486,7 +560,7 @@ async function settleHead( | ||
| 486 | 560 | for (const w of head.steps) w.settle(false); |
| 487 | 561 | return behind; |
| 488 | 562 | } |
| 489 | return [...(await judged(candidate, head.steps, verdict, gated, room)), ...behind]; | |
| 563 | return [...(await judged(search, candidate, head.steps, verdict, [])), ...behind]; | |
| 490 | 564 | } |
| 491 | 565 | |
| 492 | 566 | /** |
| @@ -515,18 +589,27 @@ async function discard(ahead: Flight[]): Promise<Waiting[]> { | ||
| 515 | 589 | return gone.flatMap((f) => [...f.steps]); |
| 516 | 590 | } |
| 517 | 591 | |
| 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 | ||
| 518 | 599 | /** |
| 519 | 600 | * Build, gate and land one candidate of `batch` on the base's tip. `known` |
| 520 | 601 | * 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 | |
| 522 | 604 | * candidate. |
| 523 | 605 | */ |
| 524 | 606 | async function round( |
| 607 | search: Search, | |
| 525 | 608 | 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[], | |
| 529 | 611 | ): Promise<Waiting[]> { |
| 612 | const { gated } = search; | |
| 530 | 613 | const candidate = await build(batch); |
| 531 | 614 | if (candidate === undefined || candidate.steps.length === 0) { |
| 532 | 615 | for (const w of batch) w.settle(false); |
| @@ -534,7 +617,7 @@ async function round( | ||
| 534 | 617 | } |
| 535 | 618 | const steps = inCandidate(batch, candidate); |
| 536 | 619 | 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); | |
| 538 | 621 | if (verdict.lands) { |
| 539 | 622 | await land(candidate, steps); |
| 540 | 623 | return []; |
| @@ -543,7 +626,7 @@ async function round( | ||
| 543 | 626 | for (const w of steps) w.settle(false); |
| 544 | 627 | return []; |
| 545 | 628 | } |
| 546 | return judged(candidate, steps, verdict, gated, room); | |
| 629 | return judged(search, candidate, steps, verdict, landed); | |
| 547 | 630 | } |
| 548 | 631 | |
| 549 | 632 | /** |
| @@ -557,14 +640,26 @@ async function round( | ||
| 557 | 640 | * candidate. |
| 558 | 641 | */ |
| 559 | 642 | async function judged( |
| 643 | search: Search, | |
| 560 | 644 | candidate: Candidate, |
| 561 | 645 | steps: readonly Waiting[], |
| 562 | 646 | red: Red, |
| 563 | gated: Gated, | |
| 564 | room: Room, | |
| 647 | landed: readonly Queued[], | |
| 565 | 648 | ): Promise<Waiting[]> { |
| 649 | const { gated, room } = search; | |
| 566 | 650 | 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); | |
| 568 | 663 | steps[0]!.settle(false); |
| 569 | 664 | return []; |
| 570 | 665 | } |
| @@ -605,7 +700,8 @@ async function judged( | ||
| 605 | 700 | return []; |
| 606 | 701 | } |
| 607 | 702 | 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); | |
| 609 | 705 | return [...left, ...steps.slice(stopAt)]; |
| 610 | 706 | } |
| 611 | 707 |
src/crew/autonomy.ts +24 −10
| @@ -23,7 +23,7 @@ import { | ||
| 23 | 23 | import type { AccessError } from "../actions/access.ts"; |
| 24 | 24 | import { gateCandidate, releaseOnLand } from "../checks/landing.ts"; |
| 25 | 25 | 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"; | |
| 27 | 27 | import { checksFor } from "../checks/service.ts"; |
| 28 | 28 | import { queueDepth } from "../core/capacity.ts"; |
| 29 | 29 | import { mergeable, resolveOid, treeOf } from "../git/merge.ts"; |
| @@ -1277,6 +1277,8 @@ export async function autonomyTick( | ||
| 1277 | 1277 | ...(approved === undefined ? {} : { approved }), |
| 1278 | 1278 | // What the merge queue is doing now, not what the sweep admitted (#3430). |
| 1279 | 1279 | queue: queueWords(current.id), |
| 1280 | // Pulls the queue found red on top of others of their candidate (#4235). | |
| 1281 | clashes: queueClashes(current.id), | |
| 1280 | 1282 | spent: `${status.spentTodayUsd.toFixed(2)}/${status.ceilingUsd}`, |
| 1281 | 1283 | ...(status.paused === null ? {} : { paused: status.paused }), |
| 1282 | 1284 | // Starts after the first, and how many followed a crash (#3708). |
| @@ -1867,13 +1869,14 @@ async function admit( | ||
| 1867 | 1869 | const review = await reviewed(app, repo, pull); |
| 1868 | 1870 | if (review === "hold") return err("review"); |
| 1869 | 1871 | // 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). | |
| 1874 | 1878 | 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"); | |
| 1877 | 1880 | // Green only against a base that has moved since is admitted as it is: |
| 1878 | 1881 | // the candidate's full gate runs on the base tip with this pull merged, |
| 1879 | 1882 | // which is the proof a re-check of its own would give, and a red one |
| @@ -1894,7 +1897,7 @@ async function admit( | ||
| 1894 | 1897 | why: error instanceof Error ? error.message : String(error), |
| 1895 | 1898 | }), |
| 1896 | 1899 | ), |
| 1897 | handBack: (red, baseOid) => { | |
| 1900 | handBack: (red, baseOid, together) => { | |
| 1898 | 1901 | // Told on the base it was found red on: a new base is a new try. Red |
| 1899 | 1902 | // again on the same head and base is the re-gate reproducing it: it |
| 1900 | 1903 | // stays red for good, and the pull is not told twice. |
| @@ -1912,12 +1915,23 @@ async function admit( | ||
| 1912 | 1915 | ms: 0, |
| 1913 | 1916 | }); |
| 1914 | 1917 | 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)); | |
| 1916 | 1919 | }, |
| 1917 | 1920 | }); |
| 1918 | 1921 | } |
| 1919 | 1922 | |
| 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). */ | |
| 1921 | 1935 | export const REGATE_RED_MS = 30 * 60_000; |
| 1922 | 1936 | |
| 1923 | 1937 | /** |
src/flow/queue.ts +19 −2
| @@ -5,10 +5,12 @@ | ||
| 5 | 5 | * last hour, and their median minutes from approval to landing. Queued, |
| 6 | 6 | * gating and gates are this process's line (`checks/queue.ts`); landed is |
| 7 | 7 | * 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. | |
| 8 | 10 | */ |
| 9 | 11 | |
| 10 | 12 | import type { Database } from "bun:sqlite"; |
| 11 | import { queueStatus } from "../checks/queue.ts"; | |
| 13 | import { type BatchRate, queueStatus, redBatchRate } from "../checks/queue.ts"; | |
| 12 | 14 | |
| 13 | 15 | export interface QueueRow { |
| 14 | 16 | /** `owner/name`. */ |
| @@ -23,12 +25,20 @@ export interface QueueRow { | ||
| 23 | 25 | * over the last hour's; null when none of them was approved. |
| 24 | 26 | */ |
| 25 | 27 | 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; | |
| 26 | 36 | } |
| 27 | 37 | |
| 28 | 38 | const HOUR_MS = 60 * 60 * 1000; |
| 29 | 39 | |
| 30 | 40 | /** 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[] { | |
| 32 | 42 | const since = new Date(now.getTime() - HOUR_MS).toISOString(); |
| 33 | 43 | const landed = db |
| 34 | 44 | .query<{ id: string; full: string; n: number }, [string, string]>( |
| @@ -75,10 +85,17 @@ export function readQueues(db: Database, now: Date): QueueRow[] { | ||
| 75 | 85 | gates: lines.get(id)?.gates ?? 0, |
| 76 | 86 | landedHour: count.get(id) ?? 0, |
| 77 | 87 | approvedToLanded: medianMinutes(waits.get(id) ?? []), |
| 88 | ...(reading.batches === true ? { batches: redBatchRate(id, now.getTime()) } : {}), | |
| 78 | 89 | })) |
| 79 | 90 | .sort((a, b) => b.gating + b.queued - (a.gating + a.queued) || a.repo.localeCompare(b.repo)); |
| 80 | 91 | } |
| 81 | 92 | |
| 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 | ||
| 82 | 99 | /** The median of `ms`, in whole minutes; null for none. */ |
| 83 | 100 | function medianMinutes(ms: readonly number[]): number | null { |
| 84 | 101 | if (ms.length === 0) return null; |
src/system/telemetry.ts +3 −3
| @@ -9,7 +9,7 @@ import type { App } from "../app.ts"; | ||
| 9 | 9 | import { SOURCES } from "../boards/sources.ts"; |
| 10 | 10 | import { conflictedPulls, conflictedSays } from "../flow/conflicted.ts"; |
| 11 | 11 | import { readPipeline, verdict } from "../flow/pipeline.ts"; |
| 12 | import { readQueues } from "../flow/queue.ts"; | |
| 12 | import { type QueueReading, readQueues } from "../flow/queue.ts"; | |
| 13 | 13 | import { requestedSays, requestedWaits } from "../flow/requested.ts"; |
| 14 | 14 | import { readThroughput, stageOut, throughputSays } from "../flow/throughput.ts"; |
| 15 | 15 | import type { Reader } from "../actions/telemetry.ts"; |
| @@ -48,8 +48,8 @@ export function flowFor(app: App, reader: Reader, now = new Date()) { | ||
| 48 | 48 | } |
| 49 | 49 | |
| 50 | 50 | /** 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)); | |
| 53 | 53 | } |
| 54 | 54 | |
| 55 | 55 | 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[] { | ||
| 287 | 287 | session.db, |
| 288 | 288 | new Date(), |
| 289 | 289 | 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, | |
| 291 | 291 | readHeld(session.app, flowReader.kind === "owner"), |
| 292 | 292 | ))); |
| 293 | 293 | }, |
src/tui/screens/flow.ts +7 −2
| @@ -18,7 +18,7 @@ import { HOLDS } from "../../crew/held.ts"; | ||
| 18 | 18 | import type { HeldRow } from "../../flow/held.ts"; |
| 19 | 19 | import { conflictedPulls, conflictedSays } from "../../flow/conflicted.ts"; |
| 20 | 20 | import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts"; |
| 21 | import { readQueues } from "../../flow/queue.ts"; | |
| 21 | import { readQueues, redBatchWords } from "../../flow/queue.ts"; | |
| 22 | 22 | import { requestedSays, requestedWaits } from "../../flow/requested.ts"; |
| 23 | 23 | import { readThroughput, stageOut, throughputSays } from "../../flow/throughput.ts"; |
| 24 | 24 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -43,7 +43,9 @@ export function flowLines( | ||
| 43 | 43 | const stages = readPipeline(db, now); |
| 44 | 44 | const flow = readThroughput(db, stages.map((s) => s.key), now); |
| 45 | 45 | 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); | |
| 47 | 49 | const conflicted = conflictedSays(conflictedPulls(db, agent !== undefined), now.getTime()); |
| 48 | 50 | const requested = requestedWaits(db, agent !== undefined); |
| 49 | 51 | const waits = [ |
| @@ -68,6 +70,9 @@ export function flowLines( | ||
| 68 | 70 | (q) => |
| 69 | 71 | `${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")}`, |
| 70 | 72 | )), |
| 73 | ...(red.length === 0 | |
| 74 | ? [] | |
| 75 | : wrap(`Red batches, last hour: ${red.map((q) => `${q.repo} ${redBatchWords(q)}`).join(", ")}.`, inner)), | |
| 71 | 76 | ...(conflicted === null ? [] : ["", ...wrap(conflicted, inner)]), |
| 72 | 77 | ...waits, |
| 73 | 78 | ...(held === undefined ? [] : ["", ...heldLines(held, inner)]), |
src/web/pages/flow-panel.ts +6 −3
| @@ -30,7 +30,7 @@ import { | ||
| 30 | 30 | stageOf, |
| 31 | 31 | type StageThroughput, |
| 32 | 32 | } from "../../flow/throughput.ts"; |
| 33 | import type { QueueRow } from "../../flow/queue.ts"; | |
| 33 | import { type QueueRow, redBatchWords } from "../../flow/queue.ts"; | |
| 34 | 34 | import { readHeld } from "../../flow/held.ts"; |
| 35 | 35 | import type { App } from "../../app.ts"; |
| 36 | 36 | import { html, type Html } from "../html.ts"; |
| @@ -162,7 +162,7 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 162 | 162 | { head: html`<h2>Pipeline</h2><a class="muted small" href="/settings/capacity">Capacity</a>`, id: "pipeline", pad: false }, |
| 163 | 163 | )} |
| 164 | 164 | ${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 }))} | |
| 166 | 166 | ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`; |
| 167 | 167 | } |
| 168 | 168 | |
| @@ -174,7 +174,8 @@ function queueCard(rows: readonly QueueRow[]): Html { | ||
| 174 | 174 | Green, approved pulls join their repository's queue; each candidate |
| 175 | 175 | is gated once and lands whole, or is searched for the red one. Where |
| 176 | 176 | 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. | |
| 178 | 179 | </p> |
| 179 | 180 | ${rows.length === 0 |
| 180 | 181 | ? html`<p class="muted">Nothing queued, gating or landed in the last hour.</p>` |
| @@ -188,6 +189,7 @@ function queueCard(rows: readonly QueueRow[]): Html { | ||
| 188 | 189 | <th scope="col">Gates</th> |
| 189 | 190 | <th scope="col">Landed, last hour</th> |
| 190 | 191 | <th scope="col">Approved to landed</th> |
| 192 | <th scope="col">Red batches, last hour</th> | |
| 191 | 193 | </tr> |
| 192 | 194 | </thead> |
| 193 | 195 | <tbody> |
| @@ -200,6 +202,7 @@ function queueCard(rows: readonly QueueRow[]): Html { | ||
| 200 | 202 | <td class="mono" data-gates>${String(r.gates)}</td> |
| 201 | 203 | <td class="mono" data-landed>${String(r.landedHour)}</td> |
| 202 | 204 | <td class="mono" data-approved-to-landed>${r.approvedToLanded === null ? "—" : `${r.approvedToLanded} min`}</td> |
| 205 | <td class="mono" data-red-batches>${redBatchWords(r)}</td> | |
| 203 | 206 | </tr>`, |
| 204 | 207 | )} |
| 205 | 208 | </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", () => { | ||
| 533 | 533 | expect(await sweepGreen(app, repo)).toBe(0); |
| 534 | 534 | expect(gatesOf(repo)).toHaveLength(before); |
| 535 | 535 | }); |
| 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 | }); | |
| 536 | 573 | }); |
new above
offline
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 Mthere.