Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/module-eval/agent-memory.nix
atlas 0d619e215e agents: read the memory cap at boot, not from the build
An agent's memory cap was rendered into its flake (`memoryMaxBytes` →
`services.hyperhive.agent.claudeMemoryMaxBytes`), so the closure depended
on it and a changed cap reached the JSC heap ceiling and the subagent
`MemoryHigh=` only after a rebuild.

hive-c0re now writes the effective `MemoryMax=` in bytes to the agent's
runtime dir (`/run/hive/memory-max-bytes` inside) in `write_dropins`,
from the same value the drop-in gets. The file is removed for
`infinity` or a RAM percentage, and left alone when the drop-in is kept.

Inside the container, `nix/agent-modules/memory-cap.nix` reads it at
boot: an environment generator sets `BUN_JSC_forceRAMSize` (75%) for
every unit and `machinectl shell`, `/etc/set-environment` exports it in
login shells, and a unit generator writes `MemoryHigh=` (2/3) as a
drop-in for `hive-subagent-daemon`.

The render drops `memoryMaxBytes`, the agent option is removed (both
spellings fail eval with a removal message), and
`effective_memory_bytes_from` goes with its only caller.

Part of #5081 (slice S0-2).
2026-10-12 10:59:11 +02:00

72 lines
2.7 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);
}
{
# 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