# `checks.module-eval-agent-memory` — see ./lib.nix for the shared # rationale (why this suite exists, naming convention, "evaluates # not executes"). { pkgs, lib, self, nixosSystem, }: let inherit (import ./lib.nix { inherit pkgs lib self nixosSystem ; }) agent runGroup agentHarness ; # The memory-pressure pair. `claudeMemoryMaxBytes` is the container's own # cap, rendered per agent by meta.rs — the capped arm is the one every # real deploy gets, the uncapped arm is a hive that set `infinity` or a # RAM percentage and so hands the module no byte count to size against. agentCapped = agent { claudeMemoryMaxBytes = 8589934592; }; agentUncapped = agent { }; agentSubagentDaemon = machine: machine.systemd.services.hive-subagent-daemon; cases = [ { # Two thirds of the container's cap, and a SOFT ceiling: the daemon's # cgroup holds every nested claude, so `MemoryHigh=` throttles the # subagent set as a whole before the kernel picks a victim, while the # absent `MemoryMax=` is what still lets one subagent use more than # its share on a container that has the memory free. A hard cap here # would trade the silent kill for a guaranteed wall, which is the # shape this deliberately does not have. name = "the subagent daemon throttles at two thirds of the container's memory"; ok = let c = (agentSubagentDaemon agentCapped).serviceConfig; in c.MemoryHigh == "5726623061" && !(c ? MemoryMax); } { # What makes the case above able to fail. With no byte count for the # container there is no fraction to take, and a hardcoded fallback # would be a number about some other hive's machine — so the unit # renders no ceiling at all rather than a fabricated one. name = "an agent with no byte-valued memory cap renders no subagent ceiling"; ok = !((agentSubagentDaemon agentUncapped).serviceConfig ? MemoryHigh); } { # The sign is the whole property, and it is easy to write backwards: # a HIGHER OOMScoreAdjust is a MORE likely victim. So the subagent # daemon must be strictly above zero and the harness strictly below # it — swap the two and the kernel takes the agent's own turn first, # which is worse than setting nothing at all. Both nested `claude` # processes inherit their unit's value, so ordering the units orders # the sessions. Held as an inequality rather than two constants: what # must not drift is the order, not the magnitudes. name = "the OOM killer prefers a subagent over the agent's own session"; ok = let sub = (agentSubagentDaemon agentUncapped).serviceConfig.OOMScoreAdjust; own = (agentHarness agentUncapped).serviceConfig.OOMScoreAdjust; in sub > 0 && own < 0 && sub > own; } { # The pair the case above only makes sense with: preferring this unit # as the victim is an improvement only if losing one subagent isn't # losing all of them. systemd's default `stop` would take the daemon # and every sibling session down with whichever process the kernel # chose, which is the blast radius that made the preference a bad # trade in the first place. name = "one subagent losing the OOM draw does not stop the daemon"; ok = (agentSubagentDaemon agentUncapped).serviceConfig.OOMPolicy == "continue"; } ]; in runGroup "agent-memory" cases