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

A roll does not throw away running checks: drain or hand them over before the forge restarts #1820

Merged opened this in answer to

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

Merged

scout/forge:scout/1796-a-roll-does-not-throw-away-running-check → main 1 commit fork 3 threads · 3 unresolved

  • Merged. scout/1796-a-roll-does-not-throw-away-running-check was merged into main as 545d041.

Review

Sign in to review this change.
4 files changed +83 −1
docs/APPS.md +1
@@ -1065,6 +1065,7 @@ a link to the milestone (`docs/PLANNING.md`); its progress never does.
10651065 | `FORGE_PREVIEW_IDLE_DAYS` | `3` | days a pull may sit untouched before its preview sleeps; `0` = never |
10661066 | `FORGE_CREW_CONCURRENCY` | `2` | agent runs at once (live: Settings → Capacity) |
10671067 | `FORGE_CHECKS_CONCURRENCY` | `1` | checks at once (live: Settings → Capacity) |
1068 | `FORGE_CHECKS_DRAIN_S` | `300` | seconds a roll lets running checks finish first |

The table row is misaligned with its neighbours; pad it to match.

10681069 | `FORGE_CHECK_SHARDS` | the machine's cores | test shards per check (live: Settings → Capacity) |
10691070 | `FORGE_CELLS_EGRESS` | `enforce` | `report` lets undeclared egress through on 80/443 (live: Settings → Capacity) |
10701071 | `FORGE_CREW_BUDGET_USD` | `5` | spend per run unless the agent says otherwise |
src/checks/service.ts +28 −1
@@ -23,6 +23,7 @@ import { issueMayRun } from "../core/pull_trust.ts";
2323 import { getRepoById, type Repo } from "../core/repos.ts";
2424 import {
2525 cancelRun,
26 getRun,

Import order: getRun sits between cancelRun and cancelRunsForPull; keep the list alphabetical.

2627 cancelRunsForPull,
2728 checksForHead,
2829 claimRun,
@@ -77,6 +78,12 @@ export interface ChecksOptions {
7778 readonly forgeUrl: string;
7879 /** How long a check may run before it is killed. @default BUDGETS.gate */
7980 readonly budgetMs?: number | undefined;
81 /**
82 * How long `stop` lets checks already running finish before it cuts them
83 * off and queues them again (#1796). Nothing new starts meanwhile.
84 * @default 0
85 */
86 readonly drainMs?: number | undefined;
8087 }
8188
8289 export interface ChecksHandle {
@@ -400,6 +407,12 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
400407 if (failed) announceRun(app, failed);
401408 return;
402409 }
410 // Ended from outside while it ran: its pull landed or closed, and
411 // the row says so. That is not this check failing.
412 if (!outcome.ok && getRun(app.db, run.id)?.state === "cancelled") {
413 write(`superseded: ${getRun(app.db, run.id)?.summary ?? "cancelled"}`);
414 return;
415 }
403416 if (!outcome.ok && stopped && signal.aborted) {
404417 // Cut off by this forge stopping, not by the check: the same check
405418 // goes back on the queue, and the forge that carries on runs it.
@@ -844,9 +857,23 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
844857 stopped = true;
845858 clearInterval(timer);
846859 unsubscribe();
860 // A roll waits for the checks under way, up to the budget, and only
861 // then cuts off what is left (it is queued again, not lost).
862 const running = Promise.all([...inFlight.values()].map((f) => f.promise));
863 const drainMs = options.drainMs ?? 0;
864 if (drainMs > 0 && inFlight.size > 0) {
865 let timer: ReturnType<typeof setTimeout> | undefined;
866 await Promise.race([
867 running,
868 new Promise<void>((resolve) => {
869 timer = setTimeout(resolve, drainMs);
870 }),

This inner timer shadows the interval timer cleared a few lines above. Rename it to drainTimer so a later edit does not clear the wrong one.

871 ]);
872 clearTimeout(timer);
873 }
847874 for (const { abort } of inFlight.values()) abort.abort();
848875 for (const abort of gates) abort.abort();
849 await Promise.all([...inFlight.values()].map((f) => f.promise));
876 await running;
850877 },
851878 };
852879 handles.set(app, handle);
src/main.ts +2
@@ -277,6 +277,8 @@ const checks =
277277 : startChecks(app, {
278278 forgeUrl,
279279 concurrency: positive(process.env.FORGE_CHECKS_CONCURRENCY, 1),
280 // A roll lets running checks finish this long before cutting them off.
281 drainMs: positive(process.env.FORGE_CHECKS_DRAIN_S, 300) * 1000,
280282 });
281283 // The look: a picture of every environment that comes up green, taken
282284 // through this forge's own listener.
test/checks.test.ts +52
@@ -727,6 +727,58 @@ describe("checks", () => {
727727 expect(done.state).toBe("ok");
728728 }, 60_000);
729729
730 async function slowCheck(command: string, message: string): Promise<string> {
731 const made = await commitToBranch(app, alice, repo, {
732 message,
733 files: [{ path: "forge.json", content: checkJson(command) }],
734 });
735 if (!made.ok) throw new Error(JSON.stringify(made.error));
736 const oid = made.value.commitOid;
737 const deadline = Date.now() + 20_000;
738 let found: Run | undefined;
739 while (Date.now() < deadline && found?.state !== "running") {
740 found = listRuns(app.db, repo.id, { kind: "check", limit: 50 }).find(
741 (r) => r.commitOid === oid,
742 );
743 await Bun.sleep(50);
744 }
745 expect(found?.state).toBe("running");
746 return found!.id;
747 }
748
749 test("a roll waits for a running check to finish, within its budget, instead of throwing it away", async () => {
750 await checks.stop();
751 checks = startChecks(app, {
752 forgeUrl: servers.url,
753 concurrency: 1,
754 drainMs: 30_000,
755 });
756 const id = await slowCheck("sleep 2 && test -f OK.md", "A drained check");
757 await checks.stop();
758 const run = getRun(app.db, id);
759 expect(run?.state).toBe("ok");
760 expect(
761 listRuns(app.db, repo.id, { kind: "check", limit: 50 }).some(
762 (r) => r.state === "queued",
763 ),
764 ).toBe(false);
765 checks = startChecks(app, { forgeUrl: servers.url, concurrency: 1 });
766 }, 60_000);
767
768 test("a check whose pull lands while it runs ends as superseded, not failed", async () => {
769 const id = await slowCheck("sleep 2 && false", "A check overtaken");
770 cancelRun(app.db, id, "pull landed");
771 const deadline = Date.now() + 20_000;
772 let lines = "";
773 while (Date.now() < deadline && !lines.includes("superseded")) {
774 await Bun.sleep(100);
775 lines = readRunLog(dataDir, id).lines.join("\n");
776 }
777 expect(lines).toContain("superseded: pull landed");
778 expect(lines).not.toContain("failed:");
779 expect(getRun(app.db, id)?.state).toBe("cancelled");
780 }, 60_000);
781
730782 test("a check that is declared wrong fails with the reason", async () => {
731783 const wrong = await commitToBranch(app, alice, repo, {
732784 message: "A wrong check",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs