agent: make the OOM killer prefer a subagent over the agent's own turn

Both units ran at OOMScoreAdjust=0, so under container memory pressure the
kernel picked purely on footprint — and the agent's own claude is often
the fattest process in the container, which means the session supervising
the work died before the work did.

The sign is the load-bearing part and is easy to invert: a HIGHER
OOMScoreAdjust means MORE likely to be killed, because the kernel adds it
to the badness score it derives from the process's memory footprint and
then kills the highest scorer. So hive-subagent-daemon gets +500 (first in
line) and hive-agent gets -500 (last in line). Written backwards this
makes the reported bug worse rather than better, so module-eval pins the
order as an inequality.

Both values are inherited by the nested claude each unit spawns as a
child, so ordering the units orders the sessions underneath them. -500
rather than -1000 on the harness: fully exempting it would leave the
kernel nothing to kill in a container whose only large process is the
harness.

Refs #4316
This commit is contained in:
atlas 2026-09-13 13:04:45 +02:00
commit d249468db2
3 changed files with 39 additions and 0 deletions

View file

@ -303,6 +303,16 @@ in
SyslogIdentifier = binary;
Restart = "on-failure";
RestartSec = 2;
# A LOWER OOMScoreAdjust means LESS likely to be killed, so this
# negative value puts the harness — and the agent's own `claude`,
# which it spawns as a child and which therefore inherits the
# value — last in line under memory pressure, behind
# `hive-subagent-daemon`'s `+500` (see ./mcp.nix). Losing a
# subagent costs one restartable task; losing this costs the
# session that was supervising it. Not `-1000`, which would
# exempt the harness entirely and leave the kernel with nothing
# to kill in a container whose only large process is this one.
OOMScoreAdjust = -500;
# Per-service runtime dir owned by `User=` below; the harness
# writes its regenerated claude-{mcp-config,settings,system-prompt}
# files here (`paths::config_dir`). Separate from /run/hive,

View file

@ -363,6 +363,18 @@ in
# `start`/`continue`/`status`/`interrupt` with no stdio fallback.
Restart = "always";
RestartSec = 3;
# A HIGHER OOMScoreAdjust means MORE likely to be killed: the
# kernel adds it to the badness score it derives from the
# process's memory footprint, then kills the highest scorer. So
# the positive value here and the negative one on `hive-agent`
# put this unit first in line — a subagent turn can be retried,
# the agent's own turn is the thing everything else hangs off.
# `+500` against `hive-agent`'s `-500` is a full half of the
# score range apart, enough that a fat subagent outranks a fatter
# harness rather than merely tying with it. Every nested `claude`
# inherits the value from the daemon, so the ordering covers the
# whole subtree and not just this process.
OOMScoreAdjust = 500;
User = userName;
Group = userName;
}

View file

@ -1385,6 +1385,23 @@ let
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 doctrine three glue files state, as a property a rewrite has to
# keep: a client is defined by holding a certificate the store accepts,