Public: anyone can read this; the crew can change it.
The forge's own web process always answers: check cells and crew runs never starve it (readiness flapped with 9 checks) #4477
Check · Review · Land · Live done — Landed and live in production.
The forge's web process now always answers. Check steps and crew runs start in a background class: nice 10, `SCHED_BATCH` where `chrt` exists, best-effort IO at level 7. Where the forge holds a delegated cgroup, a step's cell also gets a low `cpu.weight`. A full lane now slows checks, not pages. A Serving board charts the forge's event-loop lag at p99, from a new `src/kernel/loop_lag.ts`, beside t…
shipped production is serving it deploy 93e874d
The forge's web process now always answers. Check steps and crew runs start in a background class: nice 10, SCHED_BATCH where chrt exists, best-effort IO at level 7. Where the forge holds a delegated cgroup, a step's cell also gets a low cpu.weight. A full lane now slows checks, not pages. A Serving board charts the forge's event-loop lag at p99, from a new src/kernel/loop_lag.ts, beside them.
What to look at:
src/apps/background.ts: builds the wrapped command. The script contains no caller text; the command to run is only passed in as arguments. Each priority step is optional: a host without the tool, or one that refuses, runs the work at its old priority.src/crew/claude.ts: a crew run whose binary can't be found is spawned without the wrapper, as before. Otherwise a missing binary showed up asniceexiting 127 and was read as "no changes pushed", where it used to fail at spawn.test/serving-priority.test.ts: a load test runs 12 busy-loop "burners" in the background class beside a server and holds/healthzp99 under 200 ms.
Why the last check was stopped: a test bug, not a bug in the app. The burners ran inside timeout(1). Because the wrapper hands its process over to the command, the process the test killed was timeout, and its busy-loop child was left orphaned and kept running. Twelve of them held the check's 16 cores until the check was stopped.
Now each burner is the loop itself, so killing it stops it. It also only loops while the test process named as its owner is alive, so none outlives a run that is cut off. A new test starts a burner whose owner exits at once and checks that the burner ends; with the old plain while : loop it would fail. I changed the test here because the test was wrong; the app code is unchanged.
bun test test/serving-priority.test.ts passes (9 tests) and bun x tsc --noEmit is clean. No burner was left running afterwards.
To try it: run bun test test/serving-priority.test.ts, then check that no kill -0 loop is left in the process list.
🤖 Generated with Claude Code
The forge's own web process now outranks every check cell and crew run on the CPU. Each check cell and each crew run also has a hard CPU quota, so one busy check or agent slows itself instead of eating the pod's 48 cores. Checks get slower under load; pages don't. Type check, scripts/gate.ts, and the cgroup, contain, serving-priority, crew-limits/watch/sandbox, session and steer suites pass here. The real-kernel cgroup tests skip in this sandbox because /sys/fs/cgroup is read-only to it; the forge's check runs them.
Already on the branch from earlier commits:
- Every check step and crew run starts at nice 10 under
SCHED_BATCH, with best-effort IO at level 7 (apps/background.ts). - Where cgroups are delegated, the forge's own leaf has
cpu.weight/io.weight1000; check-step cells have 10 and app cells keep the default 100. - A Serving board charts event-loop lag p99 beside the number of checks and crew runs running.
- A load test runs 12 CPU-burning processes beside a server and requires
/healthzp99 under 200 ms with no failed probe. ee41c0c5makes those burners end with the test, so none are left running to fill a check's 16 cores.
Added in this push, from the steward's 15:35Z ask:
- Check cells: each one's cgroup gets
cpu.maxat its declared cores (check.limits.cpus, 16 for this repo), so the kernel throttles a step instead of letting it take the pod.cpuMax()insrc/apps/cgroup.tscites cgroup-v2.rst; 2 cores is200000 100000, with the kernel's 1 ms floor. - Crew runs: until now they ran inside the forge's own cgroup, where only nice told them apart from the web process. Each run now gets its own cell cgroup with:
- no memory cap;
- weight 10;
- cpu.max of FORGE_CREW_CPUS cores (new setting, default 4, at most 1024, documented in docs/APPS.md).
The cgroup is removed when the run ends, which also kills anything the agent left running. Joining it is attempted but not required (tryEnterArgv): a host without a delegated cgroup runs the agent as before.
- Tests (
test/cgroup.test.ts,test/serving-priority.test.ts):
- a step cell's cpu.max and a crew run's cell, checked on a fake cgroupfs;
- cpuMax against hand-worked values;
- tryEnterArgv joins when it can, runs anyway when it can't, and never runs the path as a command;
- the FORGE_CREW_CPUS checks.
Not done:
- No overall cap on all cells together. The 1000-to-10 weight already gives the forge about 99% of contended CPU, and a total cap would have to read the pod's own quota.
- App cells get no quota, since they serve people too.
checks.concurrencyand the pod limit are unchanged; raising them is the steward's call once the board shows the numbers.
Concurrent writer: at 15:40:49 something reset this clone and wiped my uncommitted edits. Commit ee41c0c5 (the burner fix) was then pushed as Torque from outside this session, and I re-applied my work on top of it. It may have been a second Torque session working in the same directory. No text asking me to do anything outside the task appeared in the issue or the repository.
To try: on a host with delegated cgroups, start a check or crew run and read /sys/fs/cgroup/cells/c*/cpu.max. It should say 1600000 100000 for this repo's check, or 400000 100000 for a crew run.
🤖 Generated with Claude Code
Approved. The preview boots and / and /healthz answer 200. The background class is built as arguments only, so no caller text becomes script. Every priority step is optional. A missing crew binary still fails at spawn. Tests cover the percentile, the lag, the argv, the cgroup files on a fake cgroupfs, the roll-up and the sampler, and the 12-burner load test now cleans up after itself. Serving is a plain board, so the terminal Boards screen and the API reach it like the others, and docs/APPS.md and the README say how to use it. No dependency was added.
Two things I would do next:
- In src/crew/claude.ts the Bun.spawn options block still sits at the old indent inside the new spawn() arrow, so run the formatter over it.
- Once the Serving board has a few days of numbers, set checks.concurrency from them, as the pull says. Then add a short alert or worth-knowing line when lag_ms_max stays high, so a regression is seen before a probe fails.
The forge's web process now always answers. Check steps and crew runs start in a background class: nice 10,
SCHED_BATCHwherechrtexists, best-effort IO at level 7. Where the forge holds a delegated cgroup, a step's cell also gets a lowcpu.weight. A full lane now slows checks, not pages. A Serving board charts the forge's event-loop lag at p99, from a newsrc/kernel/loop_lag.ts, beside them.What to look at:
src/apps/background.ts: builds the wrapped command. The script contains no caller text; the command to run is only passed in as arguments. Each priority step is optional: a host without the tool, or one that refuses, runs the work at its old priority.src/crew/claude.ts: a crew run whose binary can't be found is spawned without the wrapper, as before. Otherwise a missing binary showed up asniceexiting 127 and was read as "no changes pushed", where it used to fail at spawn.test/serving-priority.test.ts: a load test runs 12 busy-loop "burners" in the background class beside a server and holds/healthzp99 under 200 ms.Why the last check was stopped: a test bug, not a bug in the app. The burners ran inside
timeout(1). Because the wrapper hands its process over to the command, the process the test killed wastimeout, and its busy-loop child was left orphaned and kept running. Twelve of them held the check's 16 cores until the check was stopped.Now each burner is the loop itself, so killing it stops it. It also only loops while the test process named as its owner is alive, so none outlives a run that is cut off. A new test starts a burner whose owner exits at once and checks that the burner ends; with the old plain
while :loop it would fail. I changed the test here because the test was wrong; the app code is unchanged.bun test test/serving-priority.test.tspasses (9 tests) andbun x tsc --noEmitis clean. No burner was left running afterwards.To try it: run
bun test test/serving-priority.test.ts, then check that nokill -0loop is left in the process list.🤖 Generated with Claude Code
Continues #4477