diff --git a/ansible/roles/developer-rust/README.md b/ansible/roles/developer-rust/README.md index a2231326..961fd8a0 100644 --- a/ansible/roles/developer-rust/README.md +++ b/ansible/roles/developer-rust/README.md @@ -189,6 +189,8 @@ cargo on PATH, so a developer or an agent who knows none of this runs Which instrument yields on which host, and why it needs the `zram_swap` role, are in [docs/rust-build-governor.md](../../../docs/rust-build-governor.md). +The role also writes `agents.slice`, the cgroup bounding Claude Code sessions, +from the same RAM budget (`--tags agents-slice`, `agents_*` variables). The host-wide picture is [docs/concurrent-dev-cache.md](../../../docs/concurrent-dev-cache.md). diff --git a/ansible/roles/developer-rust/defaults/main.yml b/ansible/roles/developer-rust/defaults/main.yml index eea194d3..0c67b932 100644 --- a/ansible/roles/developer-rust/defaults/main.yml +++ b/ansible/roles/developer-rust/defaults/main.yml @@ -155,8 +155,10 @@ rust_sccache_service_manage: true # shim, the slice and the config. # # MEMORY is BOUNDED, by rustbuild.slice: MemoryHigh throttles by reclaim and -# MemoryMax kills the build rather than the host. Both are percentages of the -# host's own RAM, so one number fits a laptop and a build box. +# MemoryMax kills the build rather than the host. Both are sized at converge +# from the host's own RAM (ansible_facts memtotal_mb), so one number fits a +# laptop and a build box and the desktop keeps the rest. A host that gains RAM +# needs a re-converge (--tags rust-governor) to pick it up. # # CPU is not bounded at all -- it is DEFERRED. Builds run at nice 19 under a # below-desktop CPUWeight, so they take every core while nobody else wants one @@ -184,14 +186,68 @@ rust_governor_enabled: true # REFUSE the build outright rather than fall back to compiling. rust_governor_no_incremental: false -# Percentages of the HOST's physical RAM (systemd resolves them all against -# RAM, the swap cap included), so the same numbers ship everywhere. High -# throttles and reclaims; Max kills the build -- the build dies, never the -# host. -rust_governor_memory_high_pct: 50 -rust_governor_memory_max_pct: 70 +# Share of the host's RAM the whole build slice may use. High throttles and +# reclaims, Max kills the build -- the build dies, never the host. Max is a +# third of RAM so the desktop and its sessions keep the other two thirds. +# Raise both on a host dedicated to builds (a remote build target), e.g. 60 and +# 50, in its host or group vars. +rust_governor_memory_high_pct: 27 +rust_governor_memory_max_pct: 33 + +# Smallest slice limits in MiB, so a small host can still build. Applied after +# the percentage, and High never exceeds Max. +rust_governor_memory_high_floor_mb: 3072 +rust_governor_memory_max_floor_mb: 4096 + +# Percentage of the host's RAM, resolved by systemd, so it needs no sizing here. rust_governor_swap_max_pct: 25 +# --------------------------------------------------------------------------- +# Agent sessions: agents.slice +# --------------------------------------------------------------------------- +# Every process a Claude Code session starts (shell commands, subagents, MCP +# servers, browsers) inherits the session's cgroup, so one cap on agents.slice +# bounds all of them. Builds get rustbuild.slice and the desktop keeps the +# rest, which means no agent can cause a host-wide OOM: an overrun kills the +# biggest process inside the slice and nothing outside it. The role writes only +# the slice. hyperi-ai moves each session into an agents-.scope under +# it when a session starts, and does nothing where the slice is missing. +agents_slice_enabled: true + +# The desktop's reserve: the larger of a share of RAM and a floor in MiB. The +# floor covers a small host, where a share alone leaves a browser and an editor +# too little. +agents_reserve_pct: 30 +agents_reserve_floor_mb: 9216 + +# MemoryMax is RAM minus the reserve minus rustbuild.slice's MemoryMax (the +# governor off means nothing to subtract). The floor in MiB stops a small host +# computing a slice too small to run one session. MemoryHigh is a share of Max. +agents_memory_max_floor_mb: 2048 +agents_memory_high_pct: 90 + +# Below the desktop's 100 so sessions yield to it under contention, and a +# process ceiling of this many tasks per vCPU so a runaway fork loop stops short +# of the host's pid limit. +agents_cpu_weight: 25 +agents_tasks_per_cpu: 1024 + +# systemd-oomd drop-in: kill inside agents.slice when memory pressure stays over +# the limit, so a thrashing slice is cut down before it stalls the desktop. Off +# by default because oomd must also watch the user manager, which is a per-host +# decision. The duration property exists in systemd 259 and may be unknown to +# an older systemd, which logs it and ignores it. +agents_oomd_enabled: false +agents_oomd_pressure_limit: "80%" +agents_oomd_pressure_duration: "20s" + +# Delegate cpuset and io to the user manager, so agents.slice can reserve cores +# and an I/O weight. System scope (/etc/systemd/system), so it needs sudo, and +# the user manager reads it only at its next start (re-login or reboot). Off by +# default for that reason. +agents_delegate_enabled: false +agents_delegate_controllers: "cpu cpuset io memory pids" + # Contention-only: the desktop wins under load, and an idle machine still gives # builds every core. 100 is the systemd default, so 30 is a bit under a third of # the desktop's share when both want the CPU at once. This, not the shim's nice diff --git a/ansible/roles/developer-rust/tasks/agents_slice.yml b/ansible/roles/developer-rust/tasks/agents_slice.yml new file mode 100644 index 00000000..3a730b70 --- /dev/null +++ b/ansible/roles/developer-rust/tasks/agents_slice.yml @@ -0,0 +1,116 @@ +--- +# agents.slice: the cgroup that bounds every Claude Code session on the host. +# Linux only. hyperi-ai attaches each session to it at SessionStart, so the role +# only writes the unit. `agents_slice_enabled: false` removes what it deployed. + +- name: Deploy the agents slice + when: + - ansible_facts['system'] == 'Linux' + - agents_slice_enabled | bool + block: + - name: Create the user systemd directory + ansible.builtin.file: + path: "{{ user_home }}/.config/systemd/user" + state: directory + owner: "{{ actual_user }}" + mode: '0755' + become: true + become_user: "{{ actual_user }}" + + - name: Install the agents slice + ansible.builtin.template: + src: agents.slice.j2 + dest: "{{ user_home }}/.config/systemd/user/agents.slice" + owner: "{{ actual_user }}" + mode: '0644' + become: true + become_user: "{{ actual_user }}" + register: developer_rust_agents_slice + + - name: Create the agents slice drop-in directory + ansible.builtin.file: + path: "{{ user_home }}/.config/systemd/user/agents.slice.d" + state: directory + owner: "{{ actual_user }}" + mode: '0755' + become: true + become_user: "{{ actual_user }}" + when: agents_oomd_enabled | bool + + - name: Install the oomd drop-in for the agents slice + ansible.builtin.template: + src: agents-oomd.conf.j2 + dest: "{{ user_home }}/.config/systemd/user/agents.slice.d/oomd.conf" + owner: "{{ actual_user }}" + mode: '0644' + become: true + become_user: "{{ actual_user }}" + register: developer_rust_agents_oomd + when: agents_oomd_enabled | bool + + - name: Remove the oomd drop-in for the agents slice + ansible.builtin.file: + path: "{{ user_home }}/.config/systemd/user/agents.slice.d/oomd.conf" + state: absent + become: true + become_user: "{{ actual_user }}" + register: developer_rust_agents_oomd_removed + when: not (agents_oomd_enabled | bool) + + - name: Reload the user manager so the agents slice is live + ansible.builtin.systemd: + daemon_reload: true + scope: user + become: true + become_user: "{{ actual_user }}" + environment: "{{ developer_rust_user_env }}" + when: + - not ansible_check_mode + - developer_rust_agents_slice is changed + or developer_rust_agents_oomd is changed + or developer_rust_agents_oomd_removed is changed + failed_when: false + + rescue: + - name: Record that the agents slice did not deploy + ansible.builtin.set_fact: + deploy_warnings: >- + {{ deploy_warnings | default([]) + + ['Agents slice: ' ~ (ansible_failed_task.name | default('unknown task')) + ~ ' -- ' ~ (ansible_failed_result.msg | default('no message'))] }} + +- name: Remove the agents slice (opt-out) + when: + - ansible_facts['system'] == 'Linux' + - not (agents_slice_enabled | bool) + block: + - name: Remove the agents slice and its drop-in + ansible.builtin.file: + path: "{{ user_home }}/.config/systemd/user/{{ item }}" + state: absent + loop: + - agents.slice + - agents.slice.d + become: true + become_user: "{{ actual_user }}" + register: developer_rust_agents_slice_removed + + - name: Reload the user manager after removing the agents slice + ansible.builtin.systemd: + daemon_reload: true + scope: user + become: true + become_user: "{{ actual_user }}" + environment: "{{ developer_rust_user_env }}" + when: + - not ansible_check_mode + - developer_rust_agents_slice_removed is changed + failed_when: false + + rescue: + - name: Record that the agents slice removal did not complete + ansible.builtin.set_fact: + deploy_warnings: >- + {{ deploy_warnings | default([]) + + ['Agents slice removal: ' ~ (ansible_failed_task.name | default('unknown task')) + ~ ' -- ' ~ (ansible_failed_result.msg | default('no message'))] }} diff --git a/ansible/roles/developer-rust/tasks/governor.yml b/ansible/roles/developer-rust/tasks/governor.yml index 5982369f..ee76e488 100644 --- a/ansible/roles/developer-rust/tasks/governor.yml +++ b/ansible/roles/developer-rust/tasks/governor.yml @@ -18,8 +18,9 @@ {{ developer_rust_retired_governor_vars | join(', ') }}: retired. Builds are no longer rationed -- there is no semaphore, no CPU reserve and no job count, so nothing reads these. Memory is bounded by rustbuild.slice - (rust_governor_memory_high_pct / _memory_max_pct) and CPU is deferred by - nice 19 plus rust_governor_cpu_weight. Delete the setting. + (rust_governor_memory_high_pct / _memory_max_pct, sized from the host's + RAM) and CPU is deferred by nice 19 plus rust_governor_cpu_weight. + Delete the setting. vars: developer_rust_retired_governor_vars: "{{ lookup('ansible.builtin.varnames', '^rust_governor_(serialize|slots|cpu_reserve_cores|build_allowance_gb|lock_wait_seconds|macos_qos)$', diff --git a/ansible/roles/developer-rust/tasks/main.yml b/ansible/roles/developer-rust/tasks/main.yml index 8484bde0..425880a8 100644 --- a/ansible/roles/developer-rust/tasks/main.yml +++ b/ansible/roles/developer-rust/tasks/main.yml @@ -23,6 +23,23 @@ tags: ['developer-rust', 'rust-governor'] tags: ['developer-rust', 'rust-governor'] +# The cgroup for agent sessions, sized in the same RAM budget as the governor's +# rustbuild.slice. Its own tag so it applies without a toolchain converge. +- name: Bound agent sessions + ansible.builtin.include_tasks: + file: agents_slice.yml + apply: + tags: ['developer-rust', 'agents-slice'] + tags: ['developer-rust', 'agents-slice'] + +# System scope and off by default, so it stays out of the slice's task file. +- name: Delegate controllers to the user manager + ansible.builtin.include_tasks: + file: user_delegate.yml + apply: + tags: ['developer-rust', 'agents-delegate'] + tags: ['developer-rust', 'agents-delegate'] + # A `--tags removals` run reaches the cargo-home sweep here, since rust.yml is # not selected by that tag. An install run sweeps from rust.yml instead. - name: Remove cargo-home strays (opt-in) diff --git a/ansible/roles/developer-rust/tasks/user_delegate.yml b/ansible/roles/developer-rust/tasks/user_delegate.yml new file mode 100644 index 00000000..4f1d63a2 --- /dev/null +++ b/ansible/roles/developer-rust/tasks/user_delegate.yml @@ -0,0 +1,55 @@ +--- +# System-scope drop-in on user@.service, kept apart from agents_slice.yml +# because it is the one change here made under /etc. Off unless +# `agents_delegate_enabled: true`, and turning it off removes the file. The user +# manager reads it at its next start, so a converge does not apply it live and +# never restarts the manager (that would end the desktop session). + +- name: Delegate more controllers to the user manager + when: + - ansible_facts['system'] == 'Linux' + - agents_delegate_enabled | bool + block: + - name: Create the user@.service drop-in directory + ansible.builtin.file: + path: /etc/systemd/system/user@.service.d + state: directory + owner: root + mode: '0755' + become: true + + - name: Install the controller delegation drop-in + ansible.builtin.template: + src: user-delegate.conf.j2 + dest: /etc/systemd/system/user@.service.d/hyperi-delegate.conf + owner: root + mode: '0644' + become: true + register: developer_rust_user_delegate + + - name: Say the delegation waits for the next login # noqa: no-handler + ansible.builtin.set_fact: + deploy_warnings: >- + {{ deploy_warnings | default([]) + + ['Agents slice: user@.service now delegates ' + ~ agents_delegate_controllers + ~ '. The user manager reads it at its next start, so log out' + ~ ' and in (or reboot) for it to apply.'] }} + when: developer_rust_user_delegate is changed + + rescue: + - name: Record that the delegation drop-in did not deploy + ansible.builtin.set_fact: + deploy_warnings: >- + {{ deploy_warnings | default([]) + + ['Agents slice delegation: ' ~ (ansible_failed_task.name | default('unknown task')) + ~ ' -- ' ~ (ansible_failed_result.msg | default('no message'))] }} + +- name: Remove the controller delegation drop-in + ansible.builtin.file: + path: /etc/systemd/system/user@.service.d/hyperi-delegate.conf + state: absent + become: true + when: + - ansible_facts['system'] == 'Linux' + - not (agents_delegate_enabled | bool) diff --git a/ansible/roles/developer-rust/templates/agents-oomd.conf.j2 b/ansible/roles/developer-rust/templates/agents-oomd.conf.j2 new file mode 100644 index 00000000..52e3cf1d --- /dev/null +++ b/ansible/roles/developer-rust/templates/agents-oomd.conf.j2 @@ -0,0 +1,7 @@ +# Deployed by hyperi-developer (developer-rust role). Edits are overwritten. +# Lets systemd-oomd kill inside agents.slice when its memory pressure stays over +# the limit for the duration, before a thrashing slice stalls the desktop. +[Slice] +ManagedOOMMemoryPressure=kill +ManagedOOMMemoryPressureLimit={{ agents_oomd_pressure_limit }} +ManagedOOMMemoryPressureDurationSec={{ agents_oomd_pressure_duration }} diff --git a/ansible/roles/developer-rust/templates/agents.slice.j2 b/ansible/roles/developer-rust/templates/agents.slice.j2 new file mode 100644 index 00000000..b57068af --- /dev/null +++ b/ansible/roles/developer-rust/templates/agents.slice.j2 @@ -0,0 +1,17 @@ +# Deployed by hyperi-developer (developer-rust role). Edits are overwritten. +[Unit] +Description=Agent sessions (hyperi-developer) + +# No dash in the name, so this is a direct child of the user manager and a true +# sibling of app.slice, where the desktop runs. See rustbuild.slice.j2. +[Slice] +# Sized from this host's RAM at converge: RAM minus the desktop's reserve minus +# the rust build slice's MemoryMax, so the three can never add up to more than +# the host has. An overrun is an OOM inside this cgroup, which kills its biggest +# process and leaves the desktop alone. +MemoryHigh={{ developer_rust_agents_high_mb }}M +MemoryMax={{ developer_rust_agents_max_mb }}M +# Yields to the desktop under contention and takes every idle core otherwise. +CPUWeight={{ agents_cpu_weight }} +# One ceiling for every session together, a fork-bomb stop rather than a budget. +TasksMax={{ developer_rust_agents_tasks_max }} diff --git a/ansible/roles/developer-rust/templates/rustbuild.slice.j2 b/ansible/roles/developer-rust/templates/rustbuild.slice.j2 index 6c92c076..31df1161 100644 --- a/ansible/roles/developer-rust/templates/rustbuild.slice.j2 +++ b/ansible/roles/developer-rust/templates/rustbuild.slice.j2 @@ -8,18 +8,19 @@ Description=Governed Rust builds (hyperi-developer) # not the 30 below, that faces app.slice. Dash-free, this slice is a direct # child of the user manager and a true sibling of the desktop's slices. [Slice] -# Percentages resolve against this host's own RAM and swap, so one unit fits +# Memory limits are sized from this host's RAM at converge, so one role fits # every machine. Both capture prongs land here: cargo trees via the # hyperi-rust-govern scope, sccache-hosted compiles via Slice= on # hyperi-sccache.service. This is the ONLY memory bound on a build: nothing # queues builds or caps their job count, so a build over budget is throttled # here and, at MemoryMax, killed here -- the build, never the host. # -# MemorySwapMax only means anything where swap exists. On a swapless host -# MemoryHigh has nothing but page cache to reclaim, so it stalls a build -# instead of slowing it -- the zram_swap role is what makes this budget behave. -MemoryHigh={{ rust_governor_memory_high_pct }}% -MemoryMax={{ rust_governor_memory_max_pct }}% +# MemorySwapMax is a percentage systemd resolves against RAM, and only means +# anything where swap exists. On a swapless host MemoryHigh has nothing but +# page cache to reclaim, so it stalls a build instead of slowing it -- the +# zram_swap role is what makes this budget behave. +MemoryHigh={{ developer_rust_slice_high_mb }}M +MemoryMax={{ developer_rust_slice_max_mb }}M MemorySwapMax={{ rust_governor_swap_max_pct }}% # Yields to the desktop under contention; no effect while the machine is idle. # This, not nice, is what makes a build defer on Linux: cgroup v2 splits CPU diff --git a/ansible/roles/developer-rust/templates/user-delegate.conf.j2 b/ansible/roles/developer-rust/templates/user-delegate.conf.j2 new file mode 100644 index 00000000..12472941 --- /dev/null +++ b/ansible/roles/developer-rust/templates/user-delegate.conf.j2 @@ -0,0 +1,6 @@ +# Deployed by hyperi-developer (developer-rust role). Edits are overwritten. +# The user manager only delegates cpu, memory and pids by default. cpuset and io +# let agents.slice reserve cores and an I/O weight. Read at the user manager's +# next start. +[Service] +Delegate={{ agents_delegate_controllers }} diff --git a/ansible/roles/developer-rust/vars/main.yml b/ansible/roles/developer-rust/vars/main.yml new file mode 100644 index 00000000..ee900252 --- /dev/null +++ b/ansible/roles/developer-rust/vars/main.yml @@ -0,0 +1,29 @@ +--- +# Limits worked out from this host's RAM and CPUs. Role vars, not defaults, +# because they are derived: set the inputs in defaults/main.yml instead. +# All sizes are MiB, the unit memtotal_mb reports. + +developer_rust_ram_mb: "{{ ansible_facts['memtotal_mb'] | int }}" + +# rustbuild.slice. High never exceeds Max. +developer_rust_slice_max_mb: >- + {{ [developer_rust_ram_mb | int * rust_governor_memory_max_pct | int // 100, + rust_governor_memory_max_floor_mb | int] | max }} +developer_rust_slice_high_mb: >- + {{ [[developer_rust_ram_mb | int * rust_governor_memory_high_pct | int // 100, + rust_governor_memory_high_floor_mb | int] | max, + developer_rust_slice_max_mb | int] | min }} + +# What the desktop keeps, and what is left for agent sessions once builds have +# their share. A host without the governor gives builds no share to reserve. +developer_rust_agents_reserve_mb: >- + {{ [developer_rust_ram_mb | int * agents_reserve_pct | int // 100, + agents_reserve_floor_mb | int] | max }} +developer_rust_agents_max_mb: >- + {{ [developer_rust_ram_mb | int - developer_rust_agents_reserve_mb | int + - (developer_rust_slice_max_mb | int if rust_governor_enabled | bool else 0), + agents_memory_max_floor_mb | int] | max }} +developer_rust_agents_high_mb: >- + {{ developer_rust_agents_max_mb | int * agents_memory_high_pct | int // 100 }} +developer_rust_agents_tasks_max: >- + {{ agents_tasks_per_cpu | int * ansible_facts['processor_vcpus'] | int }} diff --git a/ansible/roles/zram_swap/README.md b/ansible/roles/zram_swap/README.md index f793be5a..92978c85 100644 --- a/ansible/roles/zram_swap/README.md +++ b/ansible/roles/zram_swap/README.md @@ -23,7 +23,7 @@ It is somewhere for the throttle to push, which is why a few GB is the right size and a disk-backed swap file is not a substitute. This matters most on a host running `developer-rust`'s build governor, whose -`rustbuild.slice` sets `MemoryHigh` and `MemorySwapMax` as percentages of RAM. +`rustbuild.slice` sets `MemoryHigh` from the host's RAM and `MemorySwapMax` as a percentage of it. Without swap, those limits do not degrade a build -- they wedge it. ## What it touches diff --git a/docs/rust-build-governor.md b/docs/rust-build-governor.md index a9674ea9..3638f403 100644 --- a/docs/rust-build-governor.md +++ b/docs/rust-build-governor.md @@ -11,11 +11,15 @@ through `rustup run`, is ungoverned unless wrapped in `hyperi-rust-govern`. Two instruments, answering two different questions. -**Memory is bounded by the slice.** `MemoryHigh` (50% of the host's RAM) -throttles a build by reclaim, `MemoryMax` (70%) kills it, and `MemorySwapMax` -(25%) caps what it may push to swap. Percentages, so one unit fits a laptop and -a build box, and the build dies before the host does. sccache-hosted compiles -sit inside the same budget because `hyperi-sccache.service` carries +**Memory is bounded by the slice.** `MemoryHigh` (27% of the host's RAM) +throttles a build by reclaim, `MemoryMax` (33%) kills it, and `MemorySwapMax` +(25%) caps what it may push to swap. The role works the two limits out from the +host's RAM at converge, with floors of 3 GiB and 4 GiB so a small host can still +build, so one role fits a laptop and a build box and the build dies before the +host does. A 30 GB host gets about 8 GB and 10 GB, a 92 GB host about 25 GB and +30 GB. A host dedicated to builds raises `rust_governor_memory_high_pct` and +`rust_governor_memory_max_pct` in its own host or group vars. sccache-hosted +compiles sit inside the same budget because `hyperi-sccache.service` carries `Slice=rustbuild.slice`. **CPU is not bounded at all -- it is deferred.** Nothing withholds cores and @@ -104,8 +108,8 @@ The role writes `~/.config/hyperi/rust-governor.conf`, which the shim sources first. The value in it is written as `${VAR:-default}` on purpose, so an export in the shell beats the deployed file for one invocation and the shim can be tested against a real config. Nothing per-machine is needed: the memory -percentages resolve against the host's own RAM, and there is no core count to -tune. +limits are worked out from the host's own RAM (re-converge with +`--tags rust-governor` after a RAM change), and there is no core count to tune. Five role variables went with the rationing: `rust_governor_slots`, `rust_governor_cpu_reserve_cores`, `rust_governor_build_allowance_gb`, @@ -116,12 +120,14 @@ would otherwise silently get as many as it starts. Delete the setting. ## What it needs -**Enough RAM for the biggest crate.** `MemoryMax` is 70% of the host's RAM, so -a 16 GB laptop gives the whole slice 11.2 GB -- and one `rustc` on a large +**Enough RAM for the biggest crate.** `MemoryMax` is 33% of the host's RAM, so +a 16 GB laptop gives the whole slice 5.3 GB -- and one `rustc` on a large crate has been measured at 11.6 GB. That build is killed, and cargo reports the rustc process dying on signal 9 rather than an out-of-memory error. The levers -are bypassing the governor for that one build, or building it on a box with -more RAM. +are bypassing the governor for that one build, building it on a box with more +RAM, or raising `rust_governor_memory_max_pct` on a host that can spare it. +A third leaves the desktop and its sessions the rest: a 70% slice on a 30 GB +host let one 16 GB clippy run take the whole machine down. **Swap.** `MemoryHigh` throttles by reclaim, and on a swapless host the only reclaimable memory is page cache -- so a build past the line stalls rather than @@ -142,3 +148,33 @@ incremental state to lose. `HYPERI_RUST_GOVERN_NO_INCREMENTAL=1` turns it on for one invocation where the role left it off, and `=0` opts out where the role turned it on. Neither is `CARGO_INCREMENTAL=1`, which makes sccache refuse the build outright. + +## The agents slice + +The same RAM budget also bounds Claude Code sessions. Everything a session +starts (shell commands, subagents, MCP servers, browsers) inherits its cgroup, +so one cap on `agents.slice` covers all of it. The role writes +`~/.config/systemd/user/agents.slice` and hyperi-ai moves each session into an +`agents-.scope` under it at SessionStart, doing nothing on a host +without the slice. + +The three budgets add up to the host's RAM, so no agent or build can cause a +host-wide OOM: + +- Desktop reserve: the larger of 30% of RAM and 9 GiB, left unlimited. +- `rustbuild.slice`: `MemoryMax` 33%, `MemoryHigh` 27%, as above. +- `agents.slice`: `MemoryMax` is RAM minus the reserve minus the rust `MemoryMax` + (floor 2 GiB), `MemoryHigh` 90% of that, `CPUWeight` 25, `TasksMax` 1024 per + vCPU. + +A 30 GB host (12 vCPU) gets a 9.1 GiB reserve, rust 10 and 8 GiB, agents 11.3 +and 10.1 GiB. A 92 GB host (32 vCPU) gets 27.6 GiB, rust 30.4 and 24.8 GiB, +agents 34.0 and 30.6 GiB. Every input is a role default (`agents_*`), so a host +var overrides it, and `agents_slice_enabled: false` removes the slice. + +Two optional pieces stay off by default. `agents_oomd_enabled` adds a +systemd-oomd drop-in on `agents.slice` that kills at 80% memory pressure held +for 20 s, and works only where oomd watches the user manager. +`agents_delegate_enabled` writes `/etc/systemd/system/user@.service.d/` so the +user manager delegates `cpuset` and `io` as well. That one is system scope, +needs sudo, and applies at the next login.