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

test/week-pump.test.ts never takes its shard down: it passes or names its failing test #9983

Merged opened this

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

Merged

scout/forge:scout/9977-test-week-pump-test-ts-never-takes-its-s → main 3 commits fork

  • Merged. scout/9977-test-week-pump-test-ts-never-takes-its-s was merged into main as 4c78761.

Review

Sign in to review this change.
5 files changed +121 −19
src/crew/dispatch.ts +37 −11
@@ -184,6 +184,9 @@ export interface CrewHandle {
184184 */
185185 const DRAIN_MS = 3 * 60_000;
186186
187 /** How long `stop()` waits for the tick's own sweeps (converge, autonomy) to settle. */
188 const SWEEPS_STOP_MS = 10_000;
189
187190 const handles = new WeakMap<App, CrewHandle>();
188191
189192 /** The crew serving this forge, if one was started. */
@@ -257,20 +260,33 @@ export async function startCrew(
257260 // Repositories that run on their own take a turn once a minute, on the
258261 // same clock as the pump, so a pick becomes a run on the next tick.
259262 let lastAutonomy = 0;
263 // The sweeps a tick started and has not seen settle: `stop()` waits for
264 // them, so none reads `app.db` after its caller closed it (#9977).
265 const sweeps = new Set<Promise<unknown>>();
266 const track = (sweep: Promise<unknown>): void => {
267 sweeps.add(sweep);
268 void sweep.finally(() => sweeps.delete(sweep));
269 };
260270 const timer = setInterval(() => {
261271 if (stopped) return;
262 requeueInterrupted(app);
263 recheckForDisk(app);
264 if (Date.now() - lastAutonomy >= AUTONOMY_TICK_MS) {
265 lastAutonomy = Date.now();
266 settleAll(app).catch((error) =>
267 log.error("converge sweep failed", { error: String(error) }),
268 );
269 autonomyTick(app).catch((error) =>
270 log.error("autonomy tick failed", { error: String(error) }),
271 );
272 // A tick that throws is logged and the next one tries again: thrown
273 // from a timer it is an uncaught error that takes the process down.
274 try {
275 requeueInterrupted(app);
276 recheckForDisk(app);
277 if (Date.now() - lastAutonomy >= AUTONOMY_TICK_MS) {
278 lastAutonomy = Date.now();
279 track(settleAll(app).catch((error) =>
280 log.error("converge sweep failed", { error: String(error) }),
281 ));
282 track(autonomyTick(app).catch((error) =>
283 log.error("autonomy tick failed", { error: String(error) }),
284 ));
285 }
286 pump(app, ready, inFlight);
287 } catch (error) {
288 log.error("crew tick failed", { error: String(error) });
272289 }
273 pump(app, ready, inFlight);
274290 }, options.tickMs ?? TICK_MS);
275291 // Clones no open issue, pull or run claims fill the volume (#5697).
276292 const sweepClones = (): void => {
@@ -299,6 +315,16 @@ export async function startCrew(
299315 unsubscribe();
300316 unfollow();
301317 unsettle();
318 // A sweep still going would read the database its caller closes next;
319 // bounded, for one stuck on the network must not hold a restart.
320 if (sweeps.size > 0) {
321 let bound: ReturnType<typeof setTimeout> | undefined;
322 await Promise.race([
323 Promise.all(sweeps),
324 new Promise((resolve) => (bound = setTimeout(resolve, SWEEPS_STOP_MS))),
325 ]);
326 clearTimeout(bound);
327 }
302328 if (options.drain === true && inFlight.size > 0) {
303329 // Let what is in flight finish: a run cut short is a person's
304330 // money spent twice. The new forge takes new work meanwhile.
test/crew-tick.test.ts added +61
@@ -0,0 +1,61 @@
1 /**
2 * The crew's tick never takes the process down (#9977): a tick whose
3 * database refuses it is logged, and the next tick does its work. Thrown
4 * from the timer, the error would be uncaught: a forge would fall over,
5 * and a test shard would exit without naming a test.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { claimRun, createRun, getRun } from "../src/core/runs.ts";
14 import { createUser } from "../src/core/users.ts";
15 import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts";
16
17 let dataDir: string;
18 let app: App;
19 let crew: CrewHandle;
20
21 beforeAll(async () => {
22 dataDir = await mkdtemp(join(tmpdir(), "forge-crew-tick-"));
23 app = createApp({ dataDir, port: 0 });
24 crew = await startCrew(app, {
25 forgeUrl: "http://127.0.0.1:9",
26 localUrl: "http://127.0.0.1:9",
27 binary: join(dataDir, "no-claude-here"),
28 concurrency: 0,
29 budgetUsd: 1,
30 runTimeoutMs: 60_000,
31 tickMs: 20,
32 });
33 });
34
35 afterAll(async () => {
36 await crew.stop();
37 app.close();
38 await rm(dataDir, { recursive: true, force: true });
39 });
40
41 test("a tick that throws is logged, and the next tick still does its work", async () => {
42 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
43 if (!a.ok) throw new Error(a.error);
44 const made = await createRepo(app, a.value, { name: "ticks", visibility: "private" });
45 if (!made.ok) throw new Error(String(made.error));
46 // A run a dead forge left running: its lease lapsed a day ago, so every
47 // tick's first step writes, to cancel it and queue its task again.
48 const stale = createRun(app.db, { repoId: made.value.id, kind: "agent", reason: "push", actorId: a.value.id });
49 claimRun(app.db, stale.id);
50 const old = new Date(Date.now() - 86_400_000).toISOString();
51 app.db.query("UPDATE run SET heartbeat_at = ?1, started_at = ?1 WHERE id = ?2").run(old, stale.id);
52 // The database refuses every write: each tick throws on its first step.
53 app.db.exec("PRAGMA query_only = ON");
54 await Bun.sleep(200);
55 app.db.exec("PRAGMA query_only = OFF");
56 expect(getRun(app.db, stale.id)?.state).toBe("running");
57 // The crew is still ticking: the lapsed run is put back on the queue.
58 const deadline = Date.now() + 5_000;
59 while (getRun(app.db, stale.id)?.state === "running" && Date.now() < deadline) await Bun.sleep(20);
60 expect(getRun(app.db, stale.id)?.state).toBe("cancelled");
61 });
test/crew-week.test.ts +9 −3
@@ -123,7 +123,9 @@ test("home says the whole crew's number", () => {
123123 });
124124
125125 test("a repository that hit its ceiling yesterday restarts at its own time, spread over the morning", () => {
126 const midnight = new Date(Date.UTC(2026, 10, 3, 0, 5, 0));
126 // Midnight itself: repository ids are random, so an instant past 00:00 would
127 // find a repository whose offset has already come and nothing held.
128 const midnight = new Date(Date.UTC(2026, 10, 3, 0, 0, 0));
127129 for (const r of repos) {
128130 const run = createRun(app.db, { repoId: r.id, kind: "agent" });
129131 app.db.query("UPDATE run SET cost_usd = 1000, created_at = ?2, state = 'ok' WHERE id = ?1").run(run.id, new Date(Date.UTC(2026, 10, 2, 20, 0, 0)).toISOString());
@@ -147,13 +149,17 @@ test("a repository that hit its ceiling yesterday restarts at its own time, spre
147149 });
148150
149151 test("a ceiling of 0 allows no spend and is held by the ceiling, so it is never also held until an offset", () => {
150 const midnight = new Date(Date.UTC(2026, 10, 3, 0, 5, 0));
152 // Midnight itself: repository ids are random, so an instant past 00:00 would
153 // find a repository whose offset has already come and nothing held.
154 const midnight = new Date(Date.UTC(2026, 10, 3, 0, 0, 0));
151155 forgetStagger();
152156 expect(staggeredUntil(app.db, { id: repos[0]!.id, autonomyCeilingUsd: 0 }, midnight)).toBeNull();
153157 });
154158
155159 test("the stagger reads yesterday's spend once for one instant", () => {
156 const midnight = new Date(Date.UTC(2026, 10, 3, 0, 5, 0));
160 // Midnight itself: repository ids are random, so an instant past 00:00 would
161 // find a repository whose offset has already come and nothing held.
162 const midnight = new Date(Date.UTC(2026, 10, 3, 0, 0, 0));
157163 const run = createRun(app.db, { repoId: repos[3]!.id, kind: "agent" });
158164 app.db.query("UPDATE run SET cost_usd = 1000, created_at = ?2, state = 'ok' WHERE id = ?1").run(run.id, new Date(Date.UTC(2026, 10, 2, 20, 0, 0)).toISOString());
159165 forgetStagger();
test/features.test.ts +4
@@ -812,6 +812,10 @@ describe("absorbing an app", () => {
812812 } finally {
813813 app.db.run("UPDATE repo SET visibility = 'private' WHERE id = ?1", [repo.id]);
814814 stub.stop(true);
815 // The constructor makes it this forge's runtime: a deploy it was asked
816 // for must settle before afterAll closes the database, or its
817 // finishRun is an unhandled error in whichever file runs next.
818 await runtime.stop();
815819 }
816820 });
817821
test/week-pump.test.ts +10 −5
@@ -15,8 +15,13 @@ import { startStandInCrew, stopStandInCrew, type StandInCrew } from "./helpers/c
1515
1616 const DAY = 86_400_000;
1717
18 /** Each test's own timeout; every wait in it fits inside, with room to spare. */
19 const TEST_MS = 30_000;
20 /** One wait's deadline: a test waits at most three times, so a slow tick fails as this test, by name, before TEST_MS (#9977). */
21 const WAIT_MS = 8_000;
22
1823 /** Wait until `done` holds, or fail at the deadline: the pump's tick is what we wait on, not a guessed pause. */
19 async function until(done: () => boolean, what: string, ms = 20_000): Promise<void> {
24 async function until(done: () => boolean, what: string, ms = WAIT_MS): Promise<void> {
2025 const deadline = Date.now() + ms;
2126 while (!done()) {
2227 if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`);
@@ -27,8 +32,8 @@ let standIn: StandInCrew;
2732
2833 beforeAll(async () => {
2934 standIn = await startStandInCrew({ tickMs: 50 });
30 }, 30_000);
31 afterAll(() => stopStandInCrew(standIn));
35 }, TEST_MS);
36 afterAll(() => stopStandInCrew(standIn), TEST_MS);
3237
3338 test("past the week's reserve an assigned run stays queued, with the reason in its log", async () => {
3439 const { app, alice, bot } = standIn;
@@ -60,7 +65,7 @@ test("past the week's reserve an assigned run stays queued, with the reason in i
6065 forgetWeek();
6166 await until(() => mine()?.state !== "queued", "the run to start");
6267 expect(mine()?.state).not.toBe("queued");
63 }, 30_000);
68 }, TEST_MS);
6469
6570 test("builds fill the pace slots, yet a review or assigned run still starts; a third of them waits (#8910)", async () => {
6671 const { app, alice, bot } = standIn;
@@ -110,4 +115,4 @@ test("builds fill the pace slots, yet a review or assigned run still starts; a t
110115 app.db.query("UPDATE run SET state = 'ok' WHERE id = ?1").run(gone);
111116 await until(() => stateOf(review) !== "queued", "the review to start");
112117 expect(stateOf(build)).toBe("queued");
113 }, 30_000);
118 }, TEST_MS);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs