feat: derive BUN_JSC_forceRAMSize from effective per-agent MemoryMax=
This commit is contained in:
parent
ee52ddc027
commit
bd14cc5c46
4 changed files with 288 additions and 35 deletions
|
|
@ -115,6 +115,91 @@ fn resolve(limits: &AgentLimits, hive_cpu_quota: &str, hive_memory_max: &str) ->
|
||||||
(cpu, mem)
|
(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
|
/// Persist the full map. Sorted JSON output keeps meta-repo diffs
|
||||||
/// minimal.
|
/// minimal.
|
||||||
///
|
///
|
||||||
|
|
@ -323,4 +408,74 @@ mod tests {
|
||||||
assert!(validate_memory_max(v).is_err(), "{v} should be rejected");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,35 @@ pub struct AgentSpec {
|
||||||
pub port: u16,
|
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.
|
/// Idempotently reconcile the meta repo with the current agent set.
|
||||||
/// First call inits the git repo, runs `nix flake lock`, and lands a
|
/// First call inits the git repo, runs `nix flake lock`, and lands a
|
||||||
/// seed commit. Subsequent calls only touch `flake.nix` when the
|
/// 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.dashboard_port,
|
||||||
&hive.operator_pronouns,
|
&hive.operator_pronouns,
|
||||||
&hive.context_window_tokens,
|
&hive.context_window_tokens,
|
||||||
|
&hive.agent_memory_max,
|
||||||
agents,
|
agents,
|
||||||
);
|
);
|
||||||
let flake_path = dir.join("flake.nix");
|
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 {
|
for name in &ca_touched {
|
||||||
let _ = git(&dir, &["add", "--", name]).await;
|
let _ = git(&dir, &["add", "--", name]).await;
|
||||||
}
|
}
|
||||||
// Stage topology.json on every sync (regenerated by reconcile
|
stage_generated_meta_files(&dir).await?;
|
||||||
// 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?;
|
|
||||||
}
|
|
||||||
nix(&dir, &["flake", "lock"]).await?;
|
nix(&dir, &["flake", "lock"]).await?;
|
||||||
if std::path::Path::new(&dir).join("flake.lock").exists() {
|
if std::path::Path::new(&dir).join("flake.lock").exists() {
|
||||||
git(&dir, &["add", "flake.lock"]).await?;
|
git(&dir, &["add", "flake.lock"]).await?;
|
||||||
|
|
@ -653,6 +653,7 @@ fn render_flake(
|
||||||
dashboard_port: u16,
|
dashboard_port: u16,
|
||||||
operator_pronouns: &str,
|
operator_pronouns: &str,
|
||||||
context_window_tokens: &std::collections::HashMap<String, u64>,
|
context_window_tokens: &std::collections::HashMap<String, u64>,
|
||||||
|
hive_memory_max: &str,
|
||||||
agents: &[AgentSpec],
|
agents: &[AgentSpec],
|
||||||
) -> String {
|
) -> String {
|
||||||
render_flake_with_lookup(
|
render_flake_with_lookup(
|
||||||
|
|
@ -662,6 +663,7 @@ fn render_flake(
|
||||||
dashboard_port,
|
dashboard_port,
|
||||||
operator_pronouns,
|
operator_pronouns,
|
||||||
context_window_tokens,
|
context_window_tokens,
|
||||||
|
hive_memory_max,
|
||||||
agents,
|
agents,
|
||||||
agent_canonical_inputs,
|
agent_canonical_inputs,
|
||||||
)
|
)
|
||||||
|
|
@ -938,6 +940,7 @@ fn render_flake_with_lookup<F>(
|
||||||
dashboard_port: u16,
|
dashboard_port: u16,
|
||||||
operator_pronouns: &str,
|
operator_pronouns: &str,
|
||||||
context_window_tokens: &std::collections::HashMap<String, u64>,
|
context_window_tokens: &std::collections::HashMap<String, u64>,
|
||||||
|
hive_memory_max: &str,
|
||||||
agents: &[AgentSpec],
|
agents: &[AgentSpec],
|
||||||
lookup: F,
|
lookup: F,
|
||||||
) -> String
|
) -> String
|
||||||
|
|
@ -1021,7 +1024,7 @@ where
|
||||||
let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\"");
|
let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\"");
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
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(
|
out.push_str(
|
||||||
r#" let
|
r#" let
|
||||||
|
|
@ -1127,6 +1130,7 @@ where
|
||||||
# `hyperhive.user.name` match the agent's identity
|
# `hyperhive.user.name` match the agent's identity
|
||||||
# instead of the harness default of `"agent"`.
|
# instead of the harness default of `"agent"`.
|
||||||
hyperhive.user.name = name;
|
hyperhive.user.name = name;
|
||||||
|
hyperhive.claudeMemoryMaxBytes = memoryMaxBytes;
|
||||||
programs.git.config.user = {
|
programs.git.config.user = {
|
||||||
name = name;
|
name = name;
|
||||||
email = "${name}@hyperhive.local";
|
email = "${name}@hyperhive.local";
|
||||||
|
|
@ -1216,6 +1220,7 @@ where
|
||||||
let topology = crate::topology::read();
|
let topology = crate::topology::read();
|
||||||
let tool_groups_map = crate::tool_groups::read();
|
let tool_groups_map = crate::tool_groups::read();
|
||||||
let capabilities_map = crate::capabilities::read();
|
let capabilities_map = crate::capabilities::read();
|
||||||
|
let resource_limits_map = crate::resource_limits::read();
|
||||||
for spec in agents {
|
for spec in agents {
|
||||||
let parent_attr = topology
|
let parent_attr = topology
|
||||||
.get(&spec.name)
|
.get(&spec.name)
|
||||||
|
|
@ -1245,9 +1250,22 @@ where
|
||||||
let joined = caps.join(",");
|
let joined = caps.join(",");
|
||||||
format!("\"{joined}\"")
|
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!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; parent = {}; toolGroups = {}; capabilities = {}; }};",
|
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; parent = {}; toolGroups = {}; capabilities = {}; memoryMaxBytes = {}; }};",
|
||||||
spec.name,
|
spec.name,
|
||||||
spec.name,
|
spec.name,
|
||||||
if spec.is_manager { "true" } else { "false" },
|
if spec.is_manager { "true" } else { "false" },
|
||||||
|
|
@ -1255,6 +1273,7 @@ where
|
||||||
parent_attr,
|
parent_attr,
|
||||||
tool_groups_attr,
|
tool_groups_attr,
|
||||||
capabilities_attr,
|
capabilities_attr,
|
||||||
|
memory_max_attr,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
out.push_str(" };\n };\n}\n");
|
out.push_str(" };\n };\n}\n");
|
||||||
|
|
@ -1545,6 +1564,7 @@ mod tests {
|
||||||
8000,
|
8000,
|
||||||
"she/her",
|
"she/her",
|
||||||
&std::collections::HashMap::new(),
|
&std::collections::HashMap::new(),
|
||||||
|
"4G",
|
||||||
&[sample_spec("alice", false, 9001)],
|
&[sample_spec("alice", false, 9001)],
|
||||||
);
|
);
|
||||||
// nixpkgs is a top-level input with an explicit URL; hyperhive
|
// nixpkgs is a top-level input with an explicit URL; hyperhive
|
||||||
|
|
@ -1588,6 +1608,7 @@ mod tests {
|
||||||
8000,
|
8000,
|
||||||
"she/her",
|
"she/her",
|
||||||
&std::collections::HashMap::new(),
|
&std::collections::HashMap::new(),
|
||||||
|
"4G",
|
||||||
&[sample_spec("alice", false, 9001)],
|
&[sample_spec("alice", false, 9001)],
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -1607,6 +1628,7 @@ mod tests {
|
||||||
8000,
|
8000,
|
||||||
"she/her",
|
"she/her",
|
||||||
&std::collections::HashMap::new(),
|
&std::collections::HashMap::new(),
|
||||||
|
"4G",
|
||||||
&[sample_spec("alice", false, 9001)],
|
&[sample_spec("alice", false, 9001)],
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -1636,6 +1658,7 @@ mod tests {
|
||||||
8000,
|
8000,
|
||||||
"she/her",
|
"she/her",
|
||||||
&std::collections::HashMap::new(),
|
&std::collections::HashMap::new(),
|
||||||
|
"4G",
|
||||||
&[
|
&[
|
||||||
sample_spec("argus", false, 9001),
|
sample_spec("argus", false, 9001),
|
||||||
sample_spec("bitburner", false, 9002),
|
sample_spec("bitburner", false, 9002),
|
||||||
|
|
@ -1668,6 +1691,7 @@ mod tests {
|
||||||
8000,
|
8000,
|
||||||
"she/her",
|
"she/her",
|
||||||
&std::collections::HashMap::new(),
|
&std::collections::HashMap::new(),
|
||||||
|
"4G",
|
||||||
&[sample_spec("alice", false, 9001)],
|
&[sample_spec("alice", false, 9001)],
|
||||||
|_| Vec::new(),
|
|_| Vec::new(),
|
||||||
);
|
);
|
||||||
|
|
@ -1702,6 +1726,7 @@ mod tests {
|
||||||
8000,
|
8000,
|
||||||
"she/her",
|
"she/her",
|
||||||
&std::collections::HashMap::new(),
|
&std::collections::HashMap::new(),
|
||||||
|
"4G",
|
||||||
&[sample_spec("alice", false, 9001)],
|
&[sample_spec("alice", false, 9001)],
|
||||||
);
|
);
|
||||||
unsafe {
|
unsafe {
|
||||||
|
|
@ -1739,6 +1764,7 @@ mod tests {
|
||||||
8000,
|
8000,
|
||||||
"she/her",
|
"she/her",
|
||||||
&std::collections::HashMap::new(),
|
&std::collections::HashMap::new(),
|
||||||
|
"4G",
|
||||||
&[sample_spec("alice", false, 9001)],
|
&[sample_spec("alice", false, 9001)],
|
||||||
);
|
);
|
||||||
let want = format!(
|
let want = format!(
|
||||||
|
|
@ -1780,6 +1806,7 @@ mod tests {
|
||||||
8000,
|
8000,
|
||||||
"she/her",
|
"she/her",
|
||||||
&std::collections::HashMap::new(),
|
&std::collections::HashMap::new(),
|
||||||
|
"4G",
|
||||||
&[sample_spec("alice", false, 9001)],
|
&[sample_spec("alice", false, 9001)],
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
@ -1862,6 +1889,7 @@ mod tests {
|
||||||
8000,
|
8000,
|
||||||
"she/her",
|
"she/her",
|
||||||
&std::collections::HashMap::new(),
|
&std::collections::HashMap::new(),
|
||||||
|
"4G",
|
||||||
&[sample_spec("alice", false, 9001)],
|
&[sample_spec("alice", false, 9001)],
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
@ -1946,6 +1974,7 @@ mod tests {
|
||||||
8000,
|
8000,
|
||||||
"she/her",
|
"she/her",
|
||||||
&std::collections::HashMap::new(),
|
&std::collections::HashMap::new(),
|
||||||
|
"4G",
|
||||||
&[sample_spec("alice", false, 9001)],
|
&[sample_spec("alice", false, 9001)],
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
@ -1969,4 +1998,31 @@ mod tests {
|
||||||
"github.enable = false must be injected when the host disables it:\n{disabled}"
|
"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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -435,8 +435,10 @@ async fn handle_set_resource_limits(
|
||||||
&hive.agent_memory_max,
|
&hive.agent_memory_max,
|
||||||
);
|
);
|
||||||
Ok(HostResponse::messages(vec![format!(
|
Ok(HostResponse::messages(vec![format!(
|
||||||
"{name}: CPUQuota={cpu} MemoryMax={mem} (restart the container if it is running \
|
"{name}: CPUQuota={cpu} MemoryMax={mem} — the cgroup cap itself is live now (restart \
|
||||||
and the new caps need to take effect immediately)"
|
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"
|
||||||
)]))
|
)]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,11 @@ let
|
||||||
# runtime shell. Absent (option unset) → "unknown".
|
# runtime shell. Absent (option unset) → "unknown".
|
||||||
hiveDisplayName = config.environment.variables.HYPERHIVE_HIVE_NAME or "unknown";
|
hiveDisplayName = config.environment.variables.HYPERHIVE_HIVE_NAME or "unknown";
|
||||||
swarmDisplayName = config.environment.variables.HYPERHIVE_SWARM_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.
|
# Base claude-code environment applied to every agent regardless of OTEL.
|
||||||
# Shipped via the managed settings `env` block so claude and `hivectl
|
# Shipped via the managed settings `env` block so claude and `hivectl
|
||||||
# choom` both inherit them without a launch wrapper.
|
# choom` both inherit them without a launch wrapper.
|
||||||
|
|
@ -45,6 +50,19 @@ let
|
||||||
CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT = "1";
|
CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT = "1";
|
||||||
# Tag remote-control sessions with "<hive>-<agent>" for identification.
|
# Tag remote-control sessions with "<hive>-<agent>" for identification.
|
||||||
CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX = "${hiveDisplayName}-${userName}";
|
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.
|
# OTEL environment Claude Code reads to export metrics/logs/traces.
|
||||||
# Shipped via the managed claude settings json (below), which claude
|
# 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 {
|
options.hyperhive.claudeMarketplaces = lib.mkOption {
|
||||||
type = lib.types.listOf lib.types.str;
|
type = lib.types.listOf lib.types.str;
|
||||||
default = [ "anthropics/claude-plugins-official" ];
|
default = [ "anthropics/claude-plugins-official" ];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue