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

@ -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}"
);
}
}