Public: anyone can read this; the crew can change it.
A queued pull check waits while a crew run holds the only core, and starts when it ends #11793
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11775-a-queued-pull-check-waits-while-a-crew-r → main 1 commit fork
2 files
changed +125
−1
src/checks/service.ts +13 −1
| @@ -56,6 +56,7 @@ import { readCombined } from "../git/combined.ts"; | ||
| 56 | 56 | import { approvedOnce } from "./approved_first.ts"; |
| 57 | 57 | import { checkOnApproval } from "./approval.ts"; |
| 58 | 58 | import { checkKey, provenBy, recordProven } from "./proven.ts"; |
| 59 | import { crewHoldsCore, forgeCpus } from "../core/one_core.ts"; | |
| 59 | 60 | import { beginGate, endGate, gateHolding } from "./gate_record.ts"; |
| 60 | 61 | import { giveHistory } from "./history.ts"; |
| 61 | 62 | import { readLearned } from "./learned.ts"; |
| @@ -144,6 +145,10 @@ export interface ChecksOptions { | ||
| 144 | 145 | readonly concurrency?: number | undefined; |
| 145 | 146 | /** The forge's public URL, told to the check as `FORGE_URL`. */ |
| 146 | 147 | readonly forgeUrl: string; |
| 148 | /** The forge's CPU count; a 1-CPU forge holds pull checks for a crew run. @default forgeCpus */ | |
| 149 | readonly cpus?: (() => number) | undefined; | |
| 150 | /** Crew runs active now. @default the active agent runs */ | |
| 151 | readonly activeCrewRuns?: (() => number) | undefined; | |
| 147 | 152 | /** The parent's gate, for a hosted child: its checks take their turn in the parent's lane. */ |
| 148 | 153 | readonly gate?: Gate | undefined; |
| 149 | 154 | /** Whose checks these are, written first in each run's log (a hosted child's name). */ |
| @@ -647,11 +652,18 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 647 | 652 | (repoId) => redMain(app.db, repoId), |
| 648 | 653 | options.redHoldMs ?? 0, |
| 649 | 654 | ); |
| 655 | // One core: a crew run has it. A pull's check keeps its place and starts | |
| 656 | // once the run has ended; main's check (the merge gate) is never held. | |
| 657 | const crewHolds = crewHoldsCore( | |
| 658 | (options.cpus ?? forgeCpus)(), | |
| 659 | (options.activeCrewRuns ?? (() => listActive(app.db, "agent").length))(), | |
| 660 | ); | |
| 661 | const runnable = crewHolds ? queued.filter((run) => run.issueId === null) : queued; | |
| 650 | 662 | // The limit is asked at the point of starting, one pick at a time: |
| 651 | 663 | // a started check is in flight before the next is asked about, so a |
| 652 | 664 | // burst of one account's checks cannot pass together. A rebase's |
| 653 | 665 | // check goes before any other (`finishing_first.ts`, #6140). |
| 654 | const { rebases, rest: others } = rebasesFirst(app.db, queued); | |
| 666 | const { rebases, rest: others } = rebasesFirst(app.db, runnable); | |
| 655 | 667 | // While a gate is about to start, the last free slot is its: a pull's |
| 656 | 668 | // check waits for it, a rebase's and a main check do not. |
| 657 | 669 | const held = holdsLastSlot(limit - gatesInFlight() - slotted(), gateQueued()); |
test/checks-one-core.test.ts added +112
| @@ -0,0 +1,112 @@ | ||
| 1 | /** | |
| 2 | * One core (#11561): a 1-CPU forge starts no pull check while a crew run is | |
| 3 | * active, and starts it once the run is gone; main's check is never held. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterEach, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { openPull } from "../src/actions/pulls.ts"; | |
| 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { startChecks, type ChecksHandle } from "../src/checks/service.ts"; | |
| 14 | import { createRun, getRun } from "../src/core/runs.ts"; | |
| 15 | import { createUser } from "../src/core/users.ts"; | |
| 16 | ||
| 17 | let dir = ""; | |
| 18 | let app: App | undefined; | |
| 19 | let checks: ChecksHandle | undefined; | |
| 20 | ||
| 21 | afterEach(async () => { | |
| 22 | await checks?.stop(); | |
| 23 | checks = undefined; | |
| 24 | app?.db.close(); | |
| 25 | app = undefined; | |
| 26 | if (dir) await rm(dir, { recursive: true, force: true }); | |
| 27 | }); | |
| 28 | ||
| 29 | async function setup() { | |
| 30 | dir = await mkdtemp(join(tmpdir(), "forge-onecore-")); | |
| 31 | app = createApp({ dataDir: dir, port: 0 }); | |
| 32 | const u = await createUser(app.db, { | |
| 33 | origin: "seed", | |
| 34 | trusted: true, | |
| 35 | username: "alice", | |
| 36 | email: "[email protected]", | |
| 37 | password: "a-good-passphrase", | |
| 38 | adminIfFirst: true, | |
| 39 | }); | |
| 40 | if (!u.ok) throw new Error(u.error); | |
| 41 | const made = await createRepo(app, u.value, { name: "lib" }); | |
| 42 | if (!made.ok) throw new Error(String(made.error)); | |
| 43 | const first = await commitToBranch(app, u.value, made.value, { | |
| 44 | message: "Start", | |
| 45 | files: [{ path: "README.md", content: "x\n" }], | |
| 46 | }); | |
| 47 | if (!first.ok) throw new Error(JSON.stringify(first.error)); | |
| 48 | const c = await commitToBranch(app, u.value, made.value, { | |
| 49 | branch: "one", | |
| 50 | message: "one", | |
| 51 | files: [{ path: "one.md", content: "x\n" }], | |
| 52 | }); | |
| 53 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 54 | const o = await openPull(app, u.value, made.value, { | |
| 55 | title: "one", | |
| 56 | baseBranch: "main", | |
| 57 | headBranch: "one", | |
| 58 | }); | |
| 59 | if (!o.ok) throw new Error(String(o.error)); | |
| 60 | return { app, repoId: made.value.id, pullId: o.value.id }; | |
| 61 | } | |
| 62 | ||
| 63 | const left = (a: App, id: string): boolean => getRun(a.db, id)?.state === "queued"; | |
| 64 | ||
| 65 | describe("checks on one core", () => { | |
| 66 | test("1 CPU with a crew run holds a pull's check, and it starts once the run is gone", async () => { | |
| 67 | // Invariant: crewHoldsCore holds queued pull checks, and releases them when the run ends. | |
| 68 | const { app: a, repoId, pullId } = await setup(); | |
| 69 | let active = 1; | |
| 70 | const run = createRun(a.db, { repoId, kind: "check", env: "pull-1", issueId: pullId }); | |
| 71 | checks = startChecks(a, { | |
| 72 | forgeUrl: "http://localhost", | |
| 73 | cpus: () => 1, | |
| 74 | activeCrewRuns: () => active, | |
| 75 | }); | |
| 76 | await Bun.sleep(150); | |
| 77 | expect(left(a, run.id)).toBe(true); | |
| 78 | active = 0; | |
| 79 | // The tick (or a gate's release) schedules again; the check leaves the queue. | |
| 80 | const deadline = Date.now() + 8_000; | |
| 81 | while (left(a, run.id) && Date.now() < deadline) await Bun.sleep(25); | |
| 82 | expect(left(a, run.id)).toBe(false); | |
| 83 | }, 15_000); | |
| 84 | ||
| 85 | test("2 CPUs start a pull's check at once, a crew run or not", async () => { | |
| 86 | // Invariant: only a 1-CPU forge holds. | |
| 87 | const { app: a, repoId, pullId } = await setup(); | |
| 88 | const run = createRun(a.db, { repoId, kind: "check", env: "pull-1", issueId: pullId }); | |
| 89 | checks = startChecks(a, { | |
| 90 | forgeUrl: "http://localhost", | |
| 91 | cpus: () => 2, | |
| 92 | activeCrewRuns: () => 1, | |
| 93 | }); | |
| 94 | const deadline = Date.now() + 4_000; | |
| 95 | while (left(a, run.id) && Date.now() < deadline) await Bun.sleep(10); | |
| 96 | expect(left(a, run.id)).toBe(false); | |
| 97 | }); | |
| 98 | ||
| 99 | test("main's check, the merge gate, starts on 1 CPU while a crew run is active", async () => { | |
| 100 | // Invariant: the hold never reaches a check with no pull, so a red main is not held forever. | |
| 101 | const { app: a, repoId } = await setup(); | |
| 102 | const run = createRun(a.db, { repoId, kind: "check", env: "main" }); | |
| 103 | checks = startChecks(a, { | |
| 104 | forgeUrl: "http://localhost", | |
| 105 | cpus: () => 1, | |
| 106 | activeCrewRuns: () => 1, | |
| 107 | }); | |
| 108 | const deadline = Date.now() + 4_000; | |
| 109 | while (left(a, run.id) && Date.now() < deadline) await Bun.sleep(10); | |
| 110 | expect(left(a, run.id)).toBe(false); | |
| 111 | }); | |
| 112 | }); |
new above
offline