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

P0: trevato/forge has landed nothing since the proof step (#7623) went live; approved green pulls never join the queue #7678

Merged opened this

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

Merged

torque/forge:torque/7673-p0-trevato-forge-has-landed-nothing-sinc → main 6 commits fork

  • Merged. torque/7673-p0-trevato-forge-has-landed-nothing-sinc was merged into main as 4ed1fdf.

Review

Sign in to review this change.
8 files changed +116 −45
scripts/gate.ts +5
@@ -58,6 +58,11 @@ export const EXCLUDED: readonly Exclusion[] = [
5858 { file: "test/probes.test.ts", reason: "not yet admitted", issue: 261 },
5959 { file: "test/prompts.test.ts", reason: "not yet admitted", issue: 261 },
6060 { file: "test/public-url.test.ts", reason: "not yet admitted", issue: 261 },
61 {
62 file: "test/rehearsal-real.test.ts",
63 reason: "red on main@accaa65 under the gate: the second real roll's old forge does not come up",
64 issue: 7632,
65 },
6166 {
6267 file: "test/reviews.test.ts",
6368 reason: "red in random order: a posted review comment appears inline, anchored to its line",
src/apps/manifest.ts +1 −1
@@ -320,7 +320,7 @@ export interface CheckManifest {
320320 }
321321
322322 const CHECK_TIMEOUT_DEFAULT = 20 * 60;
323 const CHECK_TIMEOUT_MAX = 2 * 60 * 60;
323 export const CHECK_TIMEOUT_MAX = 2 * 60 * 60;
324324
325325 /**
326326 * The check a commit declares, `undefined` when it declares none. Fails
src/checks/gate_record.ts +16
@@ -38,3 +38,19 @@ export function endGate(db: Database, gateId: string, now = new Date()): void {
3838 now.toISOString(),
3939 );
4040 }
41
42 /**
43 * Whether a gate on a commit starting with `prefix` (the 12 characters
44 * `gateRunId` keeps) still holds its slot: open, and started after `since`.
45 * A gate writes no `run`, so this is what keeps the checkout sweep off its
46 * directory while it runs; a row a crash left open stops counting at `since`.
47 */
48 export function gateHolding(db: Database, prefix: string, since: Date): boolean {
49 return (
50 db
51 .query(
52 "SELECT 1 FROM check_gate WHERE finished_at IS NULL AND substr(commit_oid, 1, ?1) = ?2 AND started_at > ?3 LIMIT 1",
53 )
54 .get(prefix.length, prefix, since.toISOString()) !== null
55 );
56 }
src/checks/service.ts +17 −4
@@ -51,7 +51,7 @@ import { mergeForCheck } from "./merged.ts";
5151 import { approvedOnce } from "./approved_first.ts";
5252 import { checkOnApproval } from "./approval.ts";
5353 import { checkKey, provenBy, recordProven } from "./proven.ts";
54 import { beginGate, endGate } from "./gate_record.ts";
54 import { beginGate, endGate, gateHolding } from "./gate_record.ts";
5555 import { giveHistory } from "./history.ts";
5656 import { readLearned } from "./learned.ts";
5757 import { keepCache, restoreCache } from "./cache.ts";
@@ -72,7 +72,7 @@ import { holdLease, STALE_AFTER_MS } from "../runs/lease.ts";
7272 import { appendRunLog } from "../runs/log.ts";
7373 import { runningLimitSentence, runningRuns } from "../settings/quotas.ts";
7474 import { exportTree, readFileAt } from "../apps/checkout.ts";
75 import { parseCheck } from "../apps/manifest.ts";
75 import { CHECK_TIMEOUT_MAX, parseCheck } from "../apps/manifest.ts";
7676 import { workerCpus } from "../core/capacity.ts";
7777 import { accountActive } from "../core/accounts.ts";
7878 import { childEnv, killedStep, runStep } from "../apps/process.ts";
@@ -334,6 +334,9 @@ export function removeRunDir(path: string): void {
334334 /** Where a check's checkout lives while it runs. */
335335 const checksRoot = (dataDir: string): string => join(dataDir, "checks");
336336
337 /** A gate's checkout (`gateRunId`): it has no run, only its `check_gate` row. */
338 const GATE_DIR = /^gate-([0-9a-f]{12})$/;
339
337340 /** Whether a gate's checkout, this forge's or a peer's during a roll, is younger than a gate may run. */
338341 function gateMayBeRunning(path: string, now: number): boolean {
339342 try {
@@ -347,16 +350,26 @@ function gateMayBeRunning(path: string, now: number): boolean {
347350 * Remove the checkouts nobody is using: one whose run is not running with a
348351 * live lease, or has no run at all. A forge booting during a roll shares this
349352 * directory and database with the old one, whose checks are still running in
350 * theirs; those are left alone.
353 * theirs; those are left alone. A gate writes no run: its checkout is left
354 * while it is younger than a gate's deadline, and past that while its
355 * `check_gate` row is open, for as long as a check may run — the proof
356 * step's full suite outlives the window, and lost its `node_modules` (#7673).
351357 */
352358 export function sweepCheckouts(app: App, now: number = Date.now()): void {
353359 const root = checksRoot(app.config.dataDir);
354360 if (!existsSync(root)) return;
361 const since = new Date(now - CHECK_TIMEOUT_MAX * 1000);
355362 for (const name of readdirSync(root)) {
356363 // A gate has no run row: its checkout is spared until no gate could still be using it.
357364 if (name.startsWith("gate-") && gateMayBeRunning(join(root, name), now)) continue;
358365 // `<id>.home` is the home of run `<id>`: it lives and dies with it.
359 const run = getRun(app.db, name.endsWith(".home") ? name.slice(0, -5) : name);
366 const id = name.endsWith(".home") ? name.slice(0, -5) : name;
367 const gate = GATE_DIR.exec(id);
368 if (gate !== null) {
369 if (!gateHolding(app.db, gate[1]!, since)) removeRunDir(join(root, name));
370 continue;
371 }
372 const run = getRun(app.db, id);
360373 const live =
361374 run !== undefined &&
362375 run.state === "running" &&
test/checks-home-sweep.test.ts +26 −2
@@ -2,7 +2,9 @@
22 * A run's home (`<id>.home`) belongs to run `<id>` (#7545): the sweep removes
33 * it once the run is finished or its lease lapsed, leaves a live run's alone,
44 * and removes it even with read-only files in it. A torn-down preview takes
5 * its home with it.
5 * its home with it. A gate writes no run: its checkout and home stay while
6 * its `check_gate` row is open, and go once it closes or outlives any check
7 * (#7673).
68 */
79
810 import { afterAll, beforeAll, expect, test } from "bun:test";
@@ -12,9 +14,10 @@ import { join } from "node:path";
1214 import { createApp, type App } from "../src/app.ts";
1315 import { createRepo } from "../src/actions/repos.ts";
1416 import { requeueAbandonedChecks, sweepCheckouts } from "../src/checks/service.ts";
17 import { beginGate, endGate } from "../src/checks/gate_record.ts";
1518 import { ensureEnvData, removeEnv } from "../src/apps/data.ts";
1619 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
17 import { GATE_DEADLINE_MS } from "../src/checks/queue.ts";
20 import { GATE_DEADLINE_MS, gateRunId } from "../src/checks/queue.ts";
1821 import { STALE_AFTER_MS } from "../src/runs/lease.ts";
1922 import { createUser } from "../src/core/users.ts";
2023
@@ -119,3 +122,24 @@ test("tearing a preview down removes its home", () => {
119122 removeEnv(dataDir, "app1", "pull-1");
120123 expect(existsSync(h)).toBe(false);
121124 });
125
126 test("a gate's open row keeps its checkout and home past the deadline window; a finished gate's go", () => {
127 const running = "b".repeat(40);
128 const finished = "c".repeat(40);
129 const open = beginGate(app.db, repoId, undefined, running);
130 endGate(app.db, beginGate(app.db, repoId, undefined, finished));
131 for (const oid of [running, finished]) {
132 mkdirSync(join(dataDir, "checks", gateRunId(oid), "node_modules"), { recursive: true });
133 populate(gateRunId(oid));
134 }
135 // Past the young-checkout window a full suite still runs: the open row holds it.
136 sweepCheckouts(app, Date.now() + GATE_DEADLINE_MS + 60_000);
137 expect(existsSync(join(dataDir, "checks", gateRunId(running), "node_modules"))).toBe(true);
138 expect(existsSync(home(gateRunId(running)))).toBe(true);
139 expect(existsSync(join(dataDir, "checks", gateRunId(finished)))).toBe(false);
140 expect(existsSync(home(gateRunId(finished)))).toBe(false);
141 // A row a crash left open holds no longer than a check may run.
142 sweepCheckouts(app, Date.now() + 3 * 3600_000);
143 expect(existsSync(join(dataDir, "checks", gateRunId(running)))).toBe(false);
144 endGate(app.db, open);
145 });
test/home-200.test.ts +2 −1
@@ -39,7 +39,8 @@ test("the second look at home as the owner is within its budget, and every phase
3939 console.log(`home warm ${warm.ms.toFixed(0)} ms:`, warm.phases.map((p) => `${p.name} ${p.ms}`).join(", "));
4040 expectBudget(warm.ms, { ms: 300, against: await baselineMs(f.base) });
4141 // The page's own total is the sum of named phases: nothing over 20 ms hides in "app" alone.
42 const known = new Set(["app", "spawned", "steps", "session", "repos", "kpis", "waiting", "panels", "feed", "visit", "since", "render"]);
42 // Every phase home.ts and the feed time: one missing here fails as a plain expect, which the gate's budget retry never re-runs.
43 const known = new Set(["app", "spawned", "steps", "session", "repos", "asked", "kpis", "vitals", "waiting", "panels", "feed", "settled", "visit", "since", "render"]);
4344 for (const p of slow) expect(known.has(p.name)).toBe(true);
4445 const total = warm.phases.find((p) => p.name === "app")!.ms;
4546 const named = warm.phases.filter((p) => p.name !== "app").reduce((s, p) => s + p.ms, 0);
test/rehearsal-real.test.ts added +46
@@ -0,0 +1,46 @@
1 /**
2 * The roll rehearsal (#873), for real: two short rolls of two forge
3 * processes on one data directory. A forge that exits the moment it is
4 * signalled refuses the requests routers still send it; one that lingers
5 * past a probe period refuses none.
6 *
7 * Out of the gate (`scripts/gate.ts` EXCLUDED) under #7632: under the gate
8 * on main@accaa65 the second roll's old forge does not come up, and every
9 * proof went red on it, so nothing landed. The nightly outside-gate run
10 * still runs it; it rejoins with #7632.
11 */
12
13 import { describe, expect, test } from "bun:test";
14 import { rehearse } from "../scripts/rehearse-roll.ts";
15 import { isRefusal, missLine, parseArgs, type RehearsalArgs } from "../scripts/rehearsal.ts";
16
17 describe("a real roll of two forge processes on one data directory", () => {
18 const short = (lingerS: number): RehearsalArgs => ({
19 ...parseArgs([], `${import.meta.dir}/..`),
20 // Room for a slow boot and a long linger on a busy machine (#2020).
21 seconds: 30,
22 probeMs: 300,
23 lingerS,
24 concurrency: 2,
25 });
26
27 test("an old forge that exits on SIGTERM refuses what routers still send it", async () => {
28 const v = await rehearse(short(0));
29 expect(v.problem).toBeNull();
30 expect(v.refused).toBeGreaterThan(0);
31 expect(v.tally.byOutcome["refused"]).toBeGreaterThan(0);
32 }, 60_000);
33
34 test("an old forge that lingers past a probe period refuses nobody", async () => {
35 const v = await rehearse(short(5));
36 expect(v.problem).toBeNull();
37 // Each refusal says when, whom, what came back and where both processes were.
38 expect(v.tally.misses.filter((m) => isRefusal(m.outcome)).map(missLine)).toEqual([]);
39 expect(v.refused).toBe(0);
40 expect(v.tally.byForge["old"]).toBeGreaterThan(0);
41 expect(v.tally.byForge["new"]).toBeGreaterThan(0);
42 // It lingered: the old forge left rotation before it stopped answering.
43 expect(v.timeline.oldExitMs).toBeGreaterThanOrEqual(5000);
44 expect(v.timeline.oldOutOfRotationMs).toBeLessThan(v.timeline.oldExitMs ?? 0);
45 }, 60_000);
46 });
test/rehearsal.test.ts +3 −37
@@ -1,12 +1,10 @@
11 /**
2 * The roll rehearsal (#873): its plain parts, and two short real rolls of
3 * two forge processes on one data directory. A forge that exits the moment
4 * it is signalled refuses the requests routers still send it; one that
5 * lingers past a probe period refuses none.
2 * The roll rehearsal (#873): its plain parts. The two short real rolls of
3 * two forge processes on one data directory are `rehearsal-real.test.ts`,
4 * out of the gate while #7632 is open.
65 */
76
87 import { describe, expect, test } from "bun:test";
9 import { rehearse } from "../scripts/rehearse-roll.ts";
108 import {
119 count,
1210 DEFAULTS,
@@ -19,7 +17,6 @@ import {
1917 report,
2018 Rotation,
2119 SILENT_PROBES_TO_LEAVE,
22 type RehearsalArgs,
2320 } from "../scripts/rehearsal.ts";
2421
2522 describe("arguments", () => {
@@ -147,34 +144,3 @@ describe("the verdict", () => {
147144 expect(judge(emptyTally(), rolled).problem).toBe("no request was sent");
148145 });
149146 });
150
151 describe("a real roll of two forge processes on one data directory", () => {
152 const short = (lingerS: number): RehearsalArgs => ({
153 ...parseArgs([], `${import.meta.dir}/..`),
154 // Room for a slow boot and a long linger on a busy machine (#2020).
155 seconds: 30,
156 probeMs: 300,
157 lingerS,
158 concurrency: 2,
159 });
160
161 test("an old forge that exits on SIGTERM refuses what routers still send it", async () => {
162 const v = await rehearse(short(0));
163 expect(v.problem).toBeNull();
164 expect(v.refused).toBeGreaterThan(0);
165 expect(v.tally.byOutcome["refused"]).toBeGreaterThan(0);
166 }, 60_000);
167
168 test("an old forge that lingers past a probe period refuses nobody", async () => {
169 const v = await rehearse(short(5));
170 expect(v.problem).toBeNull();
171 // Each refusal says when, whom, what came back and where both processes were.
172 expect(v.tally.misses.filter((m) => isRefusal(m.outcome)).map(missLine)).toEqual([]);
173 expect(v.refused).toBe(0);
174 expect(v.tally.byForge["old"]).toBeGreaterThan(0);
175 expect(v.tally.byForge["new"]).toBeGreaterThan(0);
176 // It lingered: the old forge left rotation before it stopped answering.
177 expect(v.timeline.oldExitMs).toBeGreaterThanOrEqual(5000);
178 expect(v.timeline.oldOutOfRotationMs).toBeLessThan(v.timeline.oldExitMs ?? 0);
179 }, 60_000);
180 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs