Watch
0
0
Fork
You've already forked hyperhive
0

subagent: keep runs at +1000 when their cgroup is unavailable

Without a run cgroup (no delegation, setup failed, or one create
failed) a subagent run was started plain at oom_score_adj 0, below the
+500 the unit gave it before. Every run of the daemon now goes through
the entry wrapper; it sets +1000 and enters the cgroup only when there
is one. run_cgroup::entry takes the cgroup as an Option.
This commit is contained in:
atlas 2026-10-12 23:46:18 +02:00 • committed by mara
commit afe6026d44
5 changed files with 159 additions and 64 deletions

View file

@ -65,7 +65,7 @@ async fn serve() -> Result<()> {
.on_runtime(runtime)
.publishing_to(hive_subagent_mcp::swarm_term::Publisher::spawn())
.reading_models_from(hive_subagent_mcp::swarm_models::Reader::spawn())
.in_run_cgroups(hive_agent_sock::run_cgroup::RunCgroups::init("runs")),
.entering_runs(hive_agent_sock::run_cgroup::RunCgroups::init("runs")),
);
// Serve the MCP tools over streamable-http forever. No background poll

View file

@ -93,7 +93,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use hive_agent_sock::run_cgroup::{RunCgroup, RunCgroups};
use hive_agent_sock::run_cgroup::{self, RunCgroup, RunCgroups};
use hive_claude::{Attach, Claude, Config, SessionStore};
use hive_runtime::{
AcpCommand, AcpRuntime, PermissionAsk, PermissionPolicy, Runtime as _, RuntimeSpec, TurnStop,
@ -407,8 +407,11 @@ pub struct State {
/// The agent's own model list, checked when the swarm sets none:
/// [`available_models`] outside tests.
local_models: fn() -> Option<String>,
/// Whether runs start through this binary's entry wrapper
/// ([`State::entering_runs`]).
runs_entered: bool,
/// Where each run gets its own cgroup, when this daemon was delegated
/// one ([`State::in_run_cgroups`]).
/// one.
run_cgroups: Option<RunCgroups>,
/// The `<n>` of the next run cgroup `<name>-<n>`.
run_seq: AtomicU64,
@ -467,6 +470,7 @@ impl State {
term: None,
models: None,
local_models: available_models,
runs_entered: false,
run_cgroups: None,
run_seq: AtomicU64::new(0),
}
@ -487,10 +491,12 @@ impl State {
self
}
/// Run each subagent run in its own cgroup under `cgroups`, at
/// `oom_score_adj` +1000.
/// Start every subagent run through this binary's entry wrapper, whose
/// `main` calls `run_cgroup::exec_entry` first: at `oom_score_adj` +1000
/// always, and in its own cgroup under `cgroups` when there are any.
#[must_use]
pub fn in_run_cgroups(mut self, cgroups: Option<RunCgroups>) -> Self {
pub fn entering_runs(mut self, cgroups: Option<RunCgroups>) -> Self {
self.runs_entered = true;
self.run_cgroups = cgroups;
self
}
@ -507,6 +513,33 @@ impl State {
.ok()
}
/// `config` with its program started through the entry wrapper, at
/// [`RUN_OOM_SCORE_ADJ`] and inside `cgroup` if there is one; unchanged
/// without [`State::entering_runs`]. `hive-claude` builds the `Command`
/// itself and offers no `pre_exec`, hence the wrapper.
fn entered_config(&self, config: &Config, cgroup: Option<&RunCgroup>) -> Config {
let mut config = config.clone();
if self.runs_entered {
// `hive-claude`'s own default program.
let program = config.program.take().unwrap_or_else(|| "claude".into());
let (entry, env) = run_cgroup::entry(&program, RUN_OOM_SCORE_ADJ, cgroup);
config.program = Some(entry);
config.env.extend(env);
}
config
}
/// [`State::entered_config`] for an ACP agent's command.
fn entered_command(&self, mut command: AcpCommand, cgroup: Option<&RunCgroup>) -> AcpCommand {
if self.runs_entered {
let (entry, env) =
run_cgroup::entry(Path::new(&command.command), RUN_OOM_SCORE_ADJ, cgroup);
command.command = entry.to_string_lossy().into_owned();
command.env.extend(env);
}
command
}
/// Refuse a `model` outside the list in effect for this agent (see
/// `check_model`). The swarm key is read only for a named model.
async fn allow_model(&self, model: Option<&str>) -> anyhow::Result<()> {
@ -1424,7 +1457,11 @@ fn start_on_runtime(
) -> anyhow::Result<String> {
if let SubagentRuntime::Acp { command, sessions } = &state.runtime {
let cgroup = state.run_cgroup(name);
let runtime = acp_runtime(command.clone(), sessions, name, cgroup.as_ref());
let runtime = acp_runtime(
state.entered_command(command.clone(), cgroup.as_ref()),
sessions,
name,
);
if let Some(archived) = runtime
.archive()
.map_err(|e| anyhow::anyhow!("archiving the prior `{name}` session failed: {e}"))?
@ -1639,7 +1676,11 @@ fn continue_on_runtime(
}
state.begin_continue(name, dir);
let cgroup = state.run_cgroup(name);
let runtime = acp_runtime(command.clone(), sessions, name, cgroup.as_ref());
let runtime = acp_runtime(
state.entered_command(command.clone(), cgroup.as_ref()),
sessions,
name,
);
return spawn_and_track_acp(
state,
name,
@ -1771,7 +1812,7 @@ fn spawn_and_track(
// Every turn of the run, and all they start, share one cgroup, released
// when the background task below ends.
let cgroup = state.run_cgroup(name);
let config = &entered_config(config, cgroup.as_ref());
let config = &state.entered_config(config, cgroup.as_ref());
let running = Claude::spawn(config, attach)
.map_err(|e| anyhow::anyhow!("starting the subagent process failed: {e}"))?;
state.track(name, running.cancel_handle().into());
@ -1946,46 +1987,19 @@ fn acp_session_file(sessions: &Path, name: &str) -> PathBuf {
/// own session (-500, `nix/agent-modules/agent-service.nix`).
const RUN_OOM_SCORE_ADJ: i32 = 1000;
/// `config` with its program started inside `cgroup`, if there is one.
/// `hive-claude` builds the `Command` itself and offers no `pre_exec`, so
/// the program becomes this daemon's binary as a [`RunCgroup::entry`]
/// wrapper.
fn entered_config(config: &Config, cgroup: Option<&RunCgroup>) -> Config {
let mut config = config.clone();
if let Some(cgroup) = cgroup {
// `hive-claude`'s own default program.
let program = config.program.take().unwrap_or_else(|| "claude".into());
let (entry, env) = cgroup.entry(&program, RUN_OOM_SCORE_ADJ);
config.program = Some(entry);
config.env.extend(env);
}
config
}
/// [`entered_config`] for an ACP agent's command.
fn entered_command(mut command: AcpCommand, cgroup: Option<&RunCgroup>) -> AcpCommand {
if let Some(cgroup) = cgroup {
let (entry, env) = cgroup.entry(Path::new(&command.command), RUN_OOM_SCORE_ADJ);
command.command = entry.to_string_lossy().into_owned();
command.env.extend(env);
}
command
}
/// The ACP runtime `name`'s run drives, keeping its session id in
/// [`acp_session_file`] and answering permission requests per
/// [`acp_permits`]. Its agent process starts inside `cgroup`.
/// [`acp_permits`].
fn acp_runtime(
command: AcpCommand,
sessions: &Path,
name: &str,
cgroup: Option<&RunCgroup>,
) -> AcpRuntime<hive_claude::NeverCompact> {
let groups = hive_sh4re::permissions::effective_tool_groups();
let permit: PermissionPolicy =
Arc::new(move |ask: &PermissionAsk<'_>| acp_permits(ask, &groups));
AcpRuntime::new(
entered_command(command, cgroup),
command,
acp_session_file(sessions, name),
permit,
hive_claude::NeverCompact,
@ -5244,4 +5258,55 @@ echo '{"type":"result","subtype":"success","is_error":false}'
};
assert!(acp_permits(&mcp, none));
}
fn env_of<'a>(env: &'a [(String, String)], name: &str) -> Option<&'a str> {
env.iter().find(|(n, _)| n == name).map(|(_, v)| v.as_str())
}
#[test]
fn a_run_without_a_cgroup_still_starts_at_the_subagent_score() {
let state = State::new(PathBuf::from("/dev/null"), signal_url()).entering_runs(None);
let config = state.entered_config(&Config::default(), None);
assert_eq!(config.program.as_deref(), Some(Path::new("/proc/self/exe")));
assert_eq!(
env_of(&config.env, "HIVE_RUN_ENTRY_PROGRAM"),
Some("claude")
);
assert_eq!(
env_of(&config.env, "HIVE_RUN_ENTRY_OOM_SCORE_ADJ"),
Some("1000")
);
assert_eq!(env_of(&config.env, "HIVE_RUN_ENTRY_CGROUP"), None);
let command = AcpCommand {
command: "agent".to_owned(),
args: Vec::new(),
env: std::collections::BTreeMap::new(),
api_key_env: None,
};
let command = state.entered_command(command, None);
assert_eq!(command.command, "/proc/self/exe");
assert_eq!(
command
.env
.get("HIVE_RUN_ENTRY_PROGRAM")
.map(String::as_str),
Some("agent")
);
assert_eq!(
command
.env
.get("HIVE_RUN_ENTRY_OOM_SCORE_ADJ")
.map(String::as_str),
Some("1000")
);
}
#[test]
fn runs_are_started_plain_unless_the_daemon_enters_them() {
let state = State::new(PathBuf::from("/dev/null"), signal_url());
let config = state.entered_config(&Config::default(), None);
assert_eq!(config.program, None);
assert!(config.env.is_empty());
}
}

View file

@ -26,3 +26,16 @@ fn the_entry_enters_the_cgroup_raises_the_score_and_execs_the_program() {
assert_eq!(String::from_utf8_lossy(&out.stdout), "1000|unset|an-arg");
assert_eq!(procs.expect("cgroup.procs written"), "0");
}
#[test]
fn the_entry_without_a_cgroup_still_raises_the_score() {
let out = Command::new(env!("CARGO_BIN_EXE_hive-subagent-daemon"))
.env("HIVE_RUN_ENTRY_PROGRAM", "/bin/sh")
.env_remove("HIVE_RUN_ENTRY_CGROUP")
.env("HIVE_RUN_ENTRY_OOM_SCORE_ADJ", "1000")
.args(["-c", "cat /proc/self/oom_score_adj"])
.output()
.expect("the binary runs");
assert!(out.status.success(), "{out:?}");
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "1000");
}