The single module-eval derivation forced ~62 full nixosSystem fixtures live at once to compute its cases list: 10.6GB peak RSS / 5m25s to evaluate, by far the dominant cost in nix flake check. Splits it into 21 independent checks.module-eval-* derivations (1-7 fixtures each) sharing builders/helpers via module-eval/lib.nix, so no single derivation needs more than a handful of fixtures live at once. A few cases spanning two clusters carry a small duplicated fixture rather than threading shared state through lib.nix.
87 lines
3.5 KiB
Nix
87 lines
3.5 KiB
Nix
# `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
|