0034 — Heavy work takes turns by fair share, and the guard rewrites the command
- Status: Accepted — shipped in watch mode 2026-09-28; enforce is switched on by the supervising session, watching it live.
- Date: 2026-09-28
- Deciders: Gera, 2026-09-28 ("build the heavy-work queue"; then, rejecting a flat FIFO slot pool: fair share per session with borrowing — "ballooning").
- Systems:
pacer(local-binpace, launchdco.propflow.pacer), dotclaudehooks/guard-bash.shrule 28,memory-guard,host-alerts. Catalog entry: SYSTEMS.md → pacer (heavy work takes turns on the host).
The problem
Measured 2026-09-28 ~00:05 on the mini (14 cores: 10P + 4E): load ~30 sustained,
spiking to 60. At one moment 24 vitest processes, 17 render processes
(Remotion/ffmpeg/headless chrome), 5 next dev servers, tsgo type-checks at
7–10 GB each, eslint and Playwright — each started by a different session with
no knowledge of the others. When bursts lined up, 20–34 processes sat in
uninterruptible disk wait, background jobs starved (the ccswitch snapshot went
stale and alerted), and the kernel panicked twice that week from
userspace-watchdog stalls.
Per-tool caps already existed and did not help: propflowai's vitest.config.ts
caps one run at half the cores and narrows under load, but N sessions each take
their own "half". Nothing had a view across sessions.
Gera's constraint, the same one memory-guard was built under: no cap on the number of agents. Only heavy work takes turns.
Decision
The unit of fairness is the session. Accounting is per
CLAUDE_CODE_SESSION_ID; a session's subagent shells inherit the parent's id, so a session and its subagents are one account. Only sessions with heavy work running or waiting now count. A session alone may use the whole pool (borrowing); when units free, they go to the waiting session holding the fewest — furthest below its equal share — with ties first come, first served. No preemption: borrowed units come back when the job ends. The order is strict (a small ask does not jump the head of the line), so big asks are never starved.Units, not slots, and the balloon is real. Each class has a cost; a grant may be smaller than asked (down to the class minimum) and the child's parallelism is set from the grant —
VITEST_MAX_FORKS/VITEST_MAX_THREADS(read natively by vitest 3.2.7 and by propflowai's own config),GOMAXPROCSfor tsgo — plusnice -n 5. Long jobs (render, build) share a lane capped at min(4, capacity/2), so they can never fill the pool.Capacity follows the machine, not a constant. Base = cores, floor = cores/2; multiplicative decrease while the uninterruptible-wait count or load/core is at its high mark and pacer holds work (shrinking an idle pool cannot reduce load it did not cause); additive increase once neither has been high for a minute. A band, not a low mark: the review found that a separate low mark never recovered at this box's baseline (sessions alone sit at load/core 2–4), so one spike would have halved the pool for the day. memory-guard's HOLD file stops all new grants.
Enforcement is transparent: the guard rewrites the command. dotclaude
guard-bash.sh(rule 28) askspace hookand returns the rewritten command as PreToolUseupdatedInput(pace --class X [--fg] -- <original>). Verified on Claude Code 2.1.283 by a headless run: the rewritten command is what runs, with or without apermissionDecision; none is sent. (The permission matcher then sees the rewritten string; that is moot while the fleet runsbypassPermissions, and would matter only to an allow/deny rule written againstnpm …/npx ….) The guard askspaceonly for calls it is not denying, and cuts it off at 3 s. Sessions do not need to know pacer exists.It always fails open, and never blocks unbounded. No
paceon PATH (the Mac), a brokenpace, a broken config,mode: off, the kill switch file, a state lock busy past its bound (1 s watch, 10 s enforce), or the max wait → the command runs exactly as typed, and the log says so. Watch mode starts the child before it touches any shared state. A foreground Bash call waits at most 75 s and the hook extends that call's timeout to cover it, so queueing never silently eats the 2-minute Bash limit; a busy queue tells the session to userun_in_background.No daemon in the decision path. State is one JSON file under one flock; whoever holds the lock hands out every grant that fits, using the same pure function
pace replayuses, and waiters poll about once a second to notice. Every entry is stamped with its pid and a flock its process holds, so a killed holder's units return on the next lock and a reused pid cannot keep them. The launchd tick exists only for readers (status.json freshness, pressure samples).status.json is a published contract (fields in SYSTEMS.md), consumed by the Systems page and by host-alerts, which posts when the oldest waiter passes 10 minutes. Consumers integrate by reading it — signals, not calls (ADR-0003).
Rollout
Shipped in watch mode: every heavy command is wrapped, runs immediately and
unchanged, and its demand is logged (request / grant with would_wait /
release). pace replay re-runs the scheduler over that log at chosen pool
sizes and reports p95 wait per session, peak concurrency by class, and grants
that borrowed versus stayed within share. Enforce is one config value
("mode": "enforce" in ~/.local/bin/pace.json); the kill switch is
touch ~/.claude/jobs/pacer/DISABLED.
Review
An independent review (Fable, 2026-09-28) confirmed the scheduler's properties
and the guard's fail-open paths, and found three defects fixed before merge: an
unbounded lock wait (watch mode could wait), AIMD that never recovered at this
box's baseline, and nohup being moved inside pace where a SIGHUP during the
wait would kill it. Known limits it recorded: a grant is sized at its instant
and does not re-inflate when the pool empties; pace replay uses durations
measured without coordination and a fixed capacity.
Alternatives rejected
- A flat FIFO pool of N slots. One session running five suites would hold five slots while four other sessions wait behind it; sizing N from a day's traffic still gets that wrong. Fair share with borrowing gives the lone session the whole box and the crowded box an equal split, with no N to tune.
- Capping agents. Explicitly ruled out by Gera; agents are mostly idle between heavy commands, and the commands are what hurt.
- Asking sessions to wrap their own commands. Doctrine that depends on every session remembering is how the uncoordinated state arose; the hook makes it mechanical.
- Per-tool caps only (the vitest config's load backoff). Each tool sees one run; the overload was always across runs and across tools.
Consequences
- Heavy commands on the mini now pass through
pace. In watch mode the only cost is ~50–100 ms of bookkeeping per heavy command. - In enforce, a session can wait for its turn; the stderr status line names who holds the units, and the max wait bounds it.
- Commands pacer cannot see — heavy work inside
bash -c "…"strings, scripts that spawn vitest themselves — are not paced. Dev servers: see the amendment.
Amendment 2026-10-02 — the night Smith's worker starved twice
- Deciders: the host-side lane for the Smith starvation (Fable, under the coordinator's brief), applying Gera's standing rule unchanged: no cap on agents, heavy work takes turns, nothing running is killed.
- Systems:
pacer,host-alerts,memory-guard,agent-smith(worker + slack-socket), dotclaudeguard-bash.shrule 28.
What happened. 21:33–21:41 CDT Smith's Temporal worker logged
[TMPRL1104] Workflow task exceeded 5 seconds (verbatim from
smith-worker.err.log; the figure in that warning is the SDK's per-task
deadline, and the agent-smith#736 alarm quotes it at 10 s — the line, not the
number, is the receipt), ~20 "Failed handling activation", and a finished
reply was discarded; 22:47 a 20 s activity waited 21 s to start.
Measured: the 21:30 episode was a swap storm (memory-guard: swap 10.9 → 32
GB, free 0.1 GB, compressor 53%) that followed three typecheck grants at 1
unit each within 35 s — tsgo is 7–10 GB at any unit count, and the pacer
budgeted CPU units only; memory-guard logged "WOULD HOLD" and held nothing
(watch-only). The 22:40–23:00 episode was CPU/disk (load/core 6–10, 18–32
processes in disk wait, memory fine): 28 pytest-xdist workers from two
uv run pytest suites (-n auto), unseen by the pacer and by the hook's
prefilter; a next dev --turbopack at 255% CPU; ~36 Claude sessions. The
pacer logged 32 busy ticks across the two windows that nobody read.
Decisions (each changes how the systems relate):
The pacer budgets memory as well as units. Each class carries
mem_gb(typecheck 8, build 4, test/browser/render 2, lint 1) againstmem_budget_gb20; a waiter that does not fit waits, strictly, like units. Static on purpose — macOS "free" is not a signal (memory-guard's 2-of-3 bars exist for that reason); the dynamic brake is memory-guard's HOLD.memory-guard's HOLD is armed on its own (
memory-guard.hold.armed): the file is raised under pressure, the pacer — its only reader — pauses new heavy work, and no process is signalled and no session refused. The runaway kill stays watch-only, as Gera asked.pytest is heavy work, and the pre-push hook is pace's second caller (
pytest,uv run … pytest,python -m pytest; agent-smith's.githooks/pre-pushwraps its own suites inpace --class pytest --fg --, agent-smith#738, because a suite insidegit pushnever passes the Bash-tool hook — the first caller — and those suites were the 28 workers): 8 units,PYTEST_XDIST_AUTO_NUM_WORKERSfrom the grant; the dotclaude prefilter learns the words (a barepythondeliberately not — everypython3 - <<EOFwould pay pace's start-up).Dev servers yield, never queue. A 0-unit class (
next dev,vite,run dev*): exec'd in place at nice 10, no holder, no parent on a server for hours. Closes the "idle next dev" item above.Headroom for the latency-sensitive daemons is a smaller paced pool, not a priority.
base_unitsis 12 on the 14-core mini: the normal paced pool is capped at 12 units, so paced work leaves scheduler headroom for Smith's worker, the slack socket and the sessions' own turns. Units are accounting, not CPU affinity — unpaced work, fail-open and the max-wait overflow still use every core, and pressure stays normalised by 14 — so this is headroom, not a reservation (Codex review of local-bin#362). A launchdNice -10on the daemons was tried the same night (agent-smith#737) and withdrawn: a 90-min A/B sampler gave a CPU burst the same 0.84 share at nice 0 and -10 at load/core 4–6 (Darwin's timeshare scheduler gives nice little leverage), and the worker is a spawn root —claude -p,start-driver→ tmux,claude --resume --bg— so the priority would inherit into servers and human panes. pace's +5 nonetheless became an absolute floor (nice_target), so any future negative-nice parent still yields.Sustained saturation is loud.
pace tickkeeps a consecutive-busy clock and publishesstatus.json.busy(since, ticks, top commands, top sessions by CPU — every process charged to the registered session it descends from; no pid leaves); host-alerts postshost-saturatedafter 5 min of it. Signals, not calls (ADR-0003): host-alerts samples nothing.A dev server lives one hour (Gera, 2026-10-03 08:5x: "workers/sessions are most important and if something is consuming more it needs to be addressed"). The pacer's yield (decision 4) makes a server polite; it cannot end one, and at 08:46 three
next devservers were 2.7 cores and ~4 GB against 1.6 cores of actual sessions, one of them nine hours old. A new reflex,dev-reaper(local-bin#368, launchd every 60 s), stops anynext dev/viteserverttlS(3600 s) after it started — a hard lifetime, not an idle rule (Gera's choice over an idle-30-min proposal; a session that still needs a server starts a new one) — and announces every stop with kind, port, worktree and age. It signals only the launcher and next's own server process, re-verified by recorded identity on every rung; never a shell, a session, a test runner or a build. Relation to the supervisor'sdevserversphase: that one stops only servers it created and adopts the rest; the reaper's lifetime applies to all of them, and the supervisor's reconcile pass already removes a serve entry whose port no longer listens.
Still outside the pacer's reach, on purpose: find/git grep/rg and
the tsx scripts/check-*.ts pre-push checks (short and bounded — a queue wait
costs more than they do); the ChatGPT/Codex app's renderers (not ours); the
sessions themselves (~60–90% CPU summed across ~30 — not the load).
Amendment 2026-10-03 — the pacer reaps what has no live owner
- Deciders: the pacer-reaper lane (Fable, under the host-throughput driver's brief, session 7aef7a84), applying Gera's standing rules unchanged: no cap on agents; sessions get the host first; never kill a live session's foreground work; every reap logged and announced. The cross-system decision is ADR-0040 §4 (what finished or died is reaped by the thing that already owns it); this paragraph records how the pacer carries its share of it.
- Systems:
pacer,liveness, the conversation lease (~/.claude/hooks/conversation-lease.py),fleet-medic(reads the same lease rule),agent-smith(the Slack poster).
What was wrong. Decision 6 above stamps every entry with its pid — the
pid of pace itself, not of the vitest/tsgo/pytest it started. prune()
therefore returned a dead holder's units and never signalled the job: a
SIGKILLed pace (session killed, tmux window closed, medic relaunch) left its
tree running, unaccounted and unpaced. On record: the hung shape in
agent-smith's hook header (2026-09-12 — 99 % of tests reported, all 14 xdist
workers at 0.0 % CPU, controller alive, log frozen eight minutes); on
2026-10-03, ~45 sessions, 231 worktrees and a 27-minute 2-core vitest run.
Decisions.
- The holder records its child —
child_pid, the child'sps lstartunderLC_ALL=C(the lease hook's one start-time format) and its argv head — in watch mode inline, in enforce under one bounded lock after the child starts (a busy lock leaves it unstamped and says so). - The session's liveness decides, through the fleet's existing instruments
and never a new predicate (two answers to "is this seat alive" was the
2026-09-11 incident): the conversation lease's holder (pid + start time,
the rule fleet-medic relaunches on) and
liveness(roster + process evidence, three-valued). A lease saying alive is alive. A reap needs both to say dead, or the primitive alone where there is no lease. A read that fails — roster timeout, unreadableps, a stamp with no start time — is unknown and keeps the job: the predicate is a conjunction of absences (ADR-0040 review, agentflow #271). A job with no session id is never judged. The lag this carries is accepted: a session whose roster row vanished reads dead only after its transcript has been quiet 15 min with no process behind it. - A live session's job is never signalled, whatever its runtime. The
judging runs outside the state lock (one
ps, one roster read, then dev-reaper's ladder: TERM the child → TERM the tree's survivors after 10 s → KILL after 20 s, every pid re-verified by argv head + start time before each rung), at most two reaps per tick;DISABLEDdisables it;pace --print-reapshows the verdicts and touches nothing. - Every reap is evidence a human can falsify later: a
reapedlog line; a row inreaped.jsonlwith session, verdict, how it was obtained, argv head, age, signals sent and survivors; one Slack line through the fleet's one poster (--source pacer, never--alert);status.jsonreaped_24h/last_reap. Areap-judgedline on every tick — and a datedREAP-JUDGEDline inreaper.logthe catalog'slog-match-agecan read — so a tick that found nothing still proves the reaper looked (ADR-0009). - Part 2, the hung job (same lane, next PR): a holder past its class's
max_run_sand whose tree's cumulative CPU time has not advanced for ten ticks is reaped withwhy=hung; a live session's job past the maximum that still burns CPU is logged once (long-running) and left alone.