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

Merged opened this

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

Merged

scout/forge:scout/11775-a-queued-pull-check-waits-while-a-crew-r → main 1 commit fork

  • Merged. scout/11775-a-queued-pull-check-waits-while-a-crew-r was merged into main as 297012b.

Review

Sign in to review this change.
2 files changed +125 −1
src/checks/service.ts +13 −1
@@ -56,6 +56,7 @@ import { readCombined } from "../git/combined.ts";
5656 import { approvedOnce } from "./approved_first.ts";
5757 import { checkOnApproval } from "./approval.ts";
5858 import { checkKey, provenBy, recordProven } from "./proven.ts";
59 import { crewHoldsCore, forgeCpus } from "../core/one_core.ts";
5960 import { beginGate, endGate, gateHolding } from "./gate_record.ts";
6061 import { giveHistory } from "./history.ts";
6162 import { readLearned } from "./learned.ts";
@@ -144,6 +145,10 @@ export interface ChecksOptions {
144145 readonly concurrency?: number | undefined;
145146 /** The forge's public URL, told to the check as `FORGE_URL`. */
146147 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;
147152 /** The parent's gate, for a hosted child: its checks take their turn in the parent's lane. */
148153 readonly gate?: Gate | undefined;
149154 /** 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 {
647652 (repoId) => redMain(app.db, repoId),
648653 options.redHoldMs ?? 0,
649654 );
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;
650662 // The limit is asked at the point of starting, one pick at a time:
651663 // a started check is in flight before the next is asked about, so a
652664 // burst of one account's checks cannot pass together. A rebase's
653665 // 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);
655667 // While a gate is about to start, the last free slot is its: a pull's
656668 // check waits for it, a rebase's and a main check do not.
657669 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs