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:
parent
c8df666bf3
commit
0d619e215e
15 changed files with 416 additions and 287 deletions
|
|
@ -1,6 +1,7 @@
|
|||
//! Per-container host-side config: the nspawn conf rewrite (bind mounts,
|
||||
//! network isolation, forwarded credentials), the systemd resource-limits
|
||||
//! drop-in, and the `write_dropins` verb that re-applies both.
|
||||
//! drop-in, the agent's copy of its memory cap, and the `write_dropins` verb
|
||||
//! that re-applies them.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
|
|
@ -16,20 +17,21 @@ use super::{
|
|||
};
|
||||
|
||||
/// Re-apply the per-container host-side config: nspawn flags (bind
|
||||
/// mounts etc.), the systemd resource-limits drop-in, and a daemon
|
||||
/// reload so both take effect on the next unit (re)start. Idempotent —
|
||||
/// the job queue's `WriteDropin` node, also folded into every `Swap`
|
||||
/// (rebuild is the reconcile verb).
|
||||
/// mounts etc.), the systemd resource-limits drop-in, the agent's copy of
|
||||
/// its memory cap, and a daemon reload so all of it takes effect on the next
|
||||
/// unit (re)start. Idempotent — the job queue's `WriteDropin` node, also
|
||||
/// folded into every `Swap` (rebuild is the reconcile verb).
|
||||
pub async fn write_dropins(name: &str, hive: &HiveEnv, paths: &AgentPaths) -> Result<()> {
|
||||
validate(name)?;
|
||||
let container = container_name(name);
|
||||
set_nspawn_flags(&container, &paths.agent, &paths.claude, &paths.notes).await?;
|
||||
if let Some((cpu_quota, memory_max)) = limits_to_apply(
|
||||
if let Some((cpu_quota, memory_max)) = stage_limits(
|
||||
name,
|
||||
&crate::resource_limits::resource_limits_path(),
|
||||
&limits_dropin_path(&container),
|
||||
&paths.agent,
|
||||
hive,
|
||||
) {
|
||||
)? {
|
||||
set_resource_limits(
|
||||
&container,
|
||||
&cpu_quota,
|
||||
|
|
@ -51,6 +53,60 @@ fn limits_dropin_path(container: &str) -> PathBuf {
|
|||
))
|
||||
}
|
||||
|
||||
/// File in the agent's runtime dir, `/run/hive/` in the container, holding the
|
||||
/// byte count of the `MemoryMax=` its drop-in carries.
|
||||
/// `nix/agent-modules/memory-cap.nix` reads it at boot under this name.
|
||||
const MEMORY_CAP_FILE: &str = "memory-max-bytes";
|
||||
|
||||
/// [`limits_to_apply`], plus handing the memory cap to the agent through
|
||||
/// [`MEMORY_CAP_FILE`] in `runtime_dir`. The container cannot read the cap
|
||||
/// systemd enforces on it, so this file is how it learns the value it sizes
|
||||
/// its heap and its subagents against. When the drop-in is kept as it is, so is
|
||||
/// the file.
|
||||
fn stage_limits(
|
||||
agent_name: &str,
|
||||
limits_path: &Path,
|
||||
dropin_path: &Path,
|
||||
runtime_dir: &Path,
|
||||
hive: &HiveEnv,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
let limits = limits_to_apply(agent_name, limits_path, dropin_path, hive);
|
||||
if let Some((_, memory_max)) = &limits {
|
||||
write_memory_cap(runtime_dir, agent_name, memory_max)?;
|
||||
}
|
||||
Ok(limits)
|
||||
}
|
||||
|
||||
/// Write `memory_max` into [`MEMORY_CAP_FILE`] as a byte count, or remove the
|
||||
/// file when the value has none: `infinity`, or a RAM percentage, which needs
|
||||
/// the host's `MemTotal` to resolve. With no file the agent sets no heap or
|
||||
/// subagent ceiling.
|
||||
fn write_memory_cap(runtime_dir: &Path, agent_name: &str, memory_max: &str) -> Result<()> {
|
||||
let path = runtime_dir.join(MEMORY_CAP_FILE);
|
||||
let Some(bytes) = crate::resource_limits::parse_bytes(memory_max) else {
|
||||
if memory_max.ends_with('%') {
|
||||
tracing::warn!(
|
||||
agent = agent_name,
|
||||
memory_max,
|
||||
"effective MemoryMax= is a RAM percentage; the agent gets no byte count, so \
|
||||
no JSC heap ceiling and no subagent MemoryHigh="
|
||||
);
|
||||
}
|
||||
return match std::fs::remove_file(&path) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
|
||||
Err(e).with_context(|| format!("remove {}", path.display()))
|
||||
}
|
||||
_ => Ok(()),
|
||||
};
|
||||
};
|
||||
std::fs::create_dir_all(runtime_dir)
|
||||
.with_context(|| format!("create agent runtime dir {}", runtime_dir.display()))?;
|
||||
let tmp = runtime_dir.join(format!(".{MEMORY_CAP_FILE}.tmp"));
|
||||
std::fs::write(&tmp, format!("{bytes}\n"))
|
||||
.with_context(|| format!("write {}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, &path).with_context(|| format!("rename onto {}", path.display()))
|
||||
}
|
||||
|
||||
/// The `(CPUQuota, MemoryMax)` to write into `agent_name`'s limits
|
||||
/// drop-in, or `None` to leave the drop-in at `dropin_path` as it is.
|
||||
///
|
||||
|
|
@ -489,8 +545,9 @@ async fn set_nspawn_flags(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
BindMount, QUEUE_CLIENT_ID_CREDENTIAL, QUEUE_SECRET_CREDENTIAL, bind_child_agent_dirs,
|
||||
bind_own_agent_dirs, holds_manage_root_agent, limits_to_apply, queue_agent_credentials,
|
||||
BindMount, MEMORY_CAP_FILE, QUEUE_CLIENT_ID_CREDENTIAL, QUEUE_SECRET_CREDENTIAL,
|
||||
bind_child_agent_dirs, bind_own_agent_dirs, holds_manage_root_agent, limits_to_apply,
|
||||
queue_agent_credentials, stage_limits,
|
||||
};
|
||||
use crate::coordinator::HiveEnv;
|
||||
|
||||
|
|
@ -707,4 +764,66 @@ mod tests {
|
|||
let applied = limits_to_apply("sock", &limits, &dropin, &HiveEnv::default());
|
||||
assert_eq!(applied, Some(("200%".to_owned(), "1G".to_owned())));
|
||||
}
|
||||
|
||||
/// Runs [`stage_limits`] against the files in `dir` and returns the
|
||||
/// drop-in's `MemoryMax=` and the agent's copy of it, `None` when absent.
|
||||
fn stage(dir: &tempfile::TempDir) -> (Option<String>, Option<String>) {
|
||||
let runtime = dir.path().join("runtime");
|
||||
let applied = stage_limits(
|
||||
"sock",
|
||||
&dir.path().join("resource-limits.json"),
|
||||
&dir.path().join("hyperhive-limits.conf"),
|
||||
&runtime,
|
||||
&HiveEnv::default(),
|
||||
)
|
||||
.expect("stage limits");
|
||||
let copy = std::fs::read_to_string(runtime.join(MEMORY_CAP_FILE)).ok();
|
||||
(applied.map(|(_, mem)| mem), copy)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_agent_reads_the_cap_its_dropin_carries() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let limits = dir.path().join("resource-limits.json");
|
||||
|
||||
// No overrides anywhere: the hive-wide default, 4G.
|
||||
assert_eq!(
|
||||
stage(&dir),
|
||||
(Some("4G".to_owned()), Some("4294967296\n".to_owned()))
|
||||
);
|
||||
|
||||
std::fs::write(&limits, r#"{"sock": {"memory_max": "16G"}}"#).expect("seed limits");
|
||||
assert_eq!(
|
||||
stage(&dir),
|
||||
(Some("16G".to_owned()), Some("17179869184\n".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cap_with_no_byte_count_removes_the_agents_copy() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let limits = dir.path().join("resource-limits.json");
|
||||
assert_eq!(stage(&dir).1, Some("4294967296\n".to_owned()));
|
||||
|
||||
std::fs::write(&limits, r#"{"sock": {"memory_max": "infinity"}}"#).expect("seed limits");
|
||||
assert_eq!(stage(&dir), (Some("infinity".to_owned()), None));
|
||||
|
||||
assert_eq!(stage(&dir).1, None, "an absent copy stays absent");
|
||||
|
||||
std::fs::write(&limits, r#"{"sock": {"memory_max": "50%"}}"#).expect("seed limits");
|
||||
assert_eq!(stage(&dir), (Some("50%".to_owned()), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_dropin_keeps_the_agents_copy() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let limits = dir.path().join("resource-limits.json");
|
||||
std::fs::write(&limits, r#"{"sock": {"memory_max": "1G"}}"#).expect("seed limits");
|
||||
assert_eq!(stage(&dir).1, Some("1073741824\n".to_owned()));
|
||||
|
||||
std::fs::write(&limits, TRUNCATED_LIMITS).expect("seed limits");
|
||||
std::fs::write(dir.path().join("hyperhive-limits.conf"), "MemoryMax=1G\n")
|
||||
.expect("seed drop-in");
|
||||
assert_eq!(stage(&dir), (None, Some("1073741824\n".to_owned())));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue