feat: derive BUN_JSC_forceRAMSize from effective per-agent MemoryMax=

This commit is contained in:
damocles 2026-07-26 21:49:08 +02:00
commit bd14cc5c46
4 changed files with 288 additions and 35 deletions

View file

@ -115,6 +115,91 @@ fn resolve(limits: &AgentLimits, hive_cpu_quota: &str, hive_memory_max: &str) ->
(cpu, mem)
}
/// Effective `MemoryMax=` string for `name` against an already-loaded
/// map — the memory-only half of [`effective_from`], split out because
/// [`effective_memory_bytes_from`] doesn't have a CPU quota to pass.
/// No single-agent convenience wrapper (unlike [`effective`] /
/// [`effective_from`]): every current caller already has a loaded map
/// in hand, so one would just be dead code.
#[must_use]
fn effective_memory_max_from(
limits: &BTreeMap<String, AgentLimits>,
name: &str,
hive_memory_max: &str,
) -> String {
limits
.get(name)
.and_then(|l| l.memory_max.clone())
.unwrap_or_else(|| hive_memory_max.to_owned())
}
/// Effective `MemoryMax=` for `name`, as a raw byte count, against an
/// already-loaded map — the form `render_flake_with_lookup` uses so it
/// doesn't re-read `resource-limits.json` once per agent (same
/// reasoning as [`effective_from`] / `container_view::build_all`). No
/// single-agent convenience wrapper, for the same reason as
/// [`effective_memory_max_from`] — every current caller already has a
/// loaded map in hand.
///
/// Logs a `warn!` naming `name` when the effective value is a RAM
/// percentage: unlike `"infinity"`, a percentage IS a real, resolvable
/// cap — resolving one just needs host `MemTotal`, which this module
/// doesn't track — so today it degrades to `None` (no derived heap
/// ceiling) rather than silently guessing wrong against `MemTotal`.
#[must_use]
pub fn effective_memory_bytes_from(
limits: &BTreeMap<String, AgentLimits>,
name: &str,
hive_memory_max: &str,
) -> Option<u64> {
let mem = effective_memory_max_from(limits, name, hive_memory_max);
if mem.ends_with('%') {
tracing::warn!(
agent = name,
memory_max = %mem,
"effective MemoryMax= is a RAM percentage; can't derive a JSC heap ceiling from it \
without host MemTotal leaving BUN_JSC_forceRAMSize unset for this agent"
);
}
parse_bytes(&mem)
}
/// Parse a systemd `MemoryMax=`-style byte-size value (`"4G"`, `"512M"`,
/// a bare byte count, optionally with a decimal like `"1.5G"`) into a
/// raw byte count. systemd's `K`/`M`/`G`/`T` suffixes are IEC binary
/// (1024-based), not decimal — this matches. Pure integer arithmetic
/// throughout (via `u128` headroom) rather than `f64`: a `MemoryMax=`
/// value is always a small non-negative decimal (enforced by
/// [`is_plain_number`] upstream in [`validate_memory_max`]), so floats
/// would only add rounding / sign-loss risk for no benefit. Returns
/// `None` for `"infinity"` and percentages; see
/// [`effective_memory_bytes_from`] for why those can't be turned into a
/// byte count here.
#[must_use]
pub fn parse_bytes(value: &str) -> Option<u64> {
if value == "infinity" || value.ends_with('%') {
return None;
}
let (mantissa, exponent) = [('K', 1u32), ('M', 2), ('G', 3), ('T', 4)]
.into_iter()
.find_map(|(suffix, exp)| {
value
.strip_suffix([suffix, suffix.to_ascii_lowercase()])
.map(|m| (m, exp))
})
.unwrap_or((value, 0));
let scale = u128::from(1024u64.checked_pow(exponent)?);
let (int_part, frac_part) = mantissa.split_once('.').unwrap_or((mantissa, ""));
let whole: u128 = int_part.parse().ok()?;
let mut bytes = whole.checked_mul(scale)?;
if !frac_part.is_empty() {
let frac_num: u128 = frac_part.parse().ok()?;
let frac_denom = 10u128.checked_pow(u32::try_from(frac_part.len()).ok()?)?;
bytes = bytes.checked_add(frac_num.checked_mul(scale)? / frac_denom)?;
}
u64::try_from(bytes).ok()
}
/// Persist the full map. Sorted JSON output keeps meta-repo diffs
/// minimal.
///
@ -323,4 +408,74 @@ mod tests {
assert!(validate_memory_max(v).is_err(), "{v} should be rejected");
}
}
#[test]
fn parse_bytes_handles_plain_and_suffixed_values() {
assert_eq!(parse_bytes("1024"), Some(1024));
assert_eq!(parse_bytes("4G"), Some(4 * 1024 * 1024 * 1024));
assert_eq!(parse_bytes("512M"), Some(512 * 1024 * 1024));
assert_eq!(parse_bytes("4096K"), Some(4096 * 1024));
assert_eq!(parse_bytes("2T"), Some(2 * 1024 * 1024 * 1024 * 1024));
assert_eq!(
parse_bytes("1.5G"),
Some(1024 * 1024 * 1024 + 512 * 1024 * 1024)
);
// Lowercase suffixes accepted, matching `is_byte_size`.
assert_eq!(parse_bytes("4g"), Some(4 * 1024 * 1024 * 1024));
}
#[test]
fn parse_bytes_rejects_infinity_and_percentages() {
assert_eq!(parse_bytes("infinity"), None);
assert_eq!(parse_bytes("50%"), None);
}
#[test]
fn parse_bytes_rejects_garbage() {
for v in ["", "abc", "-8G", "8Gi"] {
assert_eq!(parse_bytes(v), None, "{v} should not parse");
}
}
#[test]
fn effective_memory_bytes_falls_back_to_hive_default() {
// Empty map, i.e. no per-agent override — exercises the
// "use the hive-wide default" arm end to end.
let empty = BTreeMap::new();
assert_eq!(
effective_memory_bytes_from(&empty, "nobody-configured-this-agent", "4G"),
Some(4 * 1024 * 1024 * 1024)
);
assert_eq!(
effective_memory_bytes_from(&empty, "nobody-configured-this-agent", "infinity"),
None
);
}
#[test]
fn effective_memory_bytes_from_prefers_per_agent_override() {
let map = BTreeMap::from([("sock".to_owned(), limits(None, Some("8G")))]);
assert_eq!(
effective_memory_bytes_from(&map, "sock", HIVE_MEM),
Some(8 * 1024 * 1024 * 1024)
);
// A different agent not in the map still falls back to the
// hive-wide default from the same loaded map (no re-read).
assert_eq!(
effective_memory_bytes_from(&map, "iris", HIVE_MEM),
Some(4 * 1024 * 1024 * 1024)
);
}
/// A percentage cap is real and resolvable in principle, but this
/// module has no host `MemTotal` to resolve it against — must
/// degrade to `None` (no derived heap ceiling) rather than silently
/// treating it as unbounded or guessing a number. `warn!` firing is
/// exercised for coverage but not asserted on (no tracing test
/// subscriber wired up here) — the `None` return is the contract.
#[test]
fn effective_memory_bytes_from_returns_none_for_percentage() {
let map = BTreeMap::from([("sock".to_owned(), limits(None, Some("50%")))]);
assert_eq!(effective_memory_bytes_from(&map, "sock", HIVE_MEM), None);
}
}

View file

@ -49,6 +49,35 @@ pub struct AgentSpec {
pub port: u16,
}
/// Stage every generated meta JSON file that exists: topology.json is
/// regenerated by `reconcile` whenever the agent set changed;
/// tool-groups/capabilities/resource-limits/roles are created lazily on
/// first write (`set_groups`/`set_caps`/`set_limits`/role assignment) —
/// absent means every agent is on defaults, no file needed. Without
/// staging, an existing-but-untracked file (e.g. roles.json) shows up as
/// untracked in the meta repo, which can confuse nix's dirty-tree fetch.
/// `git add` is a no-op when content is unchanged.
async fn stage_generated_meta_files(dir: &std::path::Path) -> Result<()> {
for (path, name) in [
(crate::topology::topology_path(), "topology.json"),
(crate::tool_groups::tool_groups_path(), "tool-groups.json"),
(
crate::capabilities::capabilities_path(),
"capabilities.json",
),
(
crate::resource_limits::resource_limits_path(),
"resource-limits.json",
),
(crate::topology::roles_path(), "roles.json"),
] {
if path.exists() {
git(dir, &["add", name]).await?;
}
}
Ok(())
}
/// Idempotently reconcile the meta repo with the current agent set.
/// First call inits the git repo, runs `nix flake lock`, and lands a
/// seed commit. Subsequent calls only touch `flake.nix` when the
@ -66,6 +95,7 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
hive.dashboard_port,
&hive.operator_pronouns,
&hive.context_window_tokens,
&hive.agent_memory_max,
agents,
);
let flake_path = dir.join("flake.nix");
@ -158,37 +188,7 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
for name in &ca_touched {
let _ = git(&dir, &["add", "--", name]).await;
}
// Stage topology.json on every sync (regenerated by reconcile
// above when the agent set changed). git add is a no-op when the
// file content is unchanged.
if crate::topology::topology_path().exists() {
git(&dir, &["add", "topology.json"]).await?;
}
// Stage tool-groups.json when it exists. Created on first
// `set_groups` call (operator-driven); absent = all agents on
// their role defaults, no file needed. git add is a no-op when
// the file is unchanged.
if crate::tool_groups::tool_groups_path().exists() {
git(&dir, &["add", "tool-groups.json"]).await?;
}
// Stage capabilities.json when it exists. Created on first
// `set_caps` call; absent = no agents have extra capabilities.
if crate::capabilities::capabilities_path().exists() {
git(&dir, &["add", "capabilities.json"]).await?;
}
// Stage resource-limits.json when it exists. Created on first
// `set_limits` call; absent = every agent on the hive-wide
// CPU/memory defaults.
if crate::resource_limits::resource_limits_path().exists() {
git(&dir, &["add", "resource-limits.json"]).await?;
}
// Stage roles.json when it exists. Written by topology::write_roles /
// reconcile_roles on first role assignment or manager default seeding.
// Without this, roles.json appears as untracked in the meta repo
// (visible in `git status`) which can confuse nix dirty-tree fetches.
if crate::topology::roles_path().exists() {
git(&dir, &["add", "roles.json"]).await?;
}
stage_generated_meta_files(&dir).await?;
nix(&dir, &["flake", "lock"]).await?;
if std::path::Path::new(&dir).join("flake.lock").exists() {
git(&dir, &["add", "flake.lock"]).await?;
@ -653,6 +653,7 @@ fn render_flake(
dashboard_port: u16,
operator_pronouns: &str,
context_window_tokens: &std::collections::HashMap<String, u64>,
hive_memory_max: &str,
agents: &[AgentSpec],
) -> String {
render_flake_with_lookup(
@ -662,6 +663,7 @@ fn render_flake(
dashboard_port,
operator_pronouns,
context_window_tokens,
hive_memory_max,
agents,
agent_canonical_inputs,
)
@ -938,6 +940,7 @@ 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,
) -> String
@ -1021,7 +1024,7 @@ where
let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(
out,
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, parent ? null, toolGroups ? null, capabilities ? null }}:"
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, parent ? null, toolGroups ? null, capabilities ? null, memoryMaxBytes ? null }}:"
);
out.push_str(
r#" let
@ -1127,6 +1130,7 @@ where
# `hyperhive.user.name` match the agent's identity
# instead of the harness default of `"agent"`.
hyperhive.user.name = name;
hyperhive.claudeMemoryMaxBytes = memoryMaxBytes;
programs.git.config.user = {
name = name;
email = "${name}@hyperhive.local";
@ -1216,6 +1220,7 @@ where
let topology = crate::topology::read();
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 {
let parent_attr = topology
.get(&spec.name)
@ -1245,9 +1250,22 @@ 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
// `claude-settings.nix` can derive a JSC heap ceiling from it
// (see `hyperhive.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 = {}; parent = {}; toolGroups = {}; capabilities = {}; }};",
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; parent = {}; toolGroups = {}; capabilities = {}; memoryMaxBytes = {}; }};",
spec.name,
spec.name,
if spec.is_manager { "true" } else { "false" },
@ -1255,6 +1273,7 @@ where
parent_attr,
tool_groups_attr,
capabilities_attr,
memory_max_attr,
);
}
out.push_str(" };\n };\n}\n");
@ -1545,6 +1564,7 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
);
// nixpkgs is a top-level input with an explicit URL; hyperhive
@ -1588,6 +1608,7 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
);
assert!(
@ -1607,6 +1628,7 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
);
assert!(
@ -1636,6 +1658,7 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[
sample_spec("argus", false, 9001),
sample_spec("bitburner", false, 9002),
@ -1668,6 +1691,7 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
|_| Vec::new(),
);
@ -1702,6 +1726,7 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
);
unsafe {
@ -1739,6 +1764,7 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
);
let want = format!(
@ -1780,6 +1806,7 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
)
};
@ -1862,6 +1889,7 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
)
};
@ -1946,6 +1974,7 @@ mod tests {
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
)
};
@ -1969,4 +1998,31 @@ mod tests {
"github.enable = false must be injected when the host disables it:\n{disabled}"
);
}
/// 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).
#[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",
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
);
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}"
);
}
}

View file

@ -435,8 +435,10 @@ async fn handle_set_resource_limits(
&hive.agent_memory_max,
);
Ok(HostResponse::messages(vec![format!(
"{name}: CPUQuota={cpu} MemoryMax={mem} (restart the container if it is running \
and the new caps need to take effect immediately)"
"{name}: CPUQuota={cpu} MemoryMax={mem} — the cgroup cap itself is live now (restart \
the container if it's running and needs the new cap immediately), but the derived \
Claude/JSC heap ceiling is baked in at build time, so it needs a REBUILD \
(`hivectl agents rebuild {name}`) to actually track this change"
)]))
}

View file

@ -20,6 +20,11 @@ let
# runtime shell. Absent (option unset) → "unknown".
hiveDisplayName = config.environment.variables.HYPERHIVE_HIVE_NAME or "unknown";
swarmDisplayName = config.environment.variables.HYPERHIVE_SWARM_NAME or "unknown";
# Effective per-agent MemoryMax=, in bytes, injected by meta.rs's
# per-agent flake render (`hyperhive.claudeMemoryMaxBytes`). `null`
# when the effective cap is unbounded ("infinity") or a RAM
# percentage — see `resource_limits::effective_memory_bytes`.
memoryMaxBytes = config.hyperhive.claudeMemoryMaxBytes;
# Base claude-code environment applied to every agent regardless of OTEL.
# Shipped via the managed settings `env` block so claude and `hivectl
# choom` both inherit them without a launch wrapper.
@ -45,6 +50,19 @@ let
CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT = "1";
# Tag remote-control sessions with "<hive>-<agent>" for identification.
CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX = "${hiveDisplayName}-${userName}";
}
# Bun/JavaScriptCore (the runtime under claude-code's `.claude-wrapped`
# binary) sizes its default heap ceiling off the container's visible
# `/proc/meminfo`, which under nspawn is the *host's* physical RAM, not
# the systemd `MemoryMax=` cgroup cap actually enforced on this
# container — the cap lives on the host's outer cgroup and is invisible
# from inside (confirmed: `/sys/fs/cgroup/memory.max` reads `max` at
# every level from in here). That mismatch lets JSC's heap grow well
# past the real wall before GC kicks in hard, causing choom sessions to
# hang. Pin JSC's ceiling to 75% of the *actual* effective cap instead,
# once it's known at build time.
// lib.optionalAttrs (memoryMaxBytes != null) {
BUN_JSC_forceRAMSize = toString (memoryMaxBytes * 75 / 100);
};
# OTEL environment Claude Code reads to export metrics/logs/traces.
# Shipped via the managed claude settings json (below), which claude
@ -186,6 +204,28 @@ in
};
};
# Build-time implementation surface for the JSC-heap-ceiling fix:
# meta.rs's per-agent flake render injects this from the effective
# `MemoryMax=` (per-agent `resource-limits.json` override, else the
# hive-wide `services.hyperhive.agentMemoryMax`) — see
# `resource_limits::effective_memory_bytes_from`. Not meant to be set
# directly in an agent.nix, same convention as `hyperhive.otel.*`
# above; the host option (or `hivectl agents set-resource-limits`) is
# the real operator knob, and this only reflects the value baked in at
# the agent's *last rebuild* — `set-resource-limits` still applies the
# cgroup cap live via a drop-in reload, but this derived heap ceiling
# needs a rebuild to pick up a new value.
options.hyperhive.claudeMemoryMaxBytes = lib.mkOption {
type = lib.types.nullOr lib.types.ints.positive;
default = null;
internal = true;
description = ''
Effective per-agent memory cap in bytes, when it's a plain
byte-size value (null for an unbounded or percentage-based cap).
Used to derive `BUN_JSC_forceRAMSize` in `baseClaudeEnv`.
'';
};
options.hyperhive.claudeMarketplaces = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "anthropics/claude-plugins-official" ];