acpmux: agent work yields CPU and disk to the app (one QoS policy)
Leo (2026-10-29): "well somehow at "100% cpu" usage claude code desktop still seems fast / how does that work? how can cmux next also do that? let's do that"
and: "but i also wonder how much of this is simply putting the desktop at higher priority?" ... "the gui of the desktop app i mean"
What this does
One declared policy (cmux-tui/crates/acpmux/src/qos.rs, POLICY) says how much each acpmux process yields to the person at the screen, applied where each process starts:
| Tier | Who | macOS | Linux |
|---|---|---|---|
| Serving | the daemon | disk I/O at IOPOL_UTILITY; CPU unchanged | unchanged |
| Agent | agent hosts, background-task runners, and everything they start (harness, shells, git, rg, compiles) | CPU clamped to utility (priority 20) and disk I/O at IOPOL_UTILITY | nice +5 |
| Background | housekeeping passes in the daemon (log retention, the upload sweep, probe cleanup) | that thread in PRIO_DARWIN_BG for the pass, then back | unchanged |
ACPMUX_QOS=off turns it off. ACPMUX_QOS=background puts agent hosts, and everything they start, in background class instead of utility.
Why the daemon keeps its CPU priority. The app waits on the daemon for every streamed frame. Demoting it would put a person's own stream behind agent compiles. Its disk work still yields.
Why a re-exec. A probe on a fleet mini (cmux-mac-mini) found that thread QoS does not survive exec into another program. posix_spawnattr_set_qos_class_np sets only the child's main thread, and its new threads come back at the default. Neither nice nor PRIO_DARWIN_BG moves the scheduler priority of default-QoS threads in a useful way. The QoS clamp does: posix_spawnattr_set_qos_clamp_np, the call behind taskpolicy -c utility, put every thread of the child and of its grandchild (through /bin/sh exec) at 20. So an agent host re-executes itself in place (POSIX_SPAWN_SETEXEC: same pid, same descriptors, before any thread starts) under the clamp. The harness and its tools inherit the clamp. The clamp setter is looked up with dlsym, so a macOS without it starts unclamped instead of failing to load. The disk I/O policy is process wide and inherited across exec, so it needs no re-exec.
No spawn site changes (agent_host/link.rs is untouched), so this does not collide with the lazy agent-host start work.
Review fixes (Codex gpt-6-astra, BLOCK at 2edf480). The clamp now takes POSIX_SPAWN_PROC_CLAMP_UTILITY (1) as a uint64_t. QOS_CLASS_UTILITY (0x11) was accepted and changed nothing; a run of the DEV build's host on the mini showed it at 31. Log retention and the upload sweep stay at the daemon's priority, because both hold a lock that chat traffic takes. ACPMUX_QOS now reaches hosts through their environment allowlist. A macOS unit test spawns a child under the clamp and checks that it, and the shell it starts, run at priority 20.
Checked on a fleet mini with the DEV build (cmux-ci build, head 86c7b63): cmux acp __agent-host runs every thread at priority 20 with ACPMUX_QOS_CLAMPED=utility, same pid. With ACPMUX_QOS=off it runs at 31.
Not done here: raising the focused session. A clamp cannot be lifted from outside, so this PR does not raise the focused session's host while it streams. Inside the agent tier every session is equal, so the focused one competes only with other agent work, and the app's own threads (46-47) stay above all of it.
Performance
Harness: scripts/measure/qos-load-bench.sh (added here). It runs probes at the app main thread's QoS (user-interactive) on cmux-mac-mini (Mac16,10, 10 cores):
- a 120 Hz frame (wake, then 2 ms of work);
- a 4 KiB uncached read every 50 ms;
- a 4 KiB write + fsync every 50 ms;
- a 64 KiB temp-file-and-rename save every 300 ms (
Data.write(options: .atomic)at the composer's draft throttle).
The load is agent-like: 12 spinning threads on 10 cores, 2 dd writers, 2 find metadata storms and 2 readers. It runs either at the default priority ("before", which is today) or under taskpolicy -c utility -d utility ("after": the CPU clamp and disk policy this PR applies to agent hosts and everything they start). Each cell is 30 s, over 6 rounds in two runs; values are the median of the rounds' values, and counts are summed.
| metric | before | after | delta | harness or run link |
|---|---|---|---|---|
| frame wake late p99, under load (idle 2.9 ms) | 3.0 ms | 3.0 ms | 0 | scripts/measure/qos-load-bench.sh |
| frames late > 16.7 ms, under load | 0 / 21600 | 0 / 21600 | 0 | same |
| uncached 4 KiB read p99, under load (idle 4.4 ms) | 86 ms | 21 ms | -76% | same |
| reads > 16.7 ms, under load | 69 / 3600 | 36 / 3600 | -48% | same |
| write + fsync p50, under load (idle 0.12 ms; after is bimodal: 0.2-0.4 ms or 16-36 ms) | 33 ms | 8 ms | -76% | same |
| write + fsync > 16.7 ms, under load | 2419 / 3600 | 1739 / 3600 | -28% | same |
| atomic 64 KiB save p95, under load (idle 1.0 ms) | 1.01 s | 1.19 s | none (noise) | same |
| atomic saves > 16.7 ms, under load | 132 / 300 | 128 / 300 | none | same |
| Chromium pane drag lag p95, heavy page, 14 busy processes (default -> utility) | 55.2 ms | 40.2 ms | -27% | manaflow-ai/cmuxterm-hq#4947, cmux20-bench, steps 33cca097, 8da23621 |
same, load at background class (ACPMUX_QOS=background) | 55.2 ms | 6.0 ms | -89% | hq#4947, step 6e012869 |
| agent CPU job, idle Mac, 1 thread (default / utility / background) | 0.90 s | 0.78 s / 16.6 s | utility -13%, background +1750% | cmux-mac-mini, 4P+6E, fixed 3e9-iteration loop |
| agent CPU job, idle Mac, 10 threads (default / utility / background) | 3.96 s | 3.59 s / 28.5 s | utility -9%, background +620% | same |
Building on hq#4947. That issue already measured the agent-priority lever on the Chromium drag: utility class helped only partly (55 to 40 ms), and background class fixed it (55 to 6 ms, and 40% less main-thread CPU per drag). Background is not the default here because it keeps agent work off the performance cores even when the Mac is idle: 18x slower on one thread and 7x on ten. Agents would get slow all the time to win under contention. So the default is utility, which costs agents nothing idle, and ACPMUX_QOS=background gives the hq#4947 result to anyone who wants it. Raising the renderer of the page being dragged (hq#4947's next step) is app-side work for the follow-up PR.
What this says, next to Leo's question about the desktop's priority:
- CPU priority is not what makes the app slow. A user-interactive thread loses nothing at 100% CPU, with or without this PR. The cmux app already runs at 46 like Claude Desktop's 47-54, so raising the GUI further gains nothing.
- Disk is what hurts, and this PR gives part of it back: slow uncached reads halve, and slow fsyncs drop by about a quarter.
- A buffered save (temp file + rename) still takes about 1 s at p95 under agent disk load at any tier. So any save on the main thread is a hitch. The app-side PR (next) moves the composer's draft save and the transcript's scroll-place save off the main thread.
Agent session: agent session
š¤ Generated with Claude Code