When hive-c0re can't read resource-limits.json and a limits drop-in already exists, it keeps the drop-in, but it wrote no memory cap file. A container whose runtime dir had no file yet (hive-c0re upgraded, /run not cleared) then started with no heap ceiling and no subagent MemoryHigh=, silently. `stage_limits` now writes the file on every call: from the new limits, or from the last `MemoryMax=` in the kept drop-in (`infinity` when it has none or can't be read). A missing or malformed cap file at boot is no longer silent. The generators run before the container's journal and an nspawn container has no /dev/kmsg, so a oneshot `hyperhive-memory-cap-check.service` logs a warning naming the path. The agent then runs with no JSC heap ceiling and no subagent MemoryHigh=; the container's MemoryMax= still applies. module-eval-agent-memory gains a case pinning how the ceiling reaches claude: `hive-agent` and `hive-subagent-daemon` neither set nor unset BUN_JSC_forceRAMSize, both generators and the check unit are installed. Part of #5081 (slice S0-2).
92 lines
3.8 KiB
Nix
92 lines
3.8 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
|
|
;
|
|
|
|
agentDefault = agent { };
|
|
|
|
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
|