# `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, shared, }: let inherit (import ./lib.nix { inherit pkgs lib self nixosSystem ; }) runGroup agentHarness ; agentDefault = shared.agentBare; agentBashDaemon = machine: machine.systemd.services.hive-bash-daemon; agentSubagentDaemon = machine: machine.systemd.services.hive-subagent-daemon; cases = [ { # The ceiling is sized at boot from the container's cap # (../agent-modules/memory-cap.nix). One in the unit would be the cap at # the last rebuild, and every agent would carry its own hive's number. name = "the subagent daemon's memory ceiling is not built into its unit"; ok = !((agentSubagentDaemon agentDefault).serviceConfig ? MemoryHigh); } { # claude's heap ceiling reaches it only as inherited environment: the # environment generator fills the manager's, systemd hands that to every # unit, and both units spawn claude as a plain child. A unit that sets # or unsets the variable itself cuts that. Without the two generators the # cap reaches nothing, and without the check unit nothing says so. name = "claude's units take the heap ceiling from the memory cap read at boot"; ok = let leavesIt = unit: !(unit.environment ? BUN_JSC_forceRAMSize) && !(lib.elem "BUN_JSC_forceRAMSize" (lib.toList (unit.serviceConfig.UnsetEnvironment or [ ]))); in leavesIt (agentHarness agentDefault) && leavesIt (agentSubagentDaemon agentDefault) && agentDefault.environment.etc ? "systemd/system-environment-generators/50-hyperhive-memory-cap" && agentDefault.systemd.generators ? hyperhive-memory-cap && lib.elem "multi-user.target" agentDefault.systemd.services.hyperhive-memory-cap-check.wantedBy; } { # 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 agentDefault).serviceConfig.OOMScoreAdjust; own = (agentHarness agentDefault).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 agentDefault).serviceConfig.OOMPolicy == "continue"; } { # Each bash task is a process in the daemon's cgroup; under `stop` one # OOM-killed task would end every running sibling task. name = "one bash task losing the OOM draw does not stop the daemon"; ok = (agentBashDaemon agentDefault).serviceConfig.OOMPolicy or null == "continue"; } ]; in runGroup "agent-memory" cases