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

@ -88,17 +88,7 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
let dir = crate::paths::meta_root();
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let new_flake = render_flake(
&hive.hyperhive_flake,
&hive.hyperhive_docs_flake,
&hive.nixpkgs_flake,
hive.claude_code_path.as_deref(),
hive.dashboard_port,
&hive.operator_pronouns,
&hive.context_window_tokens,
&hive.agent_memory_max,
agents,
)?;
let new_flake = render_hive_flake(hive, agents)?;
let flake_path = dir.join("flake.nix");
let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default();
let initial = !dir.join(".git").exists();
@ -444,6 +434,21 @@ pub async fn commit_perms(
Ok(())
}
/// [`render_flake`] with its inputs taken from `hive`, as [`sync_agents`]
/// renders it.
fn render_hive_flake(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<String> {
render_flake(
&hive.hyperhive_flake,
&hive.hyperhive_docs_flake,
&hive.nixpkgs_flake,
hive.claude_code_path.as_deref(),
hive.dashboard_port,
&hive.operator_pronouns,
&hive.context_window_tokens,
agents,
)
}
#[allow(
clippy::too_many_arguments,
reason = "many genuine flake inputs (source flakes, port, pronouns, tokens, \
@ -457,7 +462,6 @@ fn render_flake(
dashboard_port: u16,
operator_pronouns: &str,
context_window_tokens: &std::collections::HashMap<String, u64>,
hive_memory_max: &str,
agents: &[AgentSpec],
) -> Result<String> {
render_flake_with_lookup(
@ -468,7 +472,6 @@ fn render_flake(
dashboard_port,
operator_pronouns,
context_window_tokens,
hive_memory_max,
agents,
agent_canonical_inputs,
)
@ -861,7 +864,6 @@ fn render_flake_with_lookup<F>(
dashboard_port: u16,
operator_pronouns: &str,
context_window_tokens: &std::collections::HashMap<String, u64>,
hive_memory_max: &str,
agents: &[AgentSpec],
lookup: F,
) -> Result<String>
@ -943,7 +945,7 @@ where
let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(
out,
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, toolGroups ? null, capabilities ? null, memoryMaxBytes ? null }}:"
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, toolGroups ? null, capabilities ? null }}:"
);
out.push_str(
r#" let
@ -1094,7 +1096,6 @@ where
# nspawn itself already call this container, so logs join to
# both without a prefix transform.
networking.hostName = "h-${name}";
services.hyperhive.agent.claudeMemoryMaxBytes = memoryMaxBytes;
programs.git.config.user = {
name = name;
email = "${name}@hyperhive.local";
@ -1177,12 +1178,9 @@ where
);
// Propagated, never read as empty: an agent with no tool-groups entry
// renders `toolGroups = null` and gets `AGENT_DEFAULT`, which fails
// open for any agent whose explicit entry is narrower. Likewise an
// agent with no resource-limits entry gets the hive `memoryMaxBytes`,
// a larger heap ceiling than a tighter override.
// open for any agent whose explicit entry is narrower.
let tool_groups_map = crate::tool_groups::read()?;
let capabilities_map = crate::capabilities::read()?;
let resource_limits_map = crate::resource_limits::read()?;
for spec in agents {
// Emit `toolGroups = "group1,group2"` when the operator has
// explicitly configured groups for this agent. Absent entry = null
@ -1208,29 +1206,15 @@ where
let joined = caps.join(",");
format!("\"{joined}\"")
};
// Effective `MemoryMax=` for this agent (per-agent override, else
// the hive-wide default), turned into a raw byte count so
// the agent's claude-settings module can derive a JSC heap ceiling
// (see `services.hyperhive.agent.claudeMemoryMaxBytes`). `null` when the
// effective value is `"infinity"` or a RAM percentage — no
// byte count to derive, dependent env var stays unset, same as
// today's no-cap behavior.
let memory_max_attr = crate::resource_limits::effective_memory_bytes_from(
&resource_limits_map,
&spec.name,
hive_memory_max,
)
.map_or_else(|| "null".to_owned(), |b| b.to_string());
let _ = writeln!(
out,
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; toolGroups = {}; capabilities = {}; memoryMaxBytes = {}; }};",
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; toolGroups = {}; capabilities = {}; }};",
spec.name,
spec.name,
if spec.is_manager { "true" } else { "false" },
spec.port,
tool_groups_attr,
capabilities_attr,
memory_max_attr,
);
}
out.push_str(" };\n };\n}\n");
@ -1468,7 +1452,6 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
@ -1516,7 +1499,6 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
@ -1536,7 +1518,6 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
@ -1578,7 +1559,6 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
@ -1608,7 +1588,6 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
@ -1630,7 +1609,6 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
@ -1662,7 +1640,6 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[
sample_spec("argus", false, 9001),
sample_spec("bitburner", false, 9002),
@ -1697,7 +1674,6 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
|_| Vec::new(),
)
@ -1737,7 +1713,6 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
@ -1784,7 +1759,6 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
)
.expect("render flake")
@ -1966,7 +1940,6 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
@ -2004,7 +1977,6 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
@ -2050,7 +2022,6 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
)
.expect("render flake")
@ -2144,7 +2115,6 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
)
.expect("render flake")
@ -2220,7 +2190,6 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
)
.expect("render flake")
@ -2246,32 +2215,20 @@ mod tests {
);
}
/// The JSC-heap-ceiling fix needs the effective per-agent memory cap
/// threaded into the flake as raw bytes, since nspawn hides the real
/// cgroup cap from inside the container. An
/// agent with no `resource-limits.json` override falls back to the
/// hive-wide default passed to `render_flake` (`"4G"` in every test
/// in this module) — this locks in the byte-count conversion end to
/// end through the actual render path (not just `parse_bytes`
/// in isolation).
/// The agent reads its memory cap at boot from the copy
/// `lifecycle::write_dropins` hands it, so the cap is no build input.
#[test]
fn render_flake_derives_memory_max_bytes_from_hive_default() {
let out = render_flake(
"github:example/hyperhive",
"path:/nix/store/bbbb-hyperhive-docs-source",
"path:/nix/store/aaaa-nixpkgs-source",
None,
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
let want_bytes = 4u64 * 1024 * 1024 * 1024;
assert!(
out.contains(&format!("memoryMaxBytes = {want_bytes};")),
"memoryMaxBytes must reflect the hive-wide default in bytes:\n{out}"
fn render_hive_flake_is_identical_across_memory_caps() {
let hive = |cap: &str| HiveEnv {
agent_memory_max: cap.to_owned(),
..HiveEnv::default()
};
let agents = [sample_spec("alice", false, 9001)];
let small = render_hive_flake(&hive("4G"), &agents).expect("render 4G");
let large = render_hive_flake(&hive("16G"), &agents).expect("render 16G");
assert_eq!(
small, large,
"the rendered flake must not depend on the memory cap"
);
}
}