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

A forge that reaches its CPU limit is slowed, not stopped: a crew run on a 1-CPU forge finishes #11540

opened this in answer to

Check · Review · Land · Live — Changes were asked on this head. Next: builder, changes were asked on this head.

A reviewer asked for changes Review

torque/forge:torque/11539-a-forge-that-reaches-its-cpu-limit-is-sl → main 1 commit fork 6 threads · 6 unresolved

Review

Sign in to review this change.
21 files changed +549 −10
docs/features/cpu-throttle.md added +10
@@ -0,0 +1,10 @@
1 # A forge at its CPU limit is slowed, not stopped
2
3 A child forge is held to its size's cores (small is 1). Where the kernel's `cpu.max` is ours to write, the cell's cgroup holds it. Where it is not (`apps/cgroup.ts`, `cpuCapped` false), the watch (`watchTree`, `apps/contain.ts`) slows the cell itself: `apps/throttle.ts` pauses the whole process tree (SIGSTOP) for a share of each tick and lets it go (SIGCONT), so over a few seconds it gets `limit` cores. Using exactly the limit is not over it.
4
5 - **Stopping** for CPU is the last resort: more than twice the limit for five minutes, in a cell at least ten minutes old (`throttleFailed`). Memory, disk and process-count stops are unchanged.
6 - **The owner is told**: a stop by the host writes `host-stopped.json` in the forge's data directory (`spawn/stop_note.ts`); the forge's home page says what happened and why for a day.
7 - **Work fits the size** (`spawn/fit.ts`): on a 1-core forge, while a crew run is running, checks are not started, scheduled snapshots are not taken, and looks wait (their age restarts while they wait). One browser at a time: pictures and the accessibility check share `looks/browser_gate.ts`.
8 - **A run cut off by a stop** is queued again in the same clone and session (`requeue`, `crew/dispatch.ts`). Cut off twice in a row (`crew/interrupt_loop.ts`) the issue is parked with a plain note instead of looping.
9
10 Not done here: the sizes' measured first-change times (the plan lines say what each size is for, not a number), the production probe, and the real-1-CPU-cell three-minute test, which needs a cell the sandbox cannot make.
src/apps/contain.ts +45 −2
@@ -38,6 +38,7 @@ import { dirname, join } from "node:path";
3838 import { portInsteadOfSocket } from "../git/hook-address.ts";
3939 import { relay, startEgress, upstreamProxy, type Egress } from "./egress.ts";
4040 import { BACKGROUND_CPU_WEIGHT } from "./background.ts";
41 import { pauseMs, signalAll, THROTTLE_WINDOW_MS, throttleFailed, throttleFailure, type OverState } from "./throttle.ts";
4142 import {
4243 cgroupHost,
4344 diskDevice,
@@ -717,7 +718,7 @@ export function openCell(
717718 breach = why;
718719 log(`stopped: ${why}`);
719720 stop(why);
720 });
721 }, { throttle: spec.cpuQuota === true && cgroup?.cpuCapped !== true });
721722 };
722723 return {
723724 argv,
@@ -875,6 +876,21 @@ interface Watched {
875876 readonly cap: MemoryCap;
876877 /** The tree's CPU ticks so far and resident KiB, at the last tick. */
877878 last?: { ticks: number; kib: number };
879 /** Slowed (SIGSTOP/SIGCONT, `apps/throttle.ts`) rather than stopped when over its cores. */
880 readonly throttle: boolean;
881 readonly born: number;
882 readonly over: OverState;
883 /** The pids paused right now, and the timer that lets them go. */
884 paused: number[];
885 resume?: ReturnType<typeof setTimeout> | undefined;
886 }
887
888 /** Let a throttled tree run again: always, before it is stopped or unwatched. */
889 function release(entry: Watched): void {
890 if (entry.resume !== undefined) clearTimeout(entry.resume);
891 entry.resume = undefined;
892 if (entry.paused.length > 0) signalAll(entry.paused, "SIGCONT");
893 entry.paused = [];
878894 }
879895
880896 const watched = new Set<Watched>();
@@ -1043,8 +1059,13 @@ export function watchTree(
10431059 writable: readonly string[],
10441060 label: string | undefined,
10451061 breach: (why: string) => void,
1062 options: { readonly throttle?: boolean | undefined } = {},
10461063 ): () => void {
10471064 const entry: Watched = {
1065 throttle: options.throttle === true,
1066 born: Date.now(),
1067 over: {},
1068 paused: [],
10481069 root,
10491070 limits,
10501071 writable,
@@ -1058,6 +1079,7 @@ export function watchTree(
10581079 watched.add(entry);
10591080 timer ??= setInterval(() => during("cell-watch", tick), WATCH.tickMs);
10601081 return () => {
1082 release(entry);
10611083 watched.delete(entry);
10621084 if (watched.size === 0 && timer !== undefined) {
10631085 clearInterval(timer);
@@ -1243,7 +1265,28 @@ function tick(): void {
12431265 )
12441266 entry.samples.shift();
12451267 const oldest = entry.samples[0];
1246 if (oldest !== undefined && now - oldest.at >= WATCH.cpuWindowMs) {
1268 if (entry.throttle) {
1269 // Slow it, never stop it for being busy: pause the tree for a share of the next tick.
1270 release(entry);
1271 const base = [...entry.samples].reverse().find((s) => now - s.at >= THROTTLE_WINDOW_MS);
1272 if (base !== undefined) {
1273 const cores = (ticks - base.ticks) / CLK_TCK / ((now - base.at) / 1000);
1274 const ms = pauseMs(cores, limits.cpus, WATCH.tickMs);
1275 if (ms > 0) {

SIGSTOPped pids are resumed by a timer only. A host crash leaves the tree frozen, and the pids may be reused by the time the timer fires. Re-check before signalling and resume on boot.

1276 entry.paused = tree;
1277 signalAll(tree, "SIGSTOP");
1278 entry.resume = setTimeout(() => release(entry), ms);
1279 }
1280 }
1281 if (oldest !== undefined && now - oldest.at >= WATCH.cpuWindowMs) {
1282 const cores = (ticks - oldest.ticks) / CLK_TCK / ((now - oldest.at) / 1000);
1283 if (throttleFailed(entry.over, cores, limits.cpus, entry.born, now)) {
1284 release(entry);
1285 entry.breach(throttleFailure(cores, limits.cpus));
1286 continue;
1287 }
1288 }
1289 } else if (oldest !== undefined && now - oldest.at >= WATCH.cpuWindowMs) {
12471290 const cores =
12481291 (ticks - oldest.ticks) / CLK_TCK / ((now - oldest.at) / 1000);
12491292 if (cores > limits.cpus) {
src/apps/throttle.ts added +68
@@ -0,0 +1,68 @@
1 /**
2 * Holding a cell to its cores by slowing it, where the kernel's `cpu.max`
3 * is not ours to write (#11539). A forge at its limit runs slower; nothing
4 * is killed for being busy. The cell's whole process tree is paused
5 * (SIGSTOP) for a share of each watch tick and let go (SIGCONT), so that
6 * over a few seconds it gets `limit` cores: duty = limit / cores.
7 *
8 * Stopping the cell for CPU remains only as a last resort, for a tree the
9 * pause cannot hold: over twice its limit for five minutes, and never in
10 * the cell's first ten minutes.
11 */
12
13 /** The window the pause is sized from, ms: short, so a burst is slowed within seconds. */
14 export const THROTTLE_WINDOW_MS = 5_000;
15 /** A pause shorter than this share of a tick is not worth the signals; none is longer than the most share. */
16 export const PAUSE_MIN_SHARE = 0.05;
17 export const PAUSE_MAX_SHARE = 0.9;
18 /** Last resort: over `LAST_RESORT_FACTOR` × limit, for this long, in a cell at least this old. */
19 export const LAST_RESORT_FACTOR = 2;
20 export const LAST_RESORT_MS = 5 * 60_000;
21 export const LAST_RESORT_MIN_AGE_MS = 10 * 60_000;
22
23 /**
24 * How long to pause the tree over the next tick, ms (0: let it run).
25 * It used `cores` over the throttle window against `limit`; running a
26 * share `limit / cores` of each tick averages `limit`. Using exactly the
27 * limit is not over it.
28 */
29 export function pauseMs(cores: number, limit: number, tickMs: number): number {
30 if (!(cores > limit) || !(limit > 0) || !Number.isFinite(limit)) return 0;
31 const ms = Math.round(tickMs * (1 - limit / cores));
32 return ms < tickMs * PAUSE_MIN_SHARE ? 0 : Math.min(ms, Math.floor(tickMs * PAUSE_MAX_SHARE));
33 }
34
35 /** What the last-resort rule remembers between ticks: since when the tree has been over twice its limit. */
36 export interface OverState {
37 since?: number | undefined;
38 }
39
40 /**
41 * Whether the throttle has failed: `cores` (over the 30 s CPU window) above
42 * `LAST_RESORT_FACTOR` × `limit` since `LAST_RESORT_MS` ago, in a cell born
43 * `LAST_RESORT_MIN_AGE_MS` ago. Dropping under the line forgets the clock.
44 */
45 export function throttleFailed(state: OverState, cores: number, limit: number, born: number, now: number): boolean {
46 if (!(cores > limit * LAST_RESORT_FACTOR)) {
47 state.since = undefined;
48 return false;
49 }
50 state.since ??= now;
51 return now - state.since >= LAST_RESORT_MS && now - born >= LAST_RESORT_MIN_AGE_MS;
52 }
53
54 /** The line a cell is stopped with when the throttle could not hold it. */
55 export function throttleFailure(cores: number, limit: number): string {
56 return `used ${cores.toFixed(1)} cores for ${LAST_RESORT_MS / 60_000} minutes while slowed, over twice its limit of ${limit}`;
57 }
58
59 /** Signal `pids`, ignoring the ones already gone or not ours. */
60 export function signalAll(pids: readonly number[], signal: "SIGSTOP" | "SIGCONT"): void {
61 for (const pid of pids) {
62 try {
63 process.kill(pid, signal);
64 } catch {
65 // gone, or not ours to signal
66 }
67 }
68 }
src/checks/service.ts +3
@@ -11,6 +11,7 @@
1111 * is that command's business.
1212 */
1313
14 import { crewHasTheCore, crewRunActive } from "../spawn/fit.ts";
1415 import { pullDir, runDir } from "../core/pulls.ts";
1516 import { cellHides } from "../spawn/hosted-mark.ts";
1617 import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
@@ -626,6 +627,8 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
626627 if (stopped) return;
627628 const limit = capacity();
628629 if (options.gate === undefined) gate.resize(limit);
630 // On one core the crew run has it: a check waits until no run is active (`spawn/fit.ts`).
631 if (crewHasTheCore(() => crewRunActive(app.db))) return;

Unbounded wait: checks never start while a crew run is active on a 1-core forge. Add a maximum wait so the queue cannot starve, with a test.

629632 startVerify();
630633 // Every merge-queue gate in flight holds a slot, a line's first too (#6376).
631634 while (slotted() < limit - gatesInFlight()) {
src/crew/autonomy.ts +2 −1
@@ -112,6 +112,7 @@ import { crewWeekFor, weekFor, weekScope } from "./week_read.ts";
112112 import { announceGovernor, governorFor } from "./governed.ts";
113113 import { BREAKER_NOTE, breakerTripped } from "./breaker.ts";
114114 import { REPEAT_NOTE } from "./repeat_note.ts";
115 import { INTERRUPTED_TWICE_NOTE } from "./interrupt_loop.ts";
115116 import { given, pendingReplans } from "../core/limb_replan.ts";
116117 import { handedOverPulls, SKIP_SUMMARY, skipsReplan } from "./guard.ts";
117118 import { atRunCap, type Held, heldBy, heldWords, replannable } from "./held.ts";
@@ -990,7 +991,7 @@ export function parkForBreaker(
990991 const last = listComments(app.db, issue.id).at(-1);
991992 if (last?.body === note) return false;
992993 addComment(app, author, repo, issue, note);
993 log.info(note === BREAKER_NOTE ? "parked by the breaker" : note === REPEAT_NOTE ? "parked by the repeat stop" : "parked at the run cap", {
994 log.info(note === BREAKER_NOTE ? "parked by the breaker" : note === REPEAT_NOTE ? "parked by the repeat stop" : note === INTERRUPTED_TWICE_NOTE ? "parked: cut off twice in a row" : "parked at the run cap", {
994995 repo: `${repo.ownerName}/${repo.name}`,
995996 issue: issue.number,
996997 });
src/crew/dispatch.ts +7
@@ -80,6 +80,7 @@ import { detectClaude } from "./claude.ts";
8080 import { followDivergence, settleAll, settleOnPush } from "./converge.ts";
8181 import { breakerTripped } from "./breaker.ts";
8282 import { REPEAT_NOTE, repeatTripped } from "./repeat.ts";
83 import { INTERRUPTED_TWICE_NOTE, INTERRUPTIONS_IN_A_ROW, interruptedInARow } from "./interrupt_loop.ts";
8384 import { unstickOwed } from "./stalls.ts";
8485 import {
8586 CAP_NOTE,
@@ -1385,6 +1386,12 @@ function requeue(app: App, run: Run, why: string): void {
13851386 cancelRun(app.db, run.id, why);
13861387 const task = taskOf(app, run);
13871388 if (task === undefined) return;
1389 // Cut off twice in a row: the forge says so plainly, and does not loop (#11539).
1390 if (run.issueId !== null && interruptedInARow(app.db, run.issueId) >= INTERRUPTIONS_IN_A_ROW) {
1391 parkForBreaker(app, task.repo, task.issue, task.agent, INTERRUPTED_TWICE_NOTE);
1392 log.info("run not queued again: cut off twice in a row", { run: run.id });
1393 return;
1394 }
13881395 // The run that starts carries on in the clone this one left, in the
13891396 // same session — an interruption is a pause, not a fresh start.
13901397 queueAgentRun(
src/crew/interrupt_loop.ts added +39
@@ -0,0 +1,39 @@
1 /**
2 * A run cut off by its forge being stopped goes back on the queue and
3 * carries on in the same clone and session. Cut off twice in a row, the
4 * forge says so plainly rather than looping (#11539): the cycle of stop,
5 * queue again, stop again is what spent a forge's credit.
6 */
7
8 import type { Database } from "bun:sqlite";
9
10 /** The words a restart's cancel is recorded with (`requeue` in `dispatch.ts`). */
11 export const RESTART_CANCEL = "interrupted by a restart";
12
13 /** How many restarts in a row end the carrying on. */
14 export const INTERRUPTIONS_IN_A_ROW = 2;
15
16 /** What the issue is parked with, once. */
17 export const INTERRUPTED_TWICE_NOTE =
18 "Parked by the crew: this forge was stopped twice in a row while the crew worked here, so the run is not started again by itself. What the crew had done is kept in its clone; say go and it carries on from there.";
19
20 /**
21 * How many of the issue's latest ended agent runs, newest first, were cut
22 * off by a restart, without a run that ended another way between them.
23 */
24 export function interruptedInARow(db: Database, issueId: string): number {
25 const rows = db
26 .query<{ summary: string }, [string, number]>(
27 `SELECT summary FROM run
28 WHERE issue_id = ?1 AND kind = 'agent'
29 AND state IN ('ok', 'failed', 'cancelled')
30 ORDER BY id DESC LIMIT ?2`,
31 )
32 .all(issueId, INTERRUPTIONS_IN_A_ROW + 1);
33 let n = 0;
34 for (const row of rows) {
35 if (!row.summary.includes(RESTART_CANCEL)) break;
36 n++;
37 }
38 return n;
39 }
src/looks/browser_gate.ts added +7
@@ -0,0 +1,7 @@
1 /**
2 * At most one browser at a time in this forge (#11539): a picture and an
3 * accessibility check share it, so two Chromiums never compete for a core.
4 */
5 import { Gate } from "../git/env.ts";
6
7 export const browserGate = new Gate(1);
src/looks/capture.ts +2 −2
@@ -38,7 +38,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no
3838 import { tmpdir } from "node:os";
3939 import { join } from "node:path";
4040 import { childEnv } from "../apps/process.ts";
41 import { Gate } from "../git/env.ts";
41 import { browserGate } from "./browser_gate.ts";
4242 import { err, ok, type Result } from "../kernel/result.ts";
4343 import type { Entries } from "../release/webvitals.ts";
4444
@@ -107,7 +107,7 @@ export function findChromium(): string | undefined {
107107 return undefined;
108108 }
109109
110 const gate = new Gate(1);
110 const gate = browserGate;
111111
112112 /** Take one picture. Serialised: the second caller waits for the first. */
113113 export async function takeLook(
src/looks/service.ts +19 −1
@@ -15,6 +15,7 @@
1515 * environment does — a preview's go when its pull closes.
1616 */
1717
18 import { crewHasTheCore, crewRunActive } from "../spawn/fit.ts";
1819 import { sealedPreview } from "../core/sealed.ts";
1920 import {
2021 closeSync,
@@ -88,6 +89,11 @@ export interface LooksOptions {
8889 * while two agent runs are at their peak rather than being the straw.
8990 */
9091 readonly pressure?: (() => number | undefined) | undefined;
92 /**
93 * Whether the crew has the core: on a forge of one CPU a look waits while
94 * a run is active (`spawn/fit.ts`). Read from the forge's size unless a test says otherwise.
95 */
96 readonly crewBusy?: (() => boolean) | undefined;
9197 /** The clock a failed look's backoff waits on: the real one unless a test says otherwise. */
9298 readonly clock?: BackoffClock | undefined;
9399 }
@@ -140,8 +146,13 @@ export interface LooksHandle {
140146 stop(): Promise<void>;
141147 }
142148
143 /** A look that has waited longer than this is stale, and is dropped. */
149 /**
150 * A look that has waited longer than this is stale, and is dropped. A look
151 * held for the crew keeps its place: its age is counted from the run's end.
152 */
144153 const MAX_WAIT_MS = 10 * 60_000;
154 /** How long a look waits before it asks again whether the crew is still at work. */
155 const CREW_WAIT_MS = 5_000;
145156 /** How often production is checked for a picture it lacks. */
146157 const SWEEP_MS = 10 * 60_000;
147158 /** The first check, once a booted forge's apps have had a moment. */
@@ -349,6 +360,13 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
349360 for (;;) {
350361 const next = queue.shift();
351362 if (next === undefined || stopped) return;
363 // On one core the crew run has it: the look waits, and its age restarts when the run ends.
364 if ((options.crewBusy ?? (() => crewHasTheCore(() => crewRunActive(app.db))))()) {
365 if (stopped) return;
366 queue.unshift({ ...next, at: Date.now() });
367 await Bun.sleep(CREW_WAIT_MS);
368 continue;
369 }
352370 if (Date.now() - next.at > MAX_WAIT_MS) {
353371 log.warn("look dropped: waited too long", {
354372 app: next.appId,
src/release/devtools.ts +4
@@ -6,11 +6,14 @@
66 import { mkdtempSync, rmSync } from "node:fs";
77 import { tmpdir } from "node:os";
88 import { join } from "node:path";
9 import { browserGate } from "../looks/browser_gate.ts";
910
1011 const STEP_MS = 15000;
1112
1213 /** A DevTools connection to one page of a private headless Chromium. */
1314 export async function withPage<T>(binary: string, use: (send: (method: string, params?: object) => Promise<any>, once: (event: string) => Promise<void>) => Promise<T>): Promise<T> {
15 // One browser at a time: a picture being taken has it first (`looks/browser_gate.ts`).
16 const release = await browserGate.acquire();

High: the gate is acquired at line 10 of the function, but spawn (and mkdtemp) are outside the try. A throw leaks the gate and blocks every later browser use. Acquire inside the try or wrap the spawn.

1417 const profile = mkdtempSync(join(tmpdir(), "forge-axe-"));
1518 const proc = Bun.spawn(
1619 [binary, "--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", "--disable-background-networking", "--no-first-run", `--user-data-dir=${profile}`, "--remote-debugging-port=0", "about:blank"],
@@ -87,5 +90,6 @@ export async function withPage<T>(binary: string, use: (send: (method: string, p
8790 proc.kill("SIGKILL");
8891 await proc.exited.catch(() => {});
8992 rmSync(profile, { recursive: true, force: true });
93 release();
9094 }
9195 }
src/snapshot/service.ts +3
@@ -10,6 +10,7 @@
1010 * is within the hour, not the next night.
1111 */
1212
13 import { crewHasTheCore, crewRunActive } from "../spawn/fit.ts";
1314 import type { App } from "../app.ts";
1415 import { createLog } from "../kernel/log.ts";
1516 import { firstSeen, reportVerdict, seedFirstSeen } from "./verdict.ts";
@@ -267,6 +268,8 @@ export function startSnapshots(
267268 // The manifest is written last, so a snapshot cut short is not one.
268269 const takeIfDue = async (): Promise<boolean> => {
269270 if (stopped || active > 0) return false;
271 // On one core the crew run has it: the scheduled snapshot waits for the next pass (`spawn/fit.ts`).
272 if (crewHasTheCore(() => crewRunActive(app.db))) return false;
270273 const store = current();
271274 if (store === undefined) return false;
272275 let newest: Manifest | undefined;
src/spawn/cell.ts +6 −1
@@ -8,6 +8,7 @@
88 * (`spawn/placement.ts` `fallbackPlacement`), and the ledger says which.
99 */
1010
11 import { noteStop } from "./stop_note.ts";
1112 import { Database } from "bun:sqlite";
1213 import { existsSync } from "node:fs";
1314 import { tmpdir } from "node:os";
@@ -356,7 +357,11 @@ export function makeRegistry(parent: App, launcher?: Launcher): CellChildren {
356357 });
357358 const exited = proc.exited.then((code) => {
358359 const why = cell.breach();
359 if (why !== undefined) parent.log.warn("child forge stopped", { name: input.name, why });
360 if (why !== undefined) {
361 parent.log.warn("child forge stopped", { name: input.name, why });
362 // The owner reads it on their home page (`spawn/stop_note.ts`).
363 noteStop(dir, why);
364 }
360365 cell.close();
361366 door.close();
362367 release();
src/spawn/fit.ts added +35
@@ -0,0 +1,35 @@
1 /**
2 * Work scheduled to fit the forge's size (#11539). On one CPU a crew run is
3 * the product and has the core: looks, checks and snapshots wait until no
4 * run is active, so nothing competes with it for the time the host gives.
5 * A forge that is not a cell child (no size in its environment) and one of
6 * two or more cores are not held: they have the room.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { isSize, SIZES } from "./sizes.ts";
11 import { SIZE_ENV } from "./usage.ts";
12
13 /** The cores this forge is held to, or undefined where it is not a sized cell child. */
14 export function forgeCores(env: Readonly<Record<string, string | undefined>> = process.env): number | undefined {
15 const size = env[SIZE_ENV];
16 return isSize(size) ? SIZES[size].cpu : undefined;
17 }
18
19 /** Whether a crew run is working now: one `running` agent run. */
20 export function crewRunActive(db: Database): boolean {
21 return db.query("SELECT 1 FROM run WHERE kind = 'agent' AND state = 'running' LIMIT 1").get() !== null;
22 }
23
24 /**
25 * Whether the other work (a look, a check, a snapshot) waits for the crew:
26 * on a forge of one core, while a run is active. `active` is read only when
27 * the forge has one core, so a larger forge pays no query.
28 */
29 export function crewHasTheCore(
30 active: () => boolean,
31 env: Readonly<Record<string, string | undefined>> = process.env,
32 ): boolean {
33 const cores = forgeCores(env);
34 return cores !== undefined && cores <= 1 && active();
35 }
src/spawn/sizes.ts +2 −2
@@ -55,8 +55,8 @@ export const isSize = (v: unknown): v is Size => typeof v === "string" && Object
5555
5656 // What each size is for, never how big: the figures live only in SIZES.
5757 const SAYS: Readonly<Record<Size, string>> = {
58 small: "For a notes app, a small tool, a place to try things.",
59 medium: "For a busy app, or a few apps side by side.",
58 small: "For a notes app, a small tool, a place to try things. The crew has its only core while it builds, so a change takes longer and checks and pictures wait until the crew is idle.",
59 medium: "For a busy app, or a few apps side by side; checks and pictures run beside the crew.",

Principle 1: one confident sentence per size. This line now explains scheduling twice over.

6060 large: "For a crew's working forge, with room to build.",
6161 };
6262
src/spawn/stop_note.ts added +49
@@ -0,0 +1,49 @@
1 /**
2 * What a forge tells its owner when the host stopped it (#11539): the host
3 * writes one small file in the forge's own data directory, the forge reads
4 * it on its home page for a day. Plain data in, plain data out.
5 */
6 import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7 import { join } from "node:path";
8
9 const FILE = "host-stopped.json";
10 /** How long the note stays on the home page, ms. */
11 export const NOTE_MS = 24 * 60 * 60_000;
12
13 export interface StopNote {
14 readonly at: string;
15 readonly why: string;
16 }
17
18 /** Record that the host stopped the forge whose data is `dataDir`, and why. Best effort: a note never stops a stop. */
19 export function noteStop(dataDir: string, why: string, now = new Date()): void {
20 try {
21 mkdirSync(dataDir, { recursive: true });
22 const tmp = join(dataDir, `${FILE}.tmp`);
23 writeFileSync(tmp, JSON.stringify({ at: now.toISOString(), why: why.slice(0, 400) }));
24 renameSync(tmp, join(dataDir, FILE));
25 } catch {
26 // the stop stands without its note
27 }
28 }
29
30 /** The note, while it is a day old at most; undefined when there is none or it is not well formed. */
31 export function readStop(dataDir: string, now = Date.now()): StopNote | undefined {
32 try {
33 const raw: unknown = JSON.parse(readFileSync(join(dataDir, FILE), "utf8"));
34 if (typeof raw !== "object" || raw === null) return undefined;
35 const { at, why } = raw as Record<string, unknown>;
36 if (typeof at !== "string" || typeof why !== "string") return undefined;
37 const when = Date.parse(at);
38 if (!Number.isFinite(when) || now - when > NOTE_MS || when > now + 60_000) return undefined;
39 return { at, why: why.slice(0, 400) };
40 } catch {
41 return undefined;
42 }
43 }
44
45 /** The line on the owner's home page. */
46 export function stopWords(note: StopNote): string {
47 const time = note.at.slice(11, 16);
48 return `This forge was stopped by its host at ${time} UTC: ${note.why}. It started again, and the crew carries on where it stopped.`;
49 }
src/web/studio/home.ts +5 −1
@@ -6,6 +6,7 @@
66 * best of what is public (`actions/front.ts`).
77 */
88
9 import { readStop, stopWords } from "../../spawn/stop_note.ts";
910 import { UPSTREAM_REPO } from "../../core/upstream.ts";
1011 import { foundryHref } from "../../actions/foundry_door.ts";
1112 import { forgeCeiling } from "../../flow/ceiling.ts";
@@ -368,7 +369,10 @@ export async function studioHome(
368369 // The first page after Allow says so, once.
369370 const allowed = untoldAnswer(ctx.app.db, user.id) === "allowed";
370371 if (allowed) markTold(ctx.app.db, user.id, new Date().toISOString());
371 const needsYou = html`${requests}${waiting}${held}${alarms}`;
372 // A stop by the host is said to the owner, plainly, for a day (`spawn/stop_note.ts`, #11539).
373 const stopped = user.isAdmin ? readStop(ctx.app.config.dataDir) : undefined;
374 const stopLine = stopped === undefined ? null : html`<p class="small" id="host-stopped" role="status">${stopWords(stopped)}</p>`;

Both doors: the TUI home has no equivalent of this line. Also 'host' and 'slowed' are not plain words (principle 8).

375 const needsYou = html`${requests}${waiting}${held}${alarms}${stopLine}`;
372376 const mine = writableAppsOf(ctx, user).repos;
373377 const body = html`<h1 class="sr-only">Home</h1>
374378 ${allowed ? html`<div class="toast" data-tone="success" role="status" id="youre-in"><strong>${YOURE_IN_MESSAGE}</strong> Your account is open: tell the crew what you want to build.</div>` : null}
test/cpu-throttle.test.ts added +84
@@ -0,0 +1,84 @@
1 /**
2 * A cell at its CPU limit is slowed, not stopped (#11539): paused for a
3 * share of each tick so it averages its cores, and stopped only when the
4 * pause cannot hold it, after five minutes over twice the limit, in a cell
5 * ten minutes old.
6 */
7 import { describe, expect, test } from "bun:test";
8 import { readFileSync } from "node:fs";
9 import { watchTree, WATCH, type Limits } from "../src/apps/contain.ts";
10 import { LAST_RESORT_MIN_AGE_MS, LAST_RESORT_MS, pauseMs, throttleFailed } from "../src/apps/throttle.ts";
11
12 describe("the pause", () => {
13 test("at or under the limit nothing is paused: using exactly the limit is not over it", () => {
14 expect(pauseMs(1, 1, 1000)).toBe(0);
15 expect(pauseMs(0.4, 1, 1000)).toBe(0);
16 expect(pauseMs(1.02, 1, 1000)).toBe(0);
17 });
18 test("over it the tree runs limit/cores of each tick (hand calculation: 2 cores on 1 is half)", () => {
19 expect(pauseMs(2, 1, 1000)).toBe(500);
20 expect(pauseMs(4, 1, 1000)).toBe(750);
21 expect(pauseMs(3, 2, 1000)).toBe(333);
22 });
23 test("never the whole tick, whatever the demand", () => {
24 expect(pauseMs(1000, 1, 1000)).toBe(900);
25 });
26 });
27
28 describe("the last resort", () => {
29 const born = 0;
30 test("five minutes over twice the limit, in a cell past ten minutes, is a failed throttle", () => {
31 const s = {};
32 const t0 = LAST_RESORT_MIN_AGE_MS;
33 expect(throttleFailed(s, 2.5, 1, born, t0)).toBe(false);
34 expect(throttleFailed(s, 2.5, 1, born, t0 + LAST_RESORT_MS - 1)).toBe(false);
35 expect(throttleFailed(s, 2.5, 1, born, t0 + LAST_RESORT_MS)).toBe(true);
36 });
37 test("at or near the limit it never is, however long", () => {
38 const s = {};
39 for (let at = 0; at < 60 * 60_000; at += 10_000) expect(throttleFailed(s, 2, 1, born, at + LAST_RESORT_MIN_AGE_MS)).toBe(false);
40 });
41 test("a cell younger than ten minutes is never stopped for CPU", () => {
42 const s = {};
43 expect(throttleFailed(s, 9, 1, 0, 0)).toBe(false);
44 expect(throttleFailed(s, 9, 1, 0, LAST_RESORT_MS + 1)).toBe(false);
45 expect(throttleFailed(s, 9, 1, 0, LAST_RESORT_MIN_AGE_MS - 1)).toBe(false);
46 });
47 test("dropping under the line forgets the clock", () => {
48 const s = {};
49 const t0 = LAST_RESORT_MIN_AGE_MS;
50 throttleFailed(s, 3, 1, born, t0);
51 throttleFailed(s, 1, 1, born, t0 + 60_000);
52 expect(throttleFailed(s, 3, 1, born, t0 + LAST_RESORT_MS)).toBe(false);
53 });
54 });
55
56 const cpuSeconds = (pid: number): number => {
57 const f = readFileSync(`/proc/${pid}/stat`, "utf8").split(") ")[1]!.split(" ");
58 return (Number(f[11]) + Number(f[12])) / 100;
59 };
60
61 test("a CPU-bound process under a throttle runs near its quota and is not stopped", async () => {
62 const saved = { ...WATCH };

A 16 s test measuring wall-clock CPU share will flake on a loaded CI machine. Inject the clock and the signal function and test tick behaviour deterministically.

63 WATCH.tickMs = 100;
64 const hog = Bun.spawn(["bun", "-e", "const end = Date.now() + 30000; while (Date.now() < end) {}"], { stdout: "ignore", stderr: "ignore" });
65 let stopped: string | undefined;
66 const limits: Limits = { memoryMiB: 4096, cpus: 0.5, diskMiB: 1 << 20 };
67 const unwatch = watchTree(hog.pid, limits, { bytes: 4096 << 20, by: "watch" }, [], undefined, (why) => (stopped = why), { throttle: true });
68 try {
69 await Bun.sleep(10_000); // the window fills and the pause settles
70 const from = cpuSeconds(hog.pid);
71 const t0 = Date.now();
72 await Bun.sleep(6_000);
73 const cores = (cpuSeconds(hog.pid) - from) / ((Date.now() - t0) / 1000);
74 expect(stopped).toBeUndefined();
75 expect(cores).toBeGreaterThan(0.2);
76 expect(cores).toBeLessThan(0.8); // unthrottled it is 1.0
77 expect(hog.killed).toBe(false);
78 } finally {
79 unwatch();
80 hog.kill("SIGCONT");
81 hog.kill();
82 Object.assign(WATCH, saved);
83 }
84 }, 40_000);
test/crew-has-the-core.test.ts added +43
@@ -0,0 +1,43 @@
1 /** On a forge of one core the crew run has it; looks, checks and snapshots wait (#11539). */
2 import { expect, test } from "bun:test";
3 import { Database } from "bun:sqlite";
4 import { crewHasTheCore, crewRunActive, forgeCores } from "../src/spawn/fit.ts";
5 import { browserGate } from "../src/looks/browser_gate.ts";
6 import { withPage } from "../src/release/devtools.ts";
7
8 test("a small forge holds other work while a run is active; larger and unsized forges do not", () => {
9 expect(forgeCores({ FORGE_CELL_SIZE: "small" })).toBe(1);
10 expect(forgeCores({ FORGE_CELL_SIZE: "large" })).toBe(4);
11 expect(forgeCores({})).toBeUndefined();
12 expect(crewHasTheCore(() => true, { FORGE_CELL_SIZE: "small" })).toBe(true);
13 expect(crewHasTheCore(() => false, { FORGE_CELL_SIZE: "small" })).toBe(false);
14 expect(crewHasTheCore(() => true, { FORGE_CELL_SIZE: "medium" })).toBe(false);
15 expect(crewHasTheCore(() => true, {})).toBe(false);
16 });
17
18 test("a larger forge never asks whether a run is active", () => {
19 let asked = 0;
20 crewHasTheCore(() => (asked++, true), { FORGE_CELL_SIZE: "large" });
21 expect(asked).toBe(0);
22 });
23
24 test("a run is active only while one is running", () => {
25 const db = new Database(":memory:");
26 db.run("CREATE TABLE run (kind TEXT, state TEXT)");
27 expect(crewRunActive(db)).toBe(false);
28 db.run("INSERT INTO run VALUES ('agent','queued'), ('check','running')");
29 expect(crewRunActive(db)).toBe(false);
30 db.run("INSERT INTO run VALUES ('agent','running')");
31 expect(crewRunActive(db)).toBe(true);
32 });
33
34 test("one browser at a time: a page waits while a picture holds the gate", async () => {
35 const held = await browserGate.acquire();
36 let started = false;
37 const waiting = withPage("/nonexistent/chromium", async () => 1).catch(() => (started = true));
38 await Bun.sleep(100);
39 expect(started).toBe(false);
40 held();
41 await waiting;
42 expect(started).toBe(true);
43 });
test/crew-interrupted-twice.test.ts added +74
@@ -0,0 +1,74 @@
1 /**
2 * A run cut off by a forge stop carries on once; cut off twice in a row the
3 * forge says so on the issue and does not queue it again (#11539).
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { addToCrew, createAgent } from "../src/actions/crew.ts";
10 import { openIssue } from "../src/actions/issues.ts";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { claimRun, createRun, getRun, listQueued } from "../src/core/runs.ts";
14 import { listComments } from "../src/core/issues.ts";
15 import { requeueInterrupted } from "../src/crew/dispatch.ts";
16 import { INTERRUPTED_TWICE_NOTE, interruptedInARow } from "../src/crew/interrupt_loop.ts";
17 import { STALE_AFTER_MS } from "../src/runs/lease.ts";
18 import { createUser } from "../src/core/users.ts";
19
20 let dataDir = "";
21 let app: App;
22
23 beforeAll(async () => {
24 dataDir = await mkdtemp(join(tmpdir(), "forge-twice-"));
25 app = createApp({ dataDir, port: 0 });
26 });
27 afterAll(async () => {
28 app.close();
29 await rm(dataDir, { recursive: true, force: true });
30 });
31
32 test("the first stop queues the run again; the second says so and does not", async () => {
33 const made = await createUser(app.db, {
34 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
35 password: "a-good-passphrase", adminIfFirst: true,
36 });
37 if (!made.ok) throw new Error(made.error);
38 const alice = made.value;
39 const bot = await createAgent(app, alice, { username: "bot" });
40 if (!bot.ok) throw new Error(String(bot.error));
41 const created = await createRepo(app, alice, { name: "loop", visibility: "private" });
42 if (!created.ok) throw new Error(String(created.error));
43 const repo = created.value;
44 expect(addToCrew(app, alice, repo, "bot").ok).toBe(true);
45 const issued = openIssue(app, alice, repo, { title: "Build it", body: "" });
46 if (!issued.ok) throw new Error(String(issued.error));
47 const issue = issued.value;
48
49 const lapse = (id: string) =>
50 app.db.query("UPDATE run SET heartbeat_at = ?2 WHERE id = ?1").run(id, new Date(Date.now() - STALE_AFTER_MS - 1_000).toISOString());
51 const cutOff = (id: string) => {
52 claimRun(app.db, id);
53 lapse(id);
54 requeueInterrupted(app);
55 };
56
57 const first = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: issue.id, actorId: bot.value.user.id, reason: "assigned" });
58 cutOff(first.id);
59 expect(getRun(app.db, first.id)!.state).toBe("cancelled");
60 const again = listQueued(app.db, "agent").filter((r) => r.issueId === issue.id && r.state === "queued");
61 expect(again).toHaveLength(1);
62 expect(interruptedInARow(app.db, issue.id)).toBe(1);
63
64 cutOff(again[0]!.id);
65 expect(interruptedInARow(app.db, issue.id)).toBe(2);
66 expect(listQueued(app.db, "agent").filter((r) => r.issueId === issue.id && r.state === "queued")).toHaveLength(0);
67 expect(listComments(app.db, issue.id).some((c) => c.body === INTERRUPTED_TWICE_NOTE)).toBe(true);
68 });
69
70 test("a run that ended another way between two stops breaks the row", () => {
71 const rows = [{ summary: "interrupted by a restart; queued again" }, { summary: "built it" }, { summary: "interrupted by a restart; queued again" }];
72 const fake = { query: () => ({ all: () => rows }) } as never;
73 expect(interruptedInARow(fake, "x")).toBe(1);
74 });
test/host-stop-note.test.ts added +42
@@ -0,0 +1,42 @@
1 /** The owner is told on their home page that the host stopped their forge, and why (#11539). */
2 import { expect, test } from "bun:test";
3 import { mkdtempSync, rmSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { NOTE_MS, noteStop, readStop, stopWords } from "../src/spawn/stop_note.ts";
7
8 test("a stop is noted, read back for a day, and then forgotten", () => {
9 const dir = mkdtempSync(join(tmpdir(), "forge-stopnote-"));
10 try {
11 expect(readStop(dir)).toBeUndefined();
12 const at = new Date("2026-11-11T23:36:00Z");
13 noteStop(dir, "used 2.9 cores for 5 minutes while slowed, over twice its limit of 1", at);
14 expect(readStop(dir, at.getTime() + 1000)?.why).toContain("over twice its limit of 1");
15 expect(readStop(dir, at.getTime() + NOTE_MS + 1)).toBeUndefined();
16 } finally {
17 rmSync(dir, { recursive: true, force: true });
18 }
19 });
20
21 test("the words give the time and the reason", () => {
22 expect(stopWords({ at: "2026-11-11T23:36:00.000Z", why: "used too much" })).toBe(
23 "This forge was stopped by its host at 23:36 UTC: used too much. It started again, and the crew carries on where it stopped.",
24 );
25 });
26
27 test("the owner's home page says so; a forge never stopped says nothing", async () => {
28 const { startAppsForge, signedInCookie, stopAppsForge } = await import("./helpers/apps-forge.ts");
29 const forge = await startAppsForge();
30 try {
31 const cookie = await signedInCookie(forge.servers);
32 const read = async () => (await fetch(`${forge.servers.url}/`, { headers: { cookie } })).text();
33 expect(await read()).not.toContain("host-stopped");
34 noteStop(forge.app.config.dataDir, "used 2.9 cores for 5 minutes while slowed, over twice its limit of 1");
35 const page = await read();
36 expect(page).toContain('id="host-stopped"');
37 expect(page).toContain("This forge was stopped by its host");
38 expect(page).toContain("over twice its limit of 1");
39 } finally {
40 await stopAppsForge(forge);
41 }
42 }, 30_000);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs