Public: anyone can read this; the crew can change it.
A merge queue: one gated candidate per repository, batched, so landing never waits on a gate per pull #2058
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/2036-a-merge-queue-one-gated-candidate-per-re → main 6 commits fork 3 threads · 3 unresolved
README.md +14
| @@ -534,6 +534,20 @@ the mark. Only the crew parks: the API and the page refuse `reason: "parked"`. | ||
| 534 | 534 | In the database the pull's `close_reason` stays NULL (its CHECK predates the |
| 535 | 535 | word) and `issue.parked = 1` carries it, so raw SQL never reads it as done. |
| 536 | 536 | |
| 537 | **The merge queue.** A green, approved pull in an autonomous repository joins | |
| 538 | its repository's queue (`src/checks/queue.ts`). The queue builds one | |
| 539 | candidate: main, then each queued pull's squash on top of the one before, in | |
| 540 | queue order, leaving out any pull that conflicts or has nothing left to land. | |
| 541 | The full gate runs once on that candidate. Green lands every pull in order, | |
| 542 | and each landing is refused unless main is still where the candidate was | |
| 543 | built and the squash writes the tree that was gated, so main ends at the | |
| 544 | gated tree. Red bisects: the front half is gated by itself, a green half | |
| 545 | lands, and the red pull is handed back with the failing tests named. Pulls | |
| 546 | that turn green while a candidate is gating wait for the next one. Each | |
| 547 | queue's numbers — how many pulls are queued, how many are gating, and how | |
| 548 | many landed in the last hour — show in three places: the /flow page, the | |
| 549 | terminal's Flow command, and the MCP `flow` tool's `queue` field. | |
| 550 | ||
| 537 | 551 | An autonomous app's planner is **held** while nothing is landing or the plate |
| 538 | 552 | is full: no pull has merged in 48 h while one was closed unmerged, or more than |
| 539 | 553 | `slots × 3` ready, unassigned issues are waiting. The tick log says which |
scripts/gate.ts +1
| @@ -30,6 +30,7 @@ export const GATE: readonly string[] = [ | ||
| 30 | 30 | "test/domain-drill.test.ts", |
| 31 | 31 | "test/conflicts.test.ts", |
| 32 | 32 | "test/lands-once.test.ts", |
| 33 | "test/merge-queue.test.ts", | |
| 33 | 34 | "test/conflicts-tui.test.ts", |
| 34 | 35 | "test/accounts-retire.test.ts", |
| 35 | 36 | "test/client-home.test.ts", |
src/actions/pulls.ts +4
| @@ -26,6 +26,7 @@ import { | ||
| 26 | 26 | pullDiff, |
| 27 | 27 | pullStatus, |
| 28 | 28 | type CreatePullError, |
| 29 | type LandingExpect, | |
| 29 | 30 | type MergePullError, |
| 30 | 31 | type Pull, |
| 31 | 32 | pullDir, |
| @@ -358,6 +359,8 @@ export async function mergePull( | ||
| 358 | 359 | repo: Repo, |
| 359 | 360 | pull: Pull, |
| 360 | 361 | strategy: MergeStrategy, |
| 362 | /** The merge queue's guard: land only onto this base, as this tree (#2036). */ | |
| 363 | expect?: LandingExpect, | |
| 361 | 364 | ): Promise< |
| 362 | 365 | Result<{ mergeOid: string; carried: boolean }, AccessError | MergePullError> |
| 363 | 366 | > { |
| @@ -407,6 +410,7 @@ export async function mergePull( | ||
| 407 | 410 | requireChecks: repo.requireChecks, |
| 408 | 411 | }, |
| 409 | 412 | strategy, |
| 413 | ...(expect === undefined ? {} : { expect }), | |
| 410 | 414 | message: `${ |
| 411 | 415 | strategy === "squash" |
| 412 | 416 | ? `${pull.title} (#${pull.number})` |
src/checks/landing.ts +11 −29
| @@ -24,55 +24,37 @@ import { running } from "../kernel/version.ts"; | ||
| 24 | 24 | import { recordRelease, type Release } from "../release/record.ts"; |
| 25 | 25 | import type { Pull } from "../core/pulls.ts"; |
| 26 | 26 | import type { Repo } from "../core/repos.ts"; |
| 27 | import { runGit } from "../git/env.ts"; | |
| 28 | import { writeMergeTree } from "../git/merge.ts"; | |
| 29 | 27 | import { failuresOf } from "./failures.ts"; |
| 30 | 28 | import { keepForgeGate } from "./gate_last.ts"; |
| 31 | import { addLearned } from "./learned.ts"; | |
| 32 | 29 | import { checksFor } from "./service.ts"; |
| 33 | 30 | import { landVerdict, type LandVerdict } from "./tiers.ts"; |
| 34 | 31 | |
| 35 | const IDENTITY = { | |
| 36 | GIT_AUTHOR_NAME: "forge", | |
| 37 | GIT_AUTHOR_EMAIL: "forge@localhost", | |
| 38 | GIT_COMMITTER_NAME: "forge", | |
| 39 | GIT_COMMITTER_EMAIL: "forge@localhost", | |
| 40 | }; | |
| 41 | ||
| 42 | 32 | /** |
| 43 | * `dir` is where the pull's objects are (`pullDir`). A repository whose | |
| 44 | * check has no fast tier already ran its whole check on this head, and a | |
| 45 | * forge with no checks service has nothing to run: both land. | |
| 33 | * The full gate, once, on a commit that is what main would become — one | |
| 34 | * pull with its base, or the merge queue's candidate of several (#2036). | |
| 35 | * `pull` is the one the gate is asked for: where the objects are and which | |
| 36 | * base branch's check is run. The manifest is the candidate's own. | |
| 46 | 37 | */ |
| 47 | export async function gateOnLand( | |
| 38 | export async function gateCandidate( | |
| 48 | 39 | app: App, |
| 49 | 40 | repo: Repo, |
| 50 | 41 | pull: Pull, |
| 51 | 42 | dir: string, |
| 52 | baseTip: string, | |
| 43 | commitOid: string, | |
| 53 | 44 | ): Promise<LandVerdict> { |
| 54 | 45 | const checks = checksFor(app); |
| 55 | if (checks === undefined || baseTip === "") return { lands: true }; | |
| 46 | if (checks === undefined) return { lands: true }; | |
| 56 | 47 | const [forgeJson, packageJson] = await Promise.all([ |
| 57 | readFileAt(dir, pull.headOid, "forge.json"), | |
| 58 | readFileAt(dir, pull.headOid, "package.json"), | |
| 48 | readFileAt(dir, commitOid, "forge.json"), | |
| 49 | readFileAt(dir, commitOid, "package.json"), | |
| 59 | 50 | ]); |
| 60 | 51 | const declared = parseCheck(forgeJson, packageJson); |
| 61 | 52 | if (!declared.ok || declared.value === undefined || declared.value.fast === null) |
| 62 | 53 | return { lands: true }; |
| 63 | const tree = await writeMergeTree(dir, baseTip, pull.headOid); | |
| 64 | if (!tree.ok) return { lands: true }; | |
| 65 | const commit = await runGit( | |
| 66 | ["commit-tree", tree.value.treeOid, "-p", baseTip, "-p", pull.headOid, "-m", "full gate on land"], | |
| 67 | { cwd: dir, env: IDENTITY }, | |
| 68 | ); | |
| 69 | if (!commit.ok) return { lands: true }; | |
| 70 | const done = await checks.gate(repo, pull, commit.value.trim()); | |
| 54 | const done = await checks.gate(repo, pull, commitOid); | |
| 71 | 55 | keepForgeGate(app.config.dataDir, app.config.forgeRepo, `${repo.ownerName}/${repo.name}`, done.lines); |
| 72 | 56 | const failed = failuresOf(done.lines); |
| 73 | const verdict = landVerdict(done.ok, failed.tests, failed.files); | |
| 74 | if (!verdict.lands) addLearned(app.config.dataDir, pull.id, verdict.learn); | |
| 75 | return verdict; | |
| 57 | return landVerdict(done.ok, failed.tests, failed.files); | |
| 76 | 58 | } |
| 77 | 59 | |
| 78 | 60 | /** |
src/checks/queue.ts added +310
| @@ -0,0 +1,310 @@ | ||
| 1 | /** | |
| 2 | * The merge queue (#2036): per repository, one line of pulls that are green | |
| 3 | * and approved. The line is built into ONE candidate — the base's tip, then | |
| 4 | * each queued pull's squash on top of the one before, in queue order, for | |
| 5 | * every pull that merges cleanly — and that candidate is gated once. Green | |
| 6 | * lands every step in order, each landing guarded so the squash it writes | |
| 7 | * is the step's tree on the base the step was built on: main ends at | |
| 8 | * exactly the tree that was gated. Red bisects: the first half is gated on | |
| 9 | * its own; green lands it and the culprit is in the rest, red keeps the | |
| 10 | * search in the first half while the rest waits for the next candidate. A | |
| 11 | * single red step is the culprit and is handed back. | |
| 12 | * | |
| 13 | * What is queued is not stored: it is the pulls `landIfGreen` admitted, and | |
| 14 | * every sweep admits them again. The queue only orders them and remembers | |
| 15 | * how many there are, for /flow. | |
| 16 | */ | |
| 17 | ||
| 18 | import type { Pull } from "../core/pulls.ts"; | |
| 19 | import { runGit } from "../git/env.ts"; | |
| 20 | import { mergeable, resolveOid, treeOf } from "../git/merge.ts"; | |
| 21 | import { createLog } from "../kernel/log.ts"; | |
| 22 | import type { LandVerdict } from "./tiers.ts"; | |
| 23 | ||
| 24 | /** The base a step must land on, and the tree it must write. */ | |
| 25 | export interface Expect { | |
| 26 | readonly baseOid: string; | |
| 27 | readonly treeOid: string; | |
| 28 | } | |
| 29 | ||
| 30 | /** A red verdict: what the culprit is told. */ | |
| 31 | export type Red = Extract<LandVerdict, { lands: false }>; | |
| 32 | ||
| 33 | /** One pull in the line, and what landing or handing it back does. */ | |
| 34 | export interface Queued { | |
| 35 | readonly pull: Pull; | |
| 36 | /** Where the pull's objects are (`pullDir`): a candidate is built in one place. */ | |
| 37 | readonly dir: string; | |
| 38 | /** | |
| 39 | * Land this pull as the step was gated: the new tip of the base, or | |
| 40 | * undefined when it did not land. | |
| 41 | */ | |
| 42 | land(expect: Expect): Promise<string | undefined>; | |
| 43 | /** | |
| 44 | * This pull was the last step of its candidate to land, and the base is | |
| 45 | * now at `tip`: called once per candidate that landed anything. | |
| 46 | */ | |
| 47 | release(tip: string): void; | |
| 48 | /** The gate found this pull to be the red one, on this base. */ | |
| 49 | handBack(red: Red, baseOid: string): void; | |
| 50 | } | |
| 51 | ||
| 52 | /** One pull's squash on top of the steps before it. */ | |
| 53 | export interface Step { | |
| 54 | readonly entry: Queued; | |
| 55 | readonly baseOid: string; | |
| 56 | readonly treeOid: string; | |
| 57 | } | |
| 58 | ||
| 59 | /** What is gated: a chain of steps on the base, ending at `tipOid`. */ | |
| 60 | export interface Candidate { | |
| 61 | readonly dir: string; | |
| 62 | readonly baseOid: string; | |
| 63 | readonly steps: readonly Step[]; | |
| 64 | readonly tipOid: string; | |
| 65 | readonly treeOid: string; | |
| 66 | } | |
| 67 | ||
| 68 | /** Gate a candidate: the full tier on its tip. */ | |
| 69 | export type GateCandidate = (candidate: Candidate) => Promise<LandVerdict>; | |
| 70 | ||
| 71 | interface Waiting { | |
| 72 | readonly entry: Queued; | |
| 73 | readonly settle: (landed: boolean) => void; | |
| 74 | } | |
| 75 | ||
| 76 | interface Line { | |
| 77 | waiting: Waiting[]; | |
| 78 | /** Pull ids in the queue or its candidate: a pull is in the line once. */ | |
| 79 | readonly holds: Set<string>; | |
| 80 | /** How many pulls the candidate at the gate now carries. */ | |
| 81 | gating: number; | |
| 82 | /** The gate runs this line has made, for a test and the log. */ | |
| 83 | gates: number; | |
| 84 | draining: boolean; | |
| 85 | } | |
| 86 | ||
| 87 | const log = createLog("queue"); | |
| 88 | ||
| 89 | const lines = new Map<string, Line>(); | |
| 90 | ||
| 91 | const IDENTITY = { | |
| 92 | GIT_AUTHOR_NAME: "forge", | |
| 93 | GIT_AUTHOR_EMAIL: "forge@localhost", | |
| 94 | GIT_COMMITTER_NAME: "forge", | |
| 95 | GIT_COMMITTER_EMAIL: "forge@localhost", | |
| 96 | }; | |
| 97 | ||
| 98 | function lineOf(repoId: string): Line { | |
| 99 | let line = lines.get(repoId); | |
| 100 | if (line === undefined) { | |
| 101 | line = { waiting: [], holds: new Set(), gating: 0, gates: 0, draining: false }; | |
| 102 | lines.set(repoId, line); | |
| 103 | } | |
| 104 | return line; | |
| 105 | } | |
| 106 | ||
| 107 | /** | |
| 108 | * Join the repository's line, in this order. Each answer is whether that | |
| 109 | * pull landed. A pull already in the line is answered `false` at once: the | |
| 110 | * entry already there carries it. | |
| 111 | */ | |
| 112 | export function enqueue( | |
| 113 | repoId: string, | |
| 114 | entries: readonly Queued[], | |
| 115 | gate: GateCandidate, | |
| 116 | ): Promise<boolean[]> { | |
| 117 | const line = lineOf(repoId); | |
| 118 | const answers = entries.map((entry) => { | |
| 119 | if (line.holds.has(entry.pull.id)) return Promise.resolve(false); | |
| 120 | line.holds.add(entry.pull.id); | |
| 121 | return new Promise<boolean>((resolve) => | |
| 122 | line.waiting.push({ | |
| 123 | entry, | |
| 124 | settle: (landed) => { | |
| 125 | line.holds.delete(entry.pull.id); | |
| 126 | resolve(landed); | |
| 127 | }, | |
| 128 | }), | |
| 129 | ); | |
| 130 | }); | |
| 131 | if (!line.draining) void drain(line, gate); | |
| 132 | return Promise.all(answers); | |
| 133 | } | |
| 134 | ||
| 135 | /** What /flow shows of each repository's line. */ | |
| 136 | export interface QueueStatus { | |
| 137 | readonly repoId: string; | |
| 138 | readonly queued: number; | |
| 139 | readonly gating: number; | |
| 140 | } | |
| 141 | ||
| 142 | export function queueStatus(): QueueStatus[] { | |
| 143 | return [...lines.entries()] | |
| 144 | .map(([repoId, line]) => ({ | |
| 145 | repoId, | |
| 146 | queued: line.waiting.length, | |
| 147 | gating: line.gating, | |
| 148 | })) | |
| 149 | .filter((s) => s.queued > 0 || s.gating > 0); | |
| 150 | } | |
| 151 | ||
| 152 | /** Gate runs a repository's line has made since boot. */ | |
| 153 | export function gateRuns(repoId: string): number { | |
| 154 | return lines.get(repoId)?.gates ?? 0; | |
| 155 | } | |
| 156 | ||
| 157 | /** | |
| 158 | * One candidate at a time, until the line is empty. A batch is the front | |
| 159 | * of the line that shares one place and one base branch; a sealed pull | |
| 160 | * whose objects live elsewhere is a batch of its own. | |
| 161 | */ | |
| 162 | async function drain(line: Line, gate: GateCandidate): Promise<void> { | |
| 163 | line.draining = true; | |
| 164 | try { | |
| 165 | while (line.waiting.length > 0) { | |
| 166 | const first = line.waiting[0]!.entry; | |
| 167 | const batch = line.waiting.filter( | |
| 168 | (w) => w.entry.dir === first.dir && w.entry.pull.baseBranch === first.pull.baseBranch, | |
| 169 | ); | |
| 170 | line.waiting = line.waiting.filter((w) => !batch.includes(w)); | |
| 171 | const counted: GateCandidate = async (candidate) => { | |
| 172 | line.gates++; | |
| 173 | line.gating = candidate.steps.length; | |
| 174 | try { | |
| 175 | return await gate(candidate); | |
| 176 | } finally { | |
| 177 | line.gating = 0; | |
| 178 | } | |
| 179 | }; | |
| 180 | let left: Waiting[]; | |
| 181 | try { | |
| 182 | left = await round(batch, counted); | |
| 183 | } catch (error) { | |
| 184 | log.warn("merge queue round failed", { error: String(error) }); | |
| 185 | for (const w of batch) w.settle(false); | |
| 186 | left = []; | |
| 187 | } | |
| 188 | // What waits for the next candidate goes back to the front. | |
| 189 | line.waiting = [...left, ...line.waiting]; | |
| 190 | } | |
| 191 | } finally { | |
| 192 | line.draining = false; | |
| 193 | } | |
| 194 | } | |
| 195 | ||
| 196 | /** | |
| 197 | * Build, gate and land one candidate of `batch`. `known` is a red verdict | |
| 198 | * already read for a tree: a candidate that builds to that same tree is | |
| 199 | * not gated again. Returns what waits for the next candidate. | |
| 200 | */ | |
| 201 | async function round( | |
| 202 | batch: readonly Waiting[], | |
| 203 | gate: GateCandidate, | |
| 204 | known?: { treeOid: string; red: Red }, | |
| 205 | ): Promise<Waiting[]> { | |
| 206 | const candidate = await build(batch); | |
| 207 | if (candidate === undefined || candidate.steps.length === 0) { | |
| 208 | for (const w of batch) w.settle(false); | |
| 209 | return []; | |
| 210 | } | |
| 211 | const inIt = new Set(candidate.steps.map((s) => s.entry)); | |
| 212 | // Conflicts with the base or an earlier step, or nothing left to land | |
| 213 | // once the steps before it did: not this candidate's; the next sweep | |
| 214 | // brings it up to date or closes it as carried. | |
| 215 | for (const w of batch) if (!inIt.has(w.entry)) w.settle(false); | |
| 216 | const steps = batch.filter((w) => inIt.has(w.entry)); | |
| 217 | const verdict: LandVerdict = | |
| 218 | known !== undefined && known.treeOid === candidate.treeOid | |
| 219 | ? known.red | |
| 220 | : await gate(candidate); | |
| 221 | if (verdict.lands) { | |
| 222 | await land(candidate, steps); | |
| 223 | return []; | |
| 224 | } | |
| 225 | if (steps.length === 1) { | |
| 226 | steps[0]!.entry.handBack(verdict, candidate.baseOid); | |
| 227 | steps[0]!.settle(false); | |
| 228 | return []; | |
| 229 | } | |
| 230 | const red = { treeOid: candidate.treeOid, red: verdict }; | |
| 231 | const half = Math.ceil(steps.length / 2); | |
| 232 | const front = steps.slice(0, half); | |
| 233 | const rest = steps.slice(half); | |
| 234 | const part = await build(front); | |
| 235 | if (part === undefined || part.steps.length < front.length) { | |
| 236 | // The base moved under the search: nothing here is decided, and the | |
| 237 | // next sweep admits these again onto the base as it is now. | |
| 238 | for (const w of steps) w.settle(false); | |
| 239 | return []; | |
| 240 | } | |
| 241 | const frontVerdict = await gate(part); | |
| 242 | if (frontVerdict.lands) { | |
| 243 | await land(part, front); | |
| 244 | // The front was green, the whole red: the culprit is in the rest, and | |
| 245 | // the rest on the new base is the red tree again. | |
| 246 | return round(rest, gate, red); | |
| 247 | } | |
| 248 | const left = await round(front, gate, { treeOid: part.treeOid, red: frontVerdict }); | |
| 249 | return [...left, ...rest]; | |
| 250 | } | |
| 251 | ||
| 252 | /** | |
| 253 | * Land each step in order; a step that does not land leaves the rest for | |
| 254 | * the line. The last step that did land is released, so a prefix that | |
| 255 | * reached the base is never left without one. | |
| 256 | */ | |
| 257 | async function land(candidate: Candidate, steps: readonly Waiting[]): Promise<void> { | |
| 258 | let at = candidate.baseOid; | |
| 259 | let landed: Queued | undefined; | |
| 260 | try { | |
| 261 | for (const [i, step] of candidate.steps.entries()) { | |
| 262 | const w = steps[i]!; | |
outdated
When a step fails here, main is left at an ungated prefix tree, and nothing records this beyond the warn log. The pull description says so. That is acceptable for now, but the release for the prefix that did land should still be cut (see the comment in |
||
| 263 | const tip = await step.entry.land({ baseOid: at, treeOid: step.treeOid }); | |
| 264 | if (tip === undefined) { | |
| 265 | log.warn("merge queue: a gated step did not land", { | |
| 266 | pull: step.entry.pull.number, | |
| 267 | landed: i, | |
| 268 | of: candidate.steps.length, | |
| 269 | }); | |
| 270 | for (const rest of steps.slice(i)) rest.settle(false); | |
| 271 | return; | |
| 272 | } | |
| 273 | w.settle(true); | |
| 274 | at = tip; | |
| 275 | landed = step.entry; | |
| 276 | } | |
| 277 | } finally { | |
| 278 | landed?.release(at); | |
| 279 | } | |
| 280 | } | |
| 281 | ||
| 282 | /** | |
| 283 | * The candidate of `batch` on its base's tip now: each pull's squash on the | |
| 284 | * step before it. A pull that conflicts, or whose squash changes nothing, | |
| 285 | * is left out. Undefined when the base cannot be read. | |
| 286 | */ | |
| 287 | async function build(batch: readonly Waiting[]): Promise<Candidate | undefined> { | |
| 288 | const first = batch[0]?.entry; | |
| 289 | if (first === undefined) return undefined; | |
| 290 | const dir = first.dir; | |
| 291 | const baseOid = await resolveOid(dir, `refs/heads/${first.pull.baseBranch}`); | |
| 292 | if (baseOid === undefined) return undefined; | |
| 293 | let tipOid = baseOid; | |
| 294 | let treeOid = await treeOf(dir, baseOid); | |
| 295 | if (treeOid === undefined) return undefined; | |
| 296 | const steps: Step[] = []; | |
| 297 | for (const { entry } of batch) { | |
| 298 | const merged = await mergeable(dir, tipOid, entry.pull.headOid); | |
| 299 | if (!merged.ok || merged.value.treeOid === treeOid) continue; | |
| 300 | const commit = await runGit( | |
| 301 | ["commit-tree", merged.value.treeOid, "-p", tipOid, "-m", `merge queue: #${entry.pull.number}`], | |
| 302 | { cwd: dir, env: IDENTITY }, | |
| 303 | ); | |
| 304 | if (!commit.ok) continue; | |
| 305 | steps.push({ entry, baseOid: tipOid, treeOid: merged.value.treeOid }); | |
| 306 | tipOid = commit.value.trim(); | |
| 307 | treeOid = merged.value.treeOid; | |
| 308 | } | |
| 309 | return { dir, baseOid, steps, tipOid, treeOid }; | |
| 310 | } |
src/checks/service.ts +5
| @@ -231,6 +231,11 @@ const handles = new WeakMap<App, ChecksHandle>(); | ||
| 231 | 231 | export const checksFor = (app: App): ChecksHandle | undefined => |
| 232 | 232 | handles.get(app); |
| 233 | 233 | |
| 234 | /** A test's stand-in checks service: what `checksFor` answers for this app. */ | |
| 235 | export function provideChecksForTest(app: App, handle: ChecksHandle): void { | |
| 236 | handles.set(app, handle); | |
| 237 | } | |
| 238 | ||
| 234 | 239 | export function startChecks(app: App, options: ChecksOptions): ChecksHandle { |
| 235 | 240 | // Read on every pick (`core/capacity.ts`): an admin's change is honoured |
| 236 | 241 | // by the next check, not the next boot. `concurrency` is the boot default. |
src/core/pulls.ts +14
| @@ -809,6 +809,12 @@ export type MergePullError = | ||
| 809 | 809 | /** The repo requires a passing deploy of this head, and there is none yet. */ |
| 810 | 810 | | "checks_required"; |
| 811 | 811 | |
| 812 | /** The base a landing must find, and the tree it must write (#2036). */ | |
| 813 | export interface LandingExpect { | |
| 814 | readonly baseOid: string; | |
| 815 | readonly treeOid: string; | |
| 816 | } | |
| 817 | ||
| 812 | 818 | export async function mergePull( |
| 813 | 819 | db: Database, |
| 814 | 820 | dir: string, |
| @@ -827,6 +833,11 @@ export async function mergePull( | ||
| 827 | 833 | * brought into the base only to land — never before it may. |
| 828 | 834 | */ |
| 829 | 835 | sealedFrom?: string; |
| 836 | /** | |
| 837 | * What was judged (#2036): the base must still be at `baseOid` and the | |
| 838 | * merge must write `treeOid`, or nothing lands (`base_moved`). | |
| 839 | */ | |
| 840 | expect?: LandingExpect; | |
| 830 | 841 | }, |
| 831 | 842 | ): Promise<Result<{ mergeOid: string; carried: boolean }, MergePullError>> { |
| 832 | 843 | // The row, not the caller's copy: a landing reads its pull, runs a gate |
| @@ -866,6 +877,8 @@ export async function mergePull( | ||
| 866 | 877 | // caller: a check computed a request earlier and handed down is a check |
| 867 | 878 | // that can be skipped. |
| 868 | 879 | const status = await pullStatus(dir, pull, input.gate); |
| 880 | if (input.expect !== undefined && status.baseOid !== input.expect.baseOid) | |
| 881 | return err({ kind: "base_moved" }); | |
| 869 | 882 | // What the pull proposes is already on the base: there is nothing to |
| 870 | 883 | // land, so nothing is committed — the pull closes as merged into the |
| 871 | 884 | // commit that carried it, and the base does not move. |
| @@ -896,6 +909,7 @@ export async function mergePull( | ||
| 896 | 909 | message: input.message, |
| 897 | 910 | author: input.author, |
| 898 | 911 | ...(input.committer === undefined ? {} : { committer: input.committer }), |
| 912 | ...(input.expect === undefined ? {} : { treeOid: input.expect.treeOid }), | |
| 899 | 913 | }); |
| 900 | 914 | if (!landed.ok) return landed; |
| 901 | 915 |
src/crew/autonomy.ts +112 −56
| @@ -21,7 +21,9 @@ import { | ||
| 21 | 21 | setIssueState, |
| 22 | 22 | } from "../actions/issues.ts"; |
| 23 | 23 | import type { AccessError } from "../actions/access.ts"; |
| 24 | import { gateOnLand, releaseOnLand } from "../checks/landing.ts"; | |
| 24 | import { gateCandidate, releaseOnLand } from "../checks/landing.ts"; | |
| 25 | import { addLearned } from "../checks/learned.ts"; | |
| 26 | import { enqueue, type GateCandidate, type Queued } from "../checks/queue.ts"; | |
| 25 | 27 | import { mergeable, resolveOid, treeOf } from "../git/merge.ts"; |
| 26 | 28 | import { repoDir } from "../git/repos.ts"; |
| 27 | 29 | import { mergePull } from "../actions/pulls.ts"; |
| @@ -998,10 +1000,17 @@ export function parkPull( | ||
| 998 | 1000 | */ |
| 999 | 1001 | export async function sweepGreen(app: App, repo: Repo): Promise<number> { |
| 1000 | 1002 | if (repo.autonomy !== "autonomous") return 0; |
| 1001 | let landed = 0; | |
| 1002 | for (const pull of listPulls(app.db, repo.id, { sealed: true, state: "open", limit: 50 })) | |
| 1003 | if (await landIfGreen(app, repo, pull)) landed++; | |
| 1004 | return landed; | |
| 1003 | // Admitted one by one, queued together, oldest first: the green pulls of | |
| 1004 | // one sweep are one candidate and one gate (#2036). | |
| 1005 | const open = listPulls(app.db, repo.id, { sealed: true, state: "open", limit: 50 }); | |
| 1006 | const entries: Queued[] = []; | |
| 1007 | for (const pull of open.sort((a, b) => a.number - b.number)) { | |
| 1008 | const entry = await admit(app, repo, pull); | |
| 1009 | if (entry !== undefined) entries.push(entry); | |
| 1010 | } | |
| 1011 | if (entries.length === 0) return 0; | |
| 1012 | const landed = await enqueue(repo.id, entries, gateFor(app, repo)); | |
| 1013 | return landed.filter(Boolean).length; | |
| 1005 | 1014 | } |
| 1006 | 1015 | |
| 1007 | 1016 | /** The sweep each repository has in flight, by repository id. */ |
| @@ -1472,35 +1481,53 @@ export async function landIfGreen( | ||
| 1472 | 1481 | repo: Repo, |
| 1473 | 1482 | pull: Pull, |
| 1474 | 1483 | ): Promise<boolean> { |
| 1475 | if (repo.autonomy !== "autonomous" || repo.autonomyBy === null) return false; | |
| 1476 | if (pull.state !== "open" || pull.mergedAt !== null) return false; | |
| 1484 | const entry = await admit(app, repo, pull); | |
| 1485 | if (entry === undefined) return false; | |
| 1486 | const [landed] = await enqueue(repo.id, [entry], gateFor(app, repo)); | |
| 1487 | return landed === true; | |
| 1488 | } | |
| 1489 | ||
| 1490 | /** | |
| 1491 | * Everything a green pull must be before it joins the merge queue | |
| 1492 | * (#2036): autonomous, open, unheld, green on its head, clear of the wall, | |
| 1493 | * reviewed, and not already told red on this head and base. The queue | |
| 1494 | * entry, or undefined when it does not join (and why is already done: | |
| 1495 | * a conflict sent to the crew, a pull closed as carried, a wall card). | |
| 1496 | */ | |
| 1497 | async function admit( | |
| 1498 | app: App, | |
| 1499 | repo: Repo, | |
| 1500 | pull: Pull, | |
| 1501 | ): Promise<Queued | undefined> { | |
| 1502 | if (repo.autonomy !== "autonomous" || repo.autonomyBy === null) return undefined; | |
| 1503 | if (pull.state !== "open" || pull.mergedAt !== null) return undefined; | |
| 1477 | 1504 | // The caller's copy may predate a landing: the row says whether it is |
| 1478 | 1505 | // still open (#2017). |
| 1479 | 1506 | const row = getPull(app.db, repo.id, pull.number); |
| 1480 | 1507 | if (row === undefined || row.state !== "open" || row.mergedAt !== null) |
| 1481 | return false; | |
| 1508 | return undefined; | |
| 1482 | 1509 | // A person said "not this one yet": nothing below happens until it is |
| 1483 | 1510 | // released — no landing, no review sent, no run to bring the branch up |
| 1484 | 1511 | // to date (`core/holds.ts`). |
| 1485 | if (getHold(app.db, pull.id) !== undefined) return false; | |
| 1512 | if (getHold(app.db, pull.id) !== undefined) return undefined; | |
| 1486 | 1513 | // Only work the crew wrote, or a writer's, lands on green by itself: a |
| 1487 | 1514 | // reader's pull — from a fork, say — waits for a person, outsiders |
| 1488 | 1515 | // switch or not, and is never sent to the crew's reviewer (#619). |
| 1489 | if (!landsOnItsOwn(app.db, repo, pull)) return false; | |
| 1516 | if (!landsOnItsOwn(app.db, repo, pull)) return undefined; | |
| 1490 | 1517 | const by = getUserById(app.db, repo.autonomyBy); |
| 1491 | if (!by) return false; | |
| 1518 | if (!by) return undefined; | |
| 1492 | 1519 | // The newest run of each kind is what the head says: a red that a re-run |
| 1493 | 1520 | // on the same commit has since turned green is history. |
| 1494 | 1521 | const checks = verdictForHead(app.db, pull.id, pull.headOid); |
| 1495 | if (checks.length === 0) return false; | |
| 1496 | if (checks.some((c) => c.state !== "ok")) return false; | |
| 1522 | if (checks.length === 0) return undefined; | |
| 1523 | if (checks.some((c) => c.state !== "ok")) return undefined; | |
| 1497 | 1524 | // A stop the crew took on a red check ends with the green after it; a |
| 1498 | 1525 | // person's stop waits for a person. |
| 1499 | 1526 | const greenAt = checks |
| 1500 | 1527 | .map((c) => c.finishedAt ?? "") |
| 1501 | 1528 | .reduce((a, b) => (a > b ? a : b), ""); |
| 1502 | 1529 | if (stopStands(latestDecision(app.db, pull.id, "retry"), greenAt)) |
| 1503 | return false; | |
| 1530 | return undefined; | |
| 1504 | 1531 | // Conflicts first: a pull that no longer merges is the crew's to bring |
| 1505 | 1532 | // up to date, before anyone is asked whether it should land. A conflict |
| 1506 | 1533 | // is remembered by the two tips it was between: until one of them moves |
| @@ -1510,7 +1537,7 @@ export async function landIfGreen( | ||
| 1510 | 1537 | const baseTip = |
| 1511 | 1538 | (await resolveOid(dir, `refs/heads/${pull.baseBranch}`)) ?? ""; |
| 1512 | 1539 | const between = `${pull.headOid}:${baseTip}`; |
| 1513 | if (conflicted.get(pull.id) === between) return false; | |
| 1540 | if (conflicted.get(pull.id) === between) return undefined; | |
| 1514 | 1541 | if (baseTip !== "") { |
| 1515 | 1542 | const clean = await mergeable(dir, baseTip, pull.headOid); |
| 1516 | 1543 | if (!clean.ok) { |
| @@ -1518,7 +1545,7 @@ export async function landIfGreen( | ||
| 1518 | 1545 | conflicted.set(pull.id, between); |
| 1519 | 1546 | bringUpToDate(app, repo, pull, clean.error.files); |
| 1520 | 1547 | } |
| 1521 | return false; | |
| 1548 | return undefined; | |
| 1522 | 1549 | } |
| 1523 | 1550 | // Nothing the base does not already hold: no gate and no review, for |
| 1524 | 1551 | // there is nothing to land. The merge closes the pull as merged into |
| @@ -1530,7 +1557,7 @@ export async function landIfGreen( | ||
| 1530 | 1557 | pull: pull.number, |
| 1531 | 1558 | closed: closed.ok, |
| 1532 | 1559 | }); |
| 1533 | return false; | |
| 1560 | return undefined; | |
| 1534 | 1561 | } |
| 1535 | 1562 | } |
| 1536 | 1563 | // A pull that loosens the contract is a change of policy, which is a |
| @@ -1572,7 +1599,7 @@ export async function landIfGreen( | ||
| 1572 | 1599 | cells: cells.join("; "), |
| 1573 | 1600 | }); |
| 1574 | 1601 | } |
| 1575 | return false; | |
| 1602 | return undefined; | |
| 1576 | 1603 | } |
| 1577 | 1604 | // One gate for every pull, whoever wrote it: when the crew has a |
| 1578 | 1605 | // reviewer, a green head is queued for review and waits for an approval |
| @@ -1582,33 +1609,70 @@ export async function landIfGreen( | ||
| 1582 | 1609 | // (#597). Only a person's own pull lands without a reviewer, or after a |
| 1583 | 1610 | // review lapsed. |
| 1584 | 1611 | const review = await reviewed(app, repo, pull); |
| 1585 | if (review === "hold") return false; | |
| 1586 | // The full tier, once, on the merge with its base (#1198). Red is told to | |
| 1587 | // the pull once per head and base, and the pull waits for a push. | |
| 1588 | const red = latestDecision(app.db, pull.id, "land"); | |
| 1612 | if (review === "hold") return undefined; | |
| 1613 | // The full tier, once, on the queue's candidate (#1198, #2036). Red is | |
| 1614 | // told to the pull once per head and base, and the pull waits for a push. | |
| 1615 | const told = latestDecision(app.db, pull.id, "land"); | |
| 1589 | 1616 | const gatedAt = `${pull.headOid}:${baseTip}`; |
| 1590 | 1617 | if ( |
| 1591 | red?.answer === "red" && | |
| 1592 | typeof red.state === "object" && | |
| 1593 | red.state !== null && | |
| 1594 | (red.state as { at?: unknown }).at === gatedAt | |
| 1618 | told?.answer === "red" && | |
| 1619 | typeof told.state === "object" && | |
| 1620 | told.state !== null && | |
| 1621 | (told.state as { at?: unknown }).at === gatedAt | |
| 1595 | 1622 | ) |
| 1596 | return false; | |
| 1597 | const verdict = await gateOnLand(app, repo, pull, dir, baseTip); | |
| 1598 | if (!verdict.lands) { | |
| 1599 | recordDecision(app.db, { | |
| 1600 | kind: "land", | |
| 1601 | repoId: repo.id, | |
| 1602 | issueId: pull.id, | |
| 1603 | state: { head: pull.headOid, at: gatedAt }, | |
| 1604 | answer: "red", | |
| 1605 | p: 1, | |
| 1606 | by: "rule", | |
| 1607 | ms: 0, | |
| 1608 | }); | |
| 1609 | addComment(app, by, repo, getIssueById(app.db, pull.id) ?? pull, verdict.comment); | |
| 1610 | return false; | |
| 1611 | } | |
| 1623 | return undefined; | |
| 1624 | return { | |
| 1625 | pull, | |
| 1626 | dir, | |
| 1627 | land: (expect) => landGated(app, repo, by, pull, review, expect), | |
| 1628 | // On the forge's own code, the landed tree is checked out and | |
| 1629 | // preflighted off to the side; the landing does not wait on it | |
| 1630 | // (#1662). Once per candidate, at the last step that landed: that is | |
| 1631 | // the tree main is at. | |
| 1632 | release: (tip) => | |
| 1633 | void releaseOnLand(app, by, repo, pull, tip).catch((error) => | |
| 1634 | log.warn("release checkout failed", { | |
| 1635 | pull: pull.number, | |
| 1636 | why: error instanceof Error ? error.message : String(error), | |
| 1637 | }), | |
| 1638 | ), | |
| 1639 | handBack: (red, baseOid) => { | |
| 1640 | // Told on the base it was found red on: a new base is a new try. | |
| 1641 | recordDecision(app.db, { | |
| 1642 | kind: "land", | |
| 1643 | repoId: repo.id, | |
| 1644 | issueId: pull.id, | |
| 1645 | state: { head: pull.headOid, at: `${pull.headOid}:${baseOid}` }, | |
| 1646 | answer: "red", | |
| 1647 | p: 1, | |
| 1648 | by: "rule", | |
| 1649 | ms: 0, | |
| 1650 | }); | |
| 1651 | addLearned(app.config.dataDir, pull.id, red.learn); | |
| 1652 | addComment(app, by, repo, getIssueById(app.db, pull.id) ?? pull, red.comment); | |
| 1653 | }, | |
| 1654 | }; | |
| 1655 | } | |
| 1656 | ||
| 1657 | /** The queue's gate: the full tier on the candidate's tip, asked as its first pull. */ | |
| 1658 | function gateFor(app: App, repo: Repo): GateCandidate { | |
| 1659 | return (candidate) => | |
| 1660 | gateCandidate(app, repo, candidate.steps[0]!.entry.pull, candidate.dir, candidate.tipOid); | |
| 1661 | } | |
| 1662 | ||
| 1663 | /** | |
| 1664 | * Land one gated step of the queue's candidate: only onto the base it was | |
| 1665 | * built on, and only as the tree that was gated (`expect`). The new tip of | |
| 1666 | * the base, or undefined when it did not land. | |
| 1667 | */ | |
| 1668 | async function landGated( | |
| 1669 | app: App, | |
| 1670 | repo: Repo, | |
| 1671 | by: User, | |
| 1672 | pull: Pull, | |
| 1673 | review: "pass" | "lapsed", | |
| 1674 | expect: { baseOid: string; treeOid: string }, | |
| 1675 | ): Promise<string | undefined> { | |
| 1612 | 1676 | // The gate took minutes: the pull it was run on must still be the one |
| 1613 | 1677 | // here — open, unmerged, at the same head — or another landing, or a |
| 1614 | 1678 | // push, came first and this verdict is about nothing. |
| @@ -1619,7 +1683,7 @@ export async function landIfGreen( | ||
| 1619 | 1683 | still.mergedAt !== null || |
| 1620 | 1684 | still.headOid !== pull.headOid |
| 1621 | 1685 | ) |
| 1622 | return false; | |
| 1686 | return undefined; | |
| 1623 | 1687 | // Green and clear of the wall lands. An autonomous repository does not |
| 1624 | 1688 | // wait on a person; the ledger keeps the landing as the rule it is, once |
| 1625 | 1689 | // per head, so the week's numbers still add up. |
| @@ -1645,7 +1709,7 @@ export async function landIfGreen( | ||
| 1645 | 1709 | by: "rule", |
| 1646 | 1710 | ms: 0, |
| 1647 | 1711 | }); |
| 1648 | const merged = await mergePull(app, by, repo, pull, "squash"); | |
| 1712 | const merged = await mergePull(app, by, repo, pull, "squash", expect); | |
| 1649 | 1713 | if (!merged.ok) { |
| 1650 | 1714 | const why = mergeWhy(merged.error); |
| 1651 | 1715 | log.info("green but not landed", { |
| @@ -1656,24 +1720,16 @@ export async function landIfGreen( | ||
| 1656 | 1720 | // Main moved under a green pull: that is the agent's to fix, once per |
| 1657 | 1721 | // move, and never past the same limit a red check gets. |
| 1658 | 1722 | if (typeof merged.error !== "string" && merged.error.kind === "conflict") { |
| 1659 | conflicted.set(pull.id, between); | |
| 1723 | conflicted.set(pull.id, `${pull.headOid}:${expect.baseOid}`); | |
| 1660 | 1724 | bringUpToDate(app, repo, pull, merged.error.files); |
| 1661 | 1725 | } |
| 1662 | return false; | |
| 1726 | return undefined; | |
outdated
A release is cut only when the candidate's last step lands. If a later step is refused ( |
||
| 1663 | 1727 | } |
| 1664 | 1728 | conflicted.delete(pull.id); |
| 1665 | 1729 | // Closed as merged into what already carried it: main did not move, so |
| 1666 | 1730 | // there is no release to cut and nothing new was landed. |
| 1667 | if (merged.value.carried) return false; | |
| 1731 | if (merged.value.carried) return undefined; | |
| 1668 | 1732 | resolveOpen(app.db, pull.id, "land", "shipped"); |
| 1669 | // On the forge's own code, the landed tree is checked out and preflighted | |
| 1670 | // off to the side; the landing does not wait on it (#1662). | |
| 1671 | void releaseOnLand(app, by, repo, pull, merged.value.mergeOid).catch((error) => | |
| 1672 | log.warn("release checkout failed", { | |
| 1673 | pull: pull.number, | |
| 1674 | why: error instanceof Error ? error.message : String(error), | |
| 1675 | }), | |
| 1676 | ); | |
| 1677 | 1733 | // Said once it has landed, so the note wakes no builder: the review was |
| 1678 | 1734 | // asked for and never came. |
| 1679 | 1735 | if (review === "lapsed") { |
| @@ -1695,7 +1751,7 @@ export async function landIfGreen( | ||
| 1695 | 1751 | repo: `${repo.ownerName}/${repo.name}`, |
| 1696 | 1752 | pull: pull.number, |
| 1697 | 1753 | }); |
| 1698 | return true; | |
| 1754 | return merged.value.mergeOid; | |
| 1699 | 1755 | } |
| 1700 | 1756 | |
| 1701 | 1757 | export { pickBuilder }; |
src/flow/queue.ts added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | /** | |
| 2 | * The merge queue as /flow shows it (#2036): per repository, the pulls | |
| 3 | * queued for the next candidate, the pulls in the candidate at the gate, | |
| 4 | * and the pulls landed on the default branch in the last hour. Queued and | |
| 5 | * gating are this process's line (`checks/queue.ts`); landed is the rows. | |
| 6 | * A repository with none of the three is not listed. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { queueStatus } from "../checks/queue.ts"; | |
| 11 | ||
| 12 | export interface QueueRow { | |
| 13 | /** `owner/name`. */ | |
| 14 | readonly repo: string; | |
| 15 | readonly queued: number; | |
| 16 | readonly gating: number; | |
| 17 | readonly landedHour: number; | |
| 18 | } | |
| 19 | ||
| 20 | const HOUR_MS = 60 * 60 * 1000; | |
| 21 | ||
| 22 | /** Two queries, however many repositories have a line. */ | |
| 23 | export function readQueues(db: Database, now: Date): QueueRow[] { | |
| 24 | const since = new Date(now.getTime() - HOUR_MS).toISOString(); | |
| 25 | const landed = db | |
| 26 | .query<{ id: string; full: string; n: number }, [string, string]>( | |
| 27 | `SELECT r.id, u.username || '/' || r.name AS full, COUNT(*) AS n | |
| 28 | FROM pull p JOIN issue i ON i.id = p.issue_id | |
| 29 | JOIN repo r ON r.id = i.repo_id JOIN user u ON u.id = r.owner_id | |
| 30 | WHERE p.base_branch = r.default_branch | |
| 31 | AND p.merged_at >= ?1 AND p.merged_at <= ?2 | |
| 32 | GROUP BY r.id`, | |
| 33 | ) | |
| 34 | .all(since, now.toISOString()); | |
| 35 | const lines = new Map(queueStatus().map((s) => [s.repoId, s])); | |
| 36 | const named = new Map(landed.map((l) => [l.id, l.full])); | |
| 37 | const unnamed = [...lines.keys()].filter((id) => !named.has(id)); | |
| 38 | if (unnamed.length > 0) | |
| 39 | for (const r of db | |
| 40 | .query<{ id: string; full: string }, string[]>( | |
| 41 | `SELECT r.id, u.username || '/' || r.name AS full | |
| 42 | FROM repo r JOIN user u ON u.id = r.owner_id | |
| 43 | WHERE r.id IN (${unnamed.map(() => "?").join(",")})`, | |
| 44 | ) | |
| 45 | .all(...unnamed)) | |
| 46 | named.set(r.id, r.full); | |
| 47 | const count = new Map(landed.map((l) => [l.id, l.n])); | |
| 48 | return [...named.entries()] | |
| 49 | .map(([id, repo]) => ({ | |
| 50 | repo, | |
| 51 | queued: lines.get(id)?.queued ?? 0, | |
| 52 | gating: lines.get(id)?.gating ?? 0, | |
| 53 | landedHour: count.get(id) ?? 0, | |
| 54 | })) | |
| 55 | .sort((a, b) => b.gating + b.queued - (a.gating + a.queued) || a.repo.localeCompare(b.repo)); | |
| 56 | } |
src/git/merge.ts +8
| @@ -214,6 +214,12 @@ export async function merge( | ||
| 214 | 214 | author: MergeIdentity; |
| 215 | 215 | /** Who commits it, when that is not the author. */ |
| 216 | 216 | committer?: MergeIdentity; |
| 217 | /** | |
| 218 | * The tree the merge must write, when one was already judged (the merge | |
| 219 | * queue's gated candidate, #2036): any other tree is `base_moved`, and | |
| 220 | * nothing is committed. | |
| 221 | */ | |
| 222 | treeOid?: string; | |
| 217 | 223 | }, |
| 218 | 224 | ): Promise<Result<MergeResult, MergeError>> { |
| 219 | 225 | if ( |
| @@ -226,6 +232,8 @@ export async function merge( | ||
| 226 | 232 | |
| 227 | 233 | const tree = await mergeable(dir, input.baseOid, input.headOid); |
| 228 | 234 | if (!tree.ok) return tree; |
| 235 | if (input.treeOid !== undefined && tree.value.treeOid !== input.treeOid) | |
| 236 | return err({ kind: "base_moved" }); | |
| 229 | 237 | |
| 230 | 238 | // A squash keeps only the base as parent; a merge records both. |
| 231 | 239 | const parents = |
src/http/mcp.ts +3 −1
| @@ -23,6 +23,7 @@ import { listAccess } from "../actions/access_log.ts"; | ||
| 23 | 23 | import { changeCapacity, listCapacity } from "../actions/capacity.ts"; |
| 24 | 24 | import { conflictedPulls, conflictedSays } from "../flow/conflicted.ts"; |
| 25 | 25 | import { readPipeline, verdict } from "../flow/pipeline.ts"; |
| 26 | import { readQueues } from "../flow/queue.ts"; | |
| 26 | 27 | import { readThroughput, stageOut, throughputSays } from "../flow/throughput.ts"; |
| 27 | 28 | import { changeLimit, listLimits } from "../actions/limits.ts"; |
| 28 | 29 | import { LIMIT_KEYS } from "../settings/limits.ts"; |
| @@ -2048,7 +2049,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2048 | 2049 | { |
| 2049 | 2050 | name: "flow", |
| 2050 | 2051 | description: |
| 2051 | "The forge's pipeline, for a forge admin: ready, building, check queue, checking, review, approved, landed and live, each with how many wait, how many are being worked, when the oldest waiting one entered (oldestWaiting) and what limits it (capacity), and one sentence (says) naming the stage whose oldest has waited longest — the bottleneck. Beside it, throughput: per stage how many left it in the last hour (lastHour), in 24 hours (day) and in each of the last 24 hours (hours, 0 the last), the median ask→live in ms over the last 24 hours, and its own sentence (says). Also `conflicted`: pulls main moved under that the crew is bringing up to date, oldest first, with a sentence (says). Read-only; the knobs are the `capacity` tool. The same as /flow.", | |
| 2052 | "The forge's pipeline, for a forge admin: ready, building, check queue, checking, review, approved, landed and live, each with how many wait, how many are being worked, when the oldest waiting one entered (oldestWaiting) and what limits it (capacity), and one sentence (says) naming the stage whose oldest has waited longest — the bottleneck. Beside it, throughput: per stage how many left it in the last hour (lastHour), in 24 hours (day) and in each of the last 24 hours (hours, 0 the last), the median ask→live in ms over the last 24 hours, and its own sentence (says). Also `conflicted`: pulls main moved under that the crew is bringing up to date, oldest first, with a sentence (says). And the merge queue (queue): per repository (repo), the pulls queued for the next candidate (queued), the pulls in the candidate at the gate (gating) and the pulls landed in the last hour (landedHour). Read-only; the knobs are the `capacity` tool. The same as /flow.", | |
| 2052 | 2053 | inputSchema: { type: "object", properties: {} }, |
| 2053 | 2054 | writes: false, |
| 2054 | 2055 | run(ctx, user) { |
| @@ -2070,6 +2071,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2070 | 2071 | medianAskToLiveMs: flow.medianAskToLiveMs, |
| 2071 | 2072 | stages: flow.stages.map(stageOut), |
| 2072 | 2073 | }, |
| 2074 | queue: readQueues(ctx.app.db, now), | |
| 2073 | 2075 | }); |
| 2074 | 2076 | }, |
| 2075 | 2077 | }, |
src/tui/screens/flow.ts +14 −2
| @@ -3,16 +3,19 @@ | ||
| 3 | 3 | * /flow leads with, the throughput headline under it, then one line per |
| 4 | 4 | * stage of what left it in the last hour and in 24 hours — the numbers of |
| 5 | 5 | * `flow/pipeline.ts` and `flow/throughput.ts`, the same the page and the |
| 6 | * MCP `flow` tool read. Read-only; Enter, Esc or q closes it. | |
| 6 | * MCP `flow` tool read. Under them, each repository's merge queue (#2036): | |
| 7 | * queued, gating, landed in the last hour (`flow/queue.ts`). Read-only; | |
| 8 | * Enter, Esc or q closes it. | |
| 7 | 9 | */ |
| 8 | 10 | |
| 9 | 11 | import type { Database } from "bun:sqlite"; |
| 10 | 12 | import { conflictedPulls, conflictedSays } from "../../flow/conflicted.ts"; |
| 11 | 13 | import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts"; |
| 14 | import { readQueues } from "../../flow/queue.ts"; | |
| 12 | 15 | import { readThroughput, stageOut, throughputSays } from "../../flow/throughput.ts"; |
| 13 | 16 | import type { Ctx, Screen } from "../app/app.ts"; |
| 14 | 17 | import { binding } from "../app/keys.ts"; |
| 15 | import { pad, wrap } from "../term/width.ts"; | |
| 18 | import { pad, truncate, wrap } from "../term/width.ts"; | |
| 16 | 19 | import { dialog } from "../ui/widgets.ts"; |
| 17 | 20 | |
| 18 | 21 | const WIDTH = 64; |
| @@ -22,6 +25,7 @@ export function flowLines(db: Database, now = new Date()): string[] { | ||
| 22 | 25 | const stages = readPipeline(db, now); |
| 23 | 26 | const flow = readThroughput(db, stages.map((s) => s.key), now); |
| 24 | 27 | const inner = WIDTH - 6; |
| 28 | const queues = readQueues(db, now); | |
| 25 | 29 | const conflicted = conflictedSays(conflictedPulls(db), now.getTime()); |
| 26 | 30 | return [ |
| 27 | 31 | ...wrap(verdict(stages, now.getTime()), inner), |
| @@ -33,6 +37,14 @@ export function flowLines(db: Database, now = new Date()): string[] { | ||
| 33 | 37 | (s) => |
| 34 | 38 | `${pad(STAGE_LABEL[s.key], 12)} ${pad(String(s.lastHour), 9, "right")} ${pad(String(s.day), 5, "right")} ${s.label}`, |
| 35 | 39 | ), |
| 40 | "", | |
| 41 | `${pad("Merge queue", 30)} ${pad("queued", 6, "right")} ${pad("gating", 6, "right")} ${pad("landed 1h", 9, "right")}`, | |
| 42 | ...(queues.length === 0 | |
| 43 | ? ["Nothing queued, gating or landed in the last hour."] | |
| 44 | : queues.map( | |
| 45 | (q) => | |
| 46 | `${pad(truncate(q.repo, 30), 30)} ${pad(String(q.queued), 6, "right")} ${pad(String(q.gating), 6, "right")} ${pad(String(q.landedHour), 9, "right")}`, | |
| 47 | )), | |
| 36 | 48 | ...(conflicted === null ? [] : ["", ...wrap(conflicted, inner)]), |
| 37 | 49 | ]; |
| 38 | 50 | } |
src/web/pages/flow.ts +44
| @@ -12,6 +12,11 @@ | ||
| 12 | 12 | * same ones the MCP `flow` tool and the terminal's Flow command read. Every |
| 13 | 13 | * count links back here with `?out=<what>[&hour=<0–23>]`, which lists the |
| 14 | 14 | * items it counted under the table. |
| 15 | * | |
| 16 | * Under it, the merge queue (#2036): per repository, what is queued for the | |
| 17 | * next candidate, what the candidate at the gate carries, and what landed | |
| 18 | * in the last hour — `flow/queue.ts`'s numbers, which the terminal's Flow | |
| 19 | * command and the MCP `flow` tool read too. | |
| 15 | 20 | */ |
| 16 | 21 | |
| 17 | 22 | import { conflictedPulls, conflictedSays } from "../../flow/conflicted.ts"; |
| @@ -34,6 +39,7 @@ import { | ||
| 34 | 39 | stageOf, |
| 35 | 40 | type StageThroughput, |
| 36 | 41 | } from "../../flow/throughput.ts"; |
| 42 | import { readQueues, type QueueRow } from "../../flow/queue.ts"; | |
| 37 | 43 | import type { Ctx } from "../../http/context.ts"; |
| 38 | 44 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| 39 | 45 | import { html, type Html } from "../html.ts"; |
| @@ -163,10 +169,48 @@ export function flowPage(ctx: Ctx): Response { | ||
| 163 | 169 | </div>`, |
| 164 | 170 | { pad: false }, |
| 165 | 171 | )} |
| 172 | ${queueCard(readQueues(ctx.app.db, new Date(now)))} | |
| 166 | 173 | ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`; |
| 167 | 174 | return page({ ctx, title: "Flow", body, styleCss: css }); |
| 168 | 175 | } |
| 169 | 176 | |
| 177 | /** The merge queue, one row per repository with something in it or landed. */ | |
| 178 | function queueCard(rows: readonly QueueRow[]): Html { | |
| 179 | return card( | |
| 180 | html`<h2>Merge queue</h2> | |
| 181 | <p class="muted small"> | |
| 182 | Green, approved pulls join their repository's queue; each candidate | |
| 183 | is gated once and lands whole, or is bisected to the red one. | |
| 184 | </p> | |
| 185 | ${rows.length === 0 | |
| 186 | ? html`<p class="muted">Nothing queued, gating or landed in the last hour.</p>` | |
| 187 | : html`<div class="table-scroll"> | |
| 188 | <table class="table" aria-label="Merge queue"> | |
| 189 | <thead> | |
| 190 | <tr> | |
| 191 | <th scope="col">Repository</th> | |
| 192 | <th scope="col">Queued</th> | |
| 193 | <th scope="col">Gating</th> | |
| 194 | <th scope="col">Landed, last hour</th> | |
| 195 | </tr> | |
| 196 | </thead> | |
| 197 | <tbody> | |
| 198 | ${rows.map( | |
| 199 | (r) => | |
| 200 | html`<tr data-queue="${r.repo}"> | |
| 201 | <th scope="row"><a href="/${r.repo}/pulls">${r.repo}</a></th> | |
| 202 | <td class="mono" data-queued>${String(r.queued)}</td> | |
| 203 | <td class="mono" data-gating>${String(r.gating)}</td> | |
| 204 | <td class="mono" data-landed>${String(r.landedHour)}</td> | |
| 205 | </tr>`, | |
| 206 | )} | |
| 207 | </tbody> | |
| 208 | </table> | |
| 209 | </div>`}`, | |
| 210 | { id: "queue" }, | |
| 211 | ); | |
| 212 | } | |
| 213 | ||
| 170 | 214 | /** The items one count counted, newest first, each linked to where it lives. */ |
| 171 | 215 | function outList(t: StageThroughput, hour: number | null, now: number): Html { |
| 172 | 216 | const items = itemsIn(t, hour, now); |
test/check-tiers.test.ts +26 −17
| @@ -10,9 +10,9 @@ import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | ||
| 10 | 10 | import { askCheck } from "../src/checks/again.ts"; |
| 11 | 11 | import { failuresOf } from "../src/checks/failures.ts"; |
| 12 | 12 | import { addLearned, readLearned } from "../src/checks/learned.ts"; |
| 13 | import { gateOnLand } from "../src/checks/landing.ts"; | |
| 13 | import { gateCandidate } from "../src/checks/landing.ts"; | |
| 14 | 14 | import { repoDir } from "../src/git/repos.ts"; |
| 15 | import { resolveOid } from "../src/git/merge.ts"; | |
| 15 | import { mergeable, resolveOid } from "../src/git/merge.ts"; | |
| 16 | 16 | import { startChecks, type ChecksHandle } from "../src/checks/service.ts"; |
| 17 | 17 | import { listRuns, type Run } from "../src/core/runs.ts"; |
| 18 | 18 | import { listPulls } from "../src/core/pulls.ts"; |
| @@ -528,8 +528,11 @@ describe("the tiers in the forge's own checks service", () => { | ||
| 528 | 528 | expect(readLearned(dataDir, pull.id)).toEqual(["test/x.test.ts"]); |
| 529 | 529 | }); |
| 530 | 530 | |
| 531 | test("landing runs the full gate on the merge with its base; red is told and taught", async () => { | |
| 532 | const pull0 = listPulls(app.db, repo.id, { state: "open", limit: 5 })[0]!; | |
| 531 | test("landing runs the full gate on the merge with its base; red names its tests", async () => { | |
| 532 | // What the merge queue gates (#2036): a commit that is the pull on its | |
| 533 | // base. Handing the red back, and teaching the pull, is the queue's | |
| 534 | // (test/merge-queue.test.ts). | |
| 535 | const pull = listPulls(app.db, repo.id, { state: "open", limit: 5 })[0]!; | |
| 533 | 536 | const pushed = await commitToBranch(app, alice, repo, { |
| 534 | 537 | message: "Main goes red on land", |
| 535 | 538 | files: [ |
| @@ -542,21 +545,27 @@ describe("the tiers in the forge's own checks service", () => { | ||
| 542 | 545 | ], |
| 543 | 546 | }); |
| 544 | 547 | if (!pushed.ok) throw new Error(JSON.stringify(pushed.error)); |
| 545 | const pull = pull0; | |
| 546 | const base = (await resolveOid( | |
| 547 | repoDir(app.reposRoot, repo.ownerName, repo.name), | |
| 548 | "refs/heads/main", | |
| 549 | ))!; | |
| 550 | const verdict = await gateOnLand( | |
| 551 | app, | |
| 552 | repo, | |
| 553 | pull, | |
| 554 | repoDir(app.reposRoot, repo.ownerName, repo.name), | |
| 555 | base, | |
| 556 | ); | |
| 548 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 549 | const base = (await resolveOid(dir, "refs/heads/main"))!; | |
| 550 | const merged = await mergeable(dir, base, pull.headOid); | |
| 551 | if (!merged.ok) throw new Error("expected the pull to merge with its base"); | |
| 552 | const commit = Bun.spawnSync( | |
| 553 | ["git", "commit-tree", merged.value.treeOid, "-p", base, "-p", pull.headOid, "-m", "candidate"], | |
| 554 | { | |
| 555 | cwd: dir, | |
| 556 | env: { | |
| 557 | ...process.env, | |
| 558 | GIT_AUTHOR_NAME: "forge", | |
| 559 | GIT_AUTHOR_EMAIL: "forge@localhost", | |
| 560 | GIT_COMMITTER_NAME: "forge", | |
| 561 | GIT_COMMITTER_EMAIL: "forge@localhost", | |
| 562 | }, | |
| 563 | }, | |
| 564 | ).stdout.toString().trim(); | |
| 565 | const verdict = await gateCandidate(app, repo, pull, dir, commit); | |
| 557 | 566 | if (verdict.lands) throw new Error("expected the full gate to be red"); |
| 558 | 567 | expect(verdict.comment).toContain("- lands red"); |
| 559 | expect(readLearned(dataDir, pull.id)).toContain("test/y.test.ts"); | |
| 568 | expect(verdict.learn).toContain("test/y.test.ts"); | |
| 560 | 569 | }); |
| 561 | 570 | |
| 562 | 571 | const finished = async (pullId: string, oid: string): Promise<Run> => { |
test/merge-queue.test.ts added +330
| @@ -0,0 +1,330 @@ | ||
| 1 | /** | |
| 2 | * The merge queue (#2036). Green, approved pulls of one repository are | |
| 3 | * built into ONE candidate and gated once; green lands every one of them | |
| 4 | * and main ends at exactly the tree that was gated. Red bisects, lands the | |
| 5 | * green part and hands the culprit back. The gate here is a stand-in that | |
| 6 | * counts its runs and goes red on any tree holding a `red-*` file. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { readLearned } from "../src/checks/learned.ts"; | |
| 16 | import { enqueue, gateRuns, queueStatus, type Queued } from "../src/checks/queue.ts"; | |
| 17 | import { provideChecksForTest, type ChecksHandle } from "../src/checks/service.ts"; | |
| 18 | import { latestDecision } from "../src/core/decisions.ts"; | |
| 19 | import { getPull, type Pull } from "../src/core/pulls.ts"; | |
| 20 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 21 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 22 | import { createUser, type User } from "../src/core/users.ts"; | |
| 23 | import { landIfGreen, sweepGreen } from "../src/crew/autonomy.ts"; | |
| 24 | import { readQueues } from "../src/flow/queue.ts"; | |
| 25 | import { repoDir } from "../src/git/repos.ts"; | |
| 26 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 27 | import { createToken } from "../src/core/tokens.ts"; | |
| 28 | import { flowLines } from "../src/tui/screens/flow.ts"; | |
| 29 | ||
| 30 | describe("the merge queue", () => { | |
| 31 | let dataDir: string; | |
| 32 | let app: App; | |
| 33 | let alice: User; | |
| 34 | let servers: Servers; | |
| 35 | /** A gate a test holds open until it lets go. */ | |
| 36 | let held: Promise<void> | undefined; | |
| 37 | /** Every candidate the gate was asked about, with its verdict. */ | |
| 38 | const gated: { repo: string; commit: string; ok: boolean }[] = []; | |
| 39 | ||
| 40 | const git = (repo: Repo, ...args: string[]) => | |
| 41 | Bun.spawnSync(["git", ...args], { | |
| 42 | cwd: repoDir(app.reposRoot, repo.ownerName, repo.name), | |
| 43 | }) | |
| 44 | .stdout.toString() | |
| 45 | .trim(); | |
| 46 | ||
| 47 | beforeAll(async () => { | |
| 48 | dataDir = await mkdtemp(join(tmpdir(), "forge-merge-queue-")); | |
| 49 | app = createApp({ dataDir, port: 0 }); | |
| 50 | const a = await createUser(app.db, { | |
| 51 | trusted: true, | |
| 52 | username: "alice", | |
| 53 | email: "[email protected]", | |
| 54 | password: "a-good-passphrase", | |
| 55 | adminIfFirst: true, | |
| 56 | }); | |
| 57 | if (!a.ok) throw new Error(a.error); | |
| 58 | alice = a.value; | |
| 59 | provideChecksForTest(app, { | |
| 60 | gate: async (repo: Repo, _pull: Pull, commitOid: string) => { | |
| 61 | if (held !== undefined) await held; | |
| 62 | const files = git(repo, "ls-tree", "-r", "--name-only", commitOid).split("\n"); | |
| 63 | const ok = !files.some((f) => f.startsWith("red-")); | |
| 64 | gated.push({ repo: repo.id, commit: commitOid, ok }); | |
| 65 | return ok | |
| 66 | ? { ok, error: "", lines: [] } | |
| 67 | : { ok, error: "red", lines: ["test/red.test.ts:", "(fail) goes red [1.00ms]"] }; | |
| 68 | }, | |
| 69 | } as unknown as ChecksHandle); | |
| 70 | servers = await serve(app); | |
| 71 | }); | |
| 72 | ||
| 73 | afterAll(async () => { | |
| 74 | servers.stop(); | |
| 75 | await app.close?.(); | |
| 76 | await rm(dataDir, { recursive: true, force: true }); | |
| 77 | }); | |
| 78 | ||
| 79 | async function autonomousRepo(name: string): Promise<Repo> { | |
| 80 | const made = await createRepo(app, alice, { name, visibility: "private" }); | |
| 81 | if (!made.ok) throw new Error(String(made.error)); | |
| 82 | const seeded = await commitToBranch(app, alice, made.value, { | |
| 83 | message: "Start", | |
| 84 | files: [ | |
| 85 | { path: "README.md", content: `# ${name}\n` }, | |
| 86 | { | |
| 87 | path: "forge.json", | |
| 88 | content: JSON.stringify({ check: { run: "true", fast: "true", full: "true" } }), | |
| 89 | }, | |
| 90 | ], | |
| 91 | }); | |
| 92 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 93 | app.db | |
| 94 | .query("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2 WHERE id = ?1") | |
| 95 | .run(made.value.id, alice.id); | |
| 96 | return getRepoById(app.db, made.value.id)!; | |
| 97 | } | |
| 98 | ||
| 99 | async function greenPull(repo: Repo, path: string): Promise<Pull> { | |
| 100 | const pushed = await commitToBranch(app, alice, repo, { | |
| 101 | branch: `feature/${path}`, | |
| 102 | base: "main", | |
| 103 | message: path, | |
| 104 | files: [{ path, content: `export const n = "${path}";\n` }], | |
| 105 | }); | |
| 106 | if (!pushed.ok) throw new Error(JSON.stringify(pushed.error)); | |
| 107 | const opened = await openPull(app, alice, repo, { | |
| 108 | title: path, | |
| 109 | baseBranch: "main", | |
| 110 | headBranch: `feature/${path}`, | |
| 111 | }); | |
| 112 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 113 | const pull = getPull(app.db, repo.id, opened.value.number)!; | |
| 114 | const check = createRun(app.db, { | |
| 115 | repoId: repo.id, | |
| 116 | kind: "check", | |
| 117 | issueId: pull.id, | |
| 118 | env: `pull-${pull.number}`, | |
| 119 | commitOid: pull.headOid, | |
| 120 | reason: "pull", | |
| 121 | }); | |
| 122 | claimRun(app.db, check.id); | |
| 123 | finishRun(app.db, check.id, { state: "ok", summary: "check passed" }); | |
| 124 | return pull; | |
| 125 | } | |
| 126 | ||
| 127 | const login = async (): Promise<string> => { | |
| 128 | const res = await fetch(`${servers.url}/signin`, { | |
| 129 | method: "POST", | |
| 130 | redirect: "manual", | |
| 131 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 132 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 133 | }); | |
| 134 | return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 135 | }; | |
| 136 | /** The MCP `flow` tool's `queue` field, as alice reads it. */ | |
| 137 | const mcpQueue = async () => { | |
| 138 | const res = await fetch(`${servers.url}/mcp`, { | |
| 139 | method: "POST", | |
| 140 | headers: { | |
| 141 | authorization: `Bearer ${createToken(app.db, alice.id, "t", "ro").token}`, | |
| 142 | "content-type": "application/json", | |
| 143 | accept: "application/json, text/event-stream", | |
| 144 | }, | |
| 145 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "flow", arguments: {} } }), | |
| 146 | }); | |
| 147 | const body = (await res.json()) as { result: { isError?: boolean; content: { text: string }[] } }; | |
| 148 | expect(body.result.isError).not.toBe(true); | |
| 149 | return (JSON.parse(body.result.content[0]!.text) as { queue: unknown[] }).queue; | |
| 150 | }; | |
| 151 | /** The terminal Flow dialog's line for one repository's queue. */ | |
| 152 | const terminalLine = (repo: Repo) => | |
| 153 | flowLines(app.db).find((l) => l.startsWith(`alice/${repo.name} `)); | |
| 154 | const gatesOf = (repo: Repo) => gated.filter((g) => g.repo === repo.id); | |
| 155 | const tree = (repo: Repo, rev: string) => git(repo, "rev-parse", `${rev}^{tree}`); | |
| 156 | const merged = (repo: Repo, pull: Pull) => getPull(app.db, repo.id, pull.number)!.mergedAt !== null; | |
| 157 | ||
| 158 | test("four green pulls: one gate run, four landings, and main is the gated tree", async () => { | |
| 159 | const repo = await autonomousRepo("four-green"); | |
| 160 | const pulls = []; | |
| 161 | for (const name of ["a.ts", "b.ts", "c.ts", "d.ts"]) pulls.push(await greenPull(repo, name)); | |
| 162 | expect(await sweepGreen(app, repo)).toBe(4); | |
| 163 | expect(gatesOf(repo)).toHaveLength(1); | |
| 164 | expect(gateRuns(repo.id)).toBe(1); | |
| 165 | for (const pull of pulls) expect(merged(repo, pull)).toBe(true); | |
| 166 | expect(tree(repo, "refs/heads/main")).toBe(tree(repo, gatesOf(repo)[0]!.commit)); | |
| 167 | // Four commits on main, in queue order, each the pull's own squash. | |
| 168 | const subjects = git(repo, "log", "--format=%s", "-4", "refs/heads/main").split("\n"); | |
| 169 | expect(subjects).toEqual(["d.ts (#4)", "c.ts (#3)", "b.ts (#2)", "a.ts (#1)"]); | |
| 170 | expect(queueStatus().some((s) => s.repoId === repo.id)).toBe(false); | |
| 171 | }); | |
| 172 | ||
| 173 | test("one red among four: three land, the culprit is handed back, three gate runs", async () => { | |
| 174 | const repo = await autonomousRepo("one-red"); | |
| 175 | const pulls = []; | |
| 176 | for (const name of ["a.ts", "b.ts", "c.ts", "red-d.ts"]) pulls.push(await greenPull(repo, name)); | |
| 177 | const base = git(repo, "rev-parse", "refs/heads/main"); | |
| 178 | expect(await sweepGreen(app, repo)).toBe(3); | |
| 179 | expect(gatesOf(repo).length).toBeLessThanOrEqual(3); | |
| 180 | const [a, b, c, red] = pulls as [Pull, Pull, Pull, Pull]; | |
| 181 | for (const pull of [a, b, c]) expect(merged(repo, pull)).toBe(true); | |
| 182 | expect(merged(repo, red)).toBe(false); | |
| 183 | // Main is a tree the gate passed; nothing red reached it. | |
| 184 | const green = gatesOf(repo).filter((g) => g.ok).map((g) => tree(repo, g.commit)); | |
| 185 | expect(green).toContain(tree(repo, "refs/heads/main")); | |
| 186 | expect(git(repo, "ls-tree", "--name-only", "refs/heads/main")).not.toContain("red-d.ts"); | |
| 187 | // Handed back: the red is told once, on the pull, and taught. | |
| 188 | const told = latestDecision(app.db, red.id, "land"); | |
| 189 | expect(told?.answer).toBe("red"); | |
| 190 | expect((told?.state as { at?: string }).at?.startsWith(`${red.headOid}:`)).toBe(true); | |
| 191 | expect((told?.state as { at?: string }).at).not.toBe(`${red.headOid}:${base}`); | |
| 192 | expect(readLearned(dataDir, red.id)).toEqual(["test/red.test.ts"]); | |
| 193 | // Told red on this head and base: the next sweep does not gate it again. | |
| 194 | const before = gatesOf(repo).length; | |
| 195 | expect(await sweepGreen(app, repo)).toBe(0); | |
| 196 | expect(gatesOf(repo)).toHaveLength(before); | |
| 197 | }); | |
| 198 | ||
| 199 | test("a red first in line: the other three still land, and the culprit is handed back", async () => { | |
| 200 | const repo = await autonomousRepo("red-first"); | |
| 201 | const pulls = []; | |
| 202 | for (const name of ["red-a.ts", "b.ts", "c.ts", "d.ts"]) pulls.push(await greenPull(repo, name)); | |
| 203 | expect(await sweepGreen(app, repo)).toBe(3); | |
| 204 | // Whole red, front half red, the culprit alone red, the rest green. | |
| 205 | expect(gatesOf(repo).length).toBeLessThanOrEqual(4); | |
| 206 | const [red, ...rest] = pulls as [Pull, Pull, Pull, Pull]; | |
| 207 | for (const pull of rest) expect(merged(repo, pull)).toBe(true); | |
| 208 | expect(merged(repo, red)).toBe(false); | |
| 209 | expect(latestDecision(app.db, red.id, "land")?.answer).toBe("red"); | |
| 210 | expect(tree(repo, "refs/heads/main")).toBe(tree(repo, gatesOf(repo).at(-1)!.commit)); | |
| 211 | }); | |
| 212 | ||
| 213 | test("a pull that conflicts with one ahead of it is left out; the rest land as gated", async () => { | |
| 214 | const repo = await autonomousRepo("conflict"); | |
| 215 | const first = await greenPull(repo, "same.ts"); | |
| 216 | // Another branch writes the same path differently. | |
| 217 | const pushed = await commitToBranch(app, alice, repo, { | |
| 218 | branch: "feature/other", | |
| 219 | base: "main", | |
| 220 | message: "other", | |
| 221 | files: [{ path: "same.ts", content: "export const n = 2;\n" }], | |
| 222 | }); | |
| 223 | if (!pushed.ok) throw new Error(JSON.stringify(pushed.error)); | |
| 224 | const opened = await openPull(app, alice, repo, { | |
| 225 | title: "other", | |
| 226 | baseBranch: "main", | |
| 227 | headBranch: "feature/other", | |
| 228 | }); | |
| 229 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 230 | const other = getPull(app.db, repo.id, opened.value.number)!; | |
| 231 | const check = createRun(app.db, { | |
| 232 | repoId: repo.id, | |
| 233 | kind: "check", | |
| 234 | issueId: other.id, | |
| 235 | env: `pull-${other.number}`, | |
| 236 | commitOid: other.headOid, | |
| 237 | reason: "pull", | |
| 238 | }); | |
| 239 | claimRun(app.db, check.id); | |
| 240 | finishRun(app.db, check.id, { state: "ok", summary: "check passed" }); | |
| 241 | const last = await greenPull(repo, "last.ts"); | |
| 242 | expect(await sweepGreen(app, repo)).toBe(2); | |
| 243 | expect(gatesOf(repo)).toHaveLength(1); | |
| 244 | expect(merged(repo, first)).toBe(true); | |
| 245 | expect(merged(repo, last)).toBe(true); | |
| 246 | expect(merged(repo, other)).toBe(false); | |
| 247 | expect(tree(repo, "refs/heads/main")).toBe(tree(repo, gatesOf(repo)[0]!.commit)); | |
| 248 | }); | |
| 249 | ||
| 250 | test("a gated step lands only on the base and as the tree it was gated with", async () => { | |
| 251 | const repo = await autonomousRepo("guarded"); | |
| 252 | const pull = await greenPull(repo, "guard.ts"); | |
| 253 | const tip = git(repo, "rev-parse", "refs/heads/main"); | |
| 254 | const wrongBase = await mergePull(app, alice, repo, pull, "squash", { | |
| 255 | baseOid: git(repo, "rev-parse", "refs/heads/main~1"), | |
| 256 | treeOid: tree(repo, "refs/heads/main"), | |
| 257 | }); | |
| 258 | expect(wrongBase.ok).toBe(false); | |
| 259 | if (!wrongBase.ok) expect(wrongBase.error).toEqual({ kind: "base_moved" }); | |
| 260 | const wrongTree = await mergePull(app, alice, repo, pull, "squash", { | |
| 261 | baseOid: tip, | |
| 262 | treeOid: tree(repo, tip), | |
| 263 | }); | |
| 264 | expect(wrongTree.ok).toBe(false); | |
| 265 | if (!wrongTree.ok) expect(wrongTree.error).toEqual({ kind: "base_moved" }); | |
| 266 | expect(git(repo, "rev-parse", "refs/heads/main")).toBe(tip); | |
| 267 | expect(merged(repo, pull)).toBe(false); | |
| 268 | }); | |
| 269 | ||
| 270 | test("a later step refused: the steps that landed are still released, once, at the last of them", async () => { | |
| 271 | const repo = await autonomousRepo("prefix"); | |
| 272 | const pulls = []; | |
| 273 | for (const name of ["p.ts", "q.ts", "r.ts"]) pulls.push(await greenPull(repo, name)); | |
| 274 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 275 | const released: { pull: number; tip: string }[] = []; | |
| 276 | // A stand-in landing: the first two land at a made-up tip, the third is | |
| 277 | // refused as if its head moved during the gate. | |
| 278 | const entries: Queued[] = pulls.map((pull, i) => ({ | |
| 279 | pull, | |
| 280 | dir, | |
| 281 | land: async () => (i < 2 ? `tip-${pull.number}` : undefined), | |
| 282 | release: (tip) => released.push({ pull: pull.number, tip }), | |
| 283 | handBack: () => {}, | |
| 284 | })); | |
| 285 | const answers = await enqueue(repo.id, entries, async () => ({ lands: true })); | |
| 286 | expect(answers).toEqual([true, true, false]); | |
| 287 | expect(released).toEqual([{ pull: pulls[1]!.number, tip: `tip-${pulls[1]!.number}` }]); | |
| 288 | }); | |
| 289 | ||
| 290 | test("the queue shows through every door: /flow, the terminal and MCP, while gating and after", async () => { | |
| 291 | const repo = await autonomousRepo("on-flow"); | |
| 292 | const first = await greenPull(repo, "first.ts"); | |
| 293 | const second = await greenPull(repo, "second.ts"); | |
| 294 | const third = await greenPull(repo, "third.ts"); | |
| 295 | let release = (): void => {}; | |
| 296 | held = new Promise<void>((r) => (release = r)); | |
| 297 | // The first joins alone and is gated; the two after wait in line. | |
| 298 | const landing = landIfGreen(app, repo, first); | |
| 299 | while (!queueStatus().some((q) => q.repoId === repo.id && q.gating > 0)) await Bun.sleep(5); | |
| 300 | const waiting = Promise.all([landIfGreen(app, repo, second), landIfGreen(app, repo, third)]); | |
| 301 | while (!queueStatus().some((q) => q.repoId === repo.id && q.queued === 2)) await Bun.sleep(5); | |
| 302 | const row = readQueues(app.db, new Date()).find((r) => r.repo === `alice/${repo.name}`); | |
| 303 | expect(row).toMatchObject({ queued: 2, gating: 1 }); | |
| 304 | const res = await fetch(`${servers.url}/flow`, { | |
| 305 | headers: { cookie: await login() }, | |
| 306 | redirect: "manual", | |
| 307 | }); | |
| 308 | expect(res.status).toBe(200); | |
| 309 | const page = await res.text(); | |
| 310 | expect(page).toMatch( | |
| 311 | /data-queue="alice\/on-flow">[\s\S]*?data-queued>2<[\s\S]*?data-gating>1<[\s\S]*?data-landed>0</, | |
| 312 | ); | |
| 313 | // The terminal's Flow dialog and the MCP tool say the same. | |
| 314 | expect(terminalLine(repo)?.split(/\s+/)).toEqual([`alice/${repo.name}`, "2", "1", "0"]); | |
| 315 | expect(await mcpQueue()).toContainEqual({ repo: `alice/${repo.name}`, queued: 2, gating: 1, landedHour: 0 }); | |
| 316 | held = undefined; | |
| 317 | release(); | |
| 318 | expect(await landing).toBe(true); | |
| 319 | // The two that waited are one candidate and one more gate. | |
| 320 | expect(await waiting).toEqual([true, true]); | |
| 321 | expect(gatesOf(repo)).toHaveLength(2); | |
| 322 | expect(readQueues(app.db, new Date()).find((r) => r.repo === `alice/${repo.name}`)).toMatchObject({ | |
| 323 | queued: 0, | |
| 324 | gating: 0, | |
| 325 | landedHour: 3, | |
| 326 | }); | |
| 327 | expect(terminalLine(repo)?.split(/\s+/)).toEqual([`alice/${repo.name}`, "0", "0", "3"]); | |
| 328 | expect(await mcpQueue()).toContainEqual({ repo: `alice/${repo.name}`, queued: 0, gating: 0, landedHour: 3 }); | |
| 329 | }); | |
| 330 | }); |
gateOnLandhas no caller left insrc/. Onlytest/check-tiers.test.tsimports it, so it is dead code that a test keeps alive. Remove it, and have the tiers test exercisegateCandidate(plusaddLearnedvia the queue'shandBack), which is what production runs now.