Watch
0
0
Fork
You've already forked hyperhive
0

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).
This commit is contained in:
atlas 2026-10-12 09:00:45 +02:00
commit 0d619e215e
15 changed files with 416 additions and 287 deletions

View file

@ -1,6 +1,7 @@
//! Per-container host-side config: the nspawn conf rewrite (bind mounts,
//! network isolation, forwarded credentials), the systemd resource-limits
//! drop-in, and the `write_dropins` verb that re-applies both.
//! drop-in, the agent's copy of its memory cap, and the `write_dropins` verb
//! that re-applies them.
use std::path::{Path, PathBuf};
@ -16,20 +17,21 @@ use super::{
};
/// Re-apply the per-container host-side config: nspawn flags (bind
/// mounts etc.), the systemd resource-limits drop-in, and a daemon
/// reload so both take effect on the next unit (re)start. Idempotent —
/// the job queue's `WriteDropin` node, also folded into every `Swap`
/// (rebuild is the reconcile verb).
/// mounts etc.), the systemd resource-limits drop-in, the agent's copy of
/// its memory cap, and a daemon reload so all of it takes effect on the next
/// unit (re)start. Idempotent — the job queue's `WriteDropin` node, also
/// folded into every `Swap` (rebuild is the reconcile verb).
pub async fn write_dropins(name: &str, hive: &HiveEnv, paths: &AgentPaths) -> Result<()> {
validate(name)?;
let container = container_name(name);
set_nspawn_flags(&container, &paths.agent, &paths.claude, &paths.notes).await?;
if let Some((cpu_quota, memory_max)) = limits_to_apply(
if let Some((cpu_quota, memory_max)) = stage_limits(
name,
&crate::resource_limits::resource_limits_path(),
&limits_dropin_path(&container),
&paths.agent,
hive,
) {
)? {
set_resource_limits(
&container,
&cpu_quota,
@ -51,6 +53,60 @@ fn limits_dropin_path(container: &str) -> PathBuf {
))
}
/// File in the agent's runtime dir, `/run/hive/` in the container, holding the
/// byte count of the `MemoryMax=` its drop-in carries.
/// `nix/agent-modules/memory-cap.nix` reads it at boot under this name.
const MEMORY_CAP_FILE: &str = "memory-max-bytes";
/// [`limits_to_apply`], plus handing the memory cap to the agent through
/// [`MEMORY_CAP_FILE`] in `runtime_dir`. The container cannot read the cap
/// systemd enforces on it, so this file is how it learns the value it sizes
/// its heap and its subagents against. When the drop-in is kept as it is, so is
/// the file.
fn stage_limits(
agent_name: &str,
limits_path: &Path,
dropin_path: &Path,
runtime_dir: &Path,
hive: &HiveEnv,
) -> Result<Option<(String, String)>> {
let limits = limits_to_apply(agent_name, limits_path, dropin_path, hive);
if let Some((_, memory_max)) = &limits {
write_memory_cap(runtime_dir, agent_name, memory_max)?;
}
Ok(limits)
}
/// Write `memory_max` into [`MEMORY_CAP_FILE`] as a byte count, or remove the
/// file when the value has none: `infinity`, or a RAM percentage, which needs
/// the host's `MemTotal` to resolve. With no file the agent sets no heap or
/// subagent ceiling.
fn write_memory_cap(runtime_dir: &Path, agent_name: &str, memory_max: &str) -> Result<()> {
let path = runtime_dir.join(MEMORY_CAP_FILE);
let Some(bytes) = crate::resource_limits::parse_bytes(memory_max) else {
if memory_max.ends_with('%') {
tracing::warn!(
agent = agent_name,
memory_max,
"effective MemoryMax= is a RAM percentage; the agent gets no byte count, so \
no JSC heap ceiling and no subagent MemoryHigh="
);
}
return match std::fs::remove_file(&path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
Err(e).with_context(|| format!("remove {}", path.display()))
}
_ => Ok(()),
};
};
std::fs::create_dir_all(runtime_dir)
.with_context(|| format!("create agent runtime dir {}", runtime_dir.display()))?;
let tmp = runtime_dir.join(format!(".{MEMORY_CAP_FILE}.tmp"));
std::fs::write(&tmp, format!("{bytes}\n"))
.with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, &path).with_context(|| format!("rename onto {}", path.display()))
}
/// The `(CPUQuota, MemoryMax)` to write into `agent_name`'s limits
/// drop-in, or `None` to leave the drop-in at `dropin_path` as it is.
///
@ -489,8 +545,9 @@ async fn set_nspawn_flags(
#[cfg(test)]
mod tests {
use super::{
BindMount, QUEUE_CLIENT_ID_CREDENTIAL, QUEUE_SECRET_CREDENTIAL, bind_child_agent_dirs,
bind_own_agent_dirs, holds_manage_root_agent, limits_to_apply, queue_agent_credentials,
BindMount, MEMORY_CAP_FILE, QUEUE_CLIENT_ID_CREDENTIAL, QUEUE_SECRET_CREDENTIAL,
bind_child_agent_dirs, bind_own_agent_dirs, holds_manage_root_agent, limits_to_apply,
queue_agent_credentials, stage_limits,
};
use crate::coordinator::HiveEnv;
@ -707,4 +764,66 @@ mod tests {
let applied = limits_to_apply("sock", &limits, &dropin, &HiveEnv::default());
assert_eq!(applied, Some(("200%".to_owned(), "1G".to_owned())));
}
/// Runs [`stage_limits`] against the files in `dir` and returns the
/// drop-in's `MemoryMax=` and the agent's copy of it, `None` when absent.
fn stage(dir: &tempfile::TempDir) -> (Option<String>, Option<String>) {
let runtime = dir.path().join("runtime");
let applied = stage_limits(
"sock",
&dir.path().join("resource-limits.json"),
&dir.path().join("hyperhive-limits.conf"),
&runtime,
&HiveEnv::default(),
)
.expect("stage limits");
let copy = std::fs::read_to_string(runtime.join(MEMORY_CAP_FILE)).ok();
(applied.map(|(_, mem)| mem), copy)
}
#[test]
fn the_agent_reads_the_cap_its_dropin_carries() {
let dir = tempfile::tempdir().expect("tempdir");
let limits = dir.path().join("resource-limits.json");
// No overrides anywhere: the hive-wide default, 4G.
assert_eq!(
stage(&dir),
(Some("4G".to_owned()), Some("4294967296\n".to_owned()))
);
std::fs::write(&limits, r#"{"sock": {"memory_max": "16G"}}"#).expect("seed limits");
assert_eq!(
stage(&dir),
(Some("16G".to_owned()), Some("17179869184\n".to_owned()))
);
}
#[test]
fn a_cap_with_no_byte_count_removes_the_agents_copy() {
let dir = tempfile::tempdir().expect("tempdir");
let limits = dir.path().join("resource-limits.json");
assert_eq!(stage(&dir).1, Some("4294967296\n".to_owned()));
std::fs::write(&limits, r#"{"sock": {"memory_max": "infinity"}}"#).expect("seed limits");
assert_eq!(stage(&dir), (Some("infinity".to_owned()), None));
assert_eq!(stage(&dir).1, None, "an absent copy stays absent");
std::fs::write(&limits, r#"{"sock": {"memory_max": "50%"}}"#).expect("seed limits");
assert_eq!(stage(&dir), (Some("50%".to_owned()), None));
}
#[test]
fn a_kept_dropin_keeps_the_agents_copy() {
let dir = tempfile::tempdir().expect("tempdir");
let limits = dir.path().join("resource-limits.json");
std::fs::write(&limits, r#"{"sock": {"memory_max": "1G"}}"#).expect("seed limits");
assert_eq!(stage(&dir).1, Some("1073741824\n".to_owned()));
std::fs::write(&limits, TRUNCATED_LIMITS).expect("seed limits");
std::fs::write(dir.path().join("hyperhive-limits.conf"), "MemoryMax=1G\n")
.expect("seed drop-in");
assert_eq!(stage(&dir), (None, Some("1073741824\n".to_owned())));
}
}