From bd14cc5c4665adf44fb26bc10edd44ae7ebe03c2 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 26 Jul 2026 21:49:08 +0200 Subject: [PATCH] feat: derive BUN_JSC_forceRAMSize from effective per-agent MemoryMax= --- hive-c0re/src/agent_config/resource_limits.rs | 155 ++++++++++++++++++ hive-c0re/src/meta.rs | 122 ++++++++++---- hive-c0re/src/server.rs | 6 +- nix/agent-modules/claude-settings.nix | 40 +++++ 4 files changed, 288 insertions(+), 35 deletions(-) diff --git a/hive-c0re/src/agent_config/resource_limits.rs b/hive-c0re/src/agent_config/resource_limits.rs index c034ab79..54cbb07e 100644 --- a/hive-c0re/src/agent_config/resource_limits.rs +++ b/hive-c0re/src/agent_config/resource_limits.rs @@ -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, + 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, + name: &str, + hive_memory_max: &str, +) -> Option { + 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 { + 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); + } } diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index f9896c25..bc1fb6f4 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -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, + 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( dashboard_port: u16, operator_pronouns: &str, context_window_tokens: &std::collections::HashMap, + 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}" + ); + } } diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index e560bb48..ef74602a 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -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" )])) } diff --git a/nix/agent-modules/claude-settings.nix b/nix/agent-modules/claude-settings.nix index e384b960..fe5c1943 100644 --- a/nix/agent-modules/claude-settings.nix +++ b/nix/agent-modules/claude-settings.nix @@ -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 "-" 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" ];