manager: same lifecycle as agents; auto-spawn on hive-c0re start

This commit is contained in:
müde 2026-05-15 13:43:32 +02:00
parent d81a845dbe
commit f99ed3fe7a
8 changed files with 168 additions and 65 deletions

View file

@ -10,9 +10,15 @@ use tokio::process::Command;
/// name itself can be at most `MAX_AGENT_NAME` chars.
pub const AGENT_PREFIX: &str = "h-";
pub const MAX_AGENT_NAME: usize = 9;
/// Container name of the manager (a separate slot from sub-agents).
/// Container name of the manager. Lives in the same path scheme as sub-agents
/// (`/var/lib/hyperhive/agents/hm1nd/`, `/var/lib/hyperhive/applied/hm1nd/`),
/// but its container has no `h-` prefix and extends a different
/// nixosConfiguration (`manager`, not `agent-base`).
pub const MANAGER_NAME: &str = "hm1nd";
/// Web UI port reserved for the manager (sub-agents hash into 8100..8999).
pub const MANAGER_PORT: u16 = 8000;
/// Mount point of the per-agent runtime directory inside the container.
pub const CONTAINER_RUNTIME_MOUNT: &str = "/run/hive";
@ -35,9 +41,13 @@ const DEFAULT_MEMORY_MAX: &str = "2G";
const DEFAULT_CPU_QUOTA: &str = "50%";
/// Returns the per-agent web UI port. Same hash on both sides — manager,
/// dashboard, and agent harness all agree.
/// dashboard, and agent harness all agree. Manager is fixed at
/// `MANAGER_PORT`.
#[must_use]
pub fn agent_web_port(name: &str) -> u16 {
if name == MANAGER_NAME {
return MANAGER_PORT;
}
let mut hash: u32 = 2_166_136_261;
for b in name.bytes() {
hash ^= u32::from(b);
@ -47,14 +57,34 @@ pub fn agent_web_port(name: &str) -> u16 {
WEB_PORT_BASE + u16::try_from(hash % u32::from(WEB_PORT_RANGE)).unwrap_or(0)
}
#[must_use]
pub fn container_name(name: &str) -> String {
format!("{AGENT_PREFIX}{name}")
if name == MANAGER_NAME {
MANAGER_NAME.to_owned()
} else {
format!("{AGENT_PREFIX}{name}")
}
}
#[must_use]
pub fn is_manager(name: &str) -> bool {
name == MANAGER_NAME
}
/// The nixosConfiguration in the hyperhive flake the agent's `flake.nix`
/// extends. Manager → `manager`; everyone else → `agent-base`.
#[must_use]
pub fn flake_base(name: &str) -> &'static str {
if is_manager(name) { "manager" } else { "agent-base" }
}
fn validate(name: &str) -> Result<()> {
if name.is_empty() {
bail!("agent name must not be empty");
}
if is_manager(name) {
return Ok(());
}
if name.len() > MAX_AGENT_NAME {
bail!(
"agent name '{name}' is too long ({} chars); max {MAX_AGENT_NAME}",
@ -180,14 +210,25 @@ pub async fn setup_applied(applied_dir: &Path, name: &str, hyperhive_flake: &str
.with_context(|| format!("create {}", applied_dir.display()))?;
let port = agent_web_port(name);
let base = flake_base(name);
let service = if is_manager(name) {
"hive-m1nd"
} else {
"hive-ag3nt"
};
let description = if is_manager(name) {
format!("hyperhive manager {name}")
} else {
format!("hyperhive sub-agent {name}")
};
let flake_body = format!(
r#"{{
description = "hyperhive sub-agent {name}";
description = "{description}";
inputs.hyperhive.url = "{hyperhive_flake}";
outputs =
{{ hyperhive, ... }}:
{{
nixosConfigurations.default = hyperhive.nixosConfigurations.agent-base.extendModules {{
nixosConfigurations.default = hyperhive.nixosConfigurations.{base}.extendModules {{
modules = [
./agent.nix
{{
@ -198,7 +239,7 @@ pub async fn setup_applied(applied_dir: &Path, name: &str, hyperhive_flake: &str
[init]
defaultBranch = main
'';
systemd.services.hive-ag3nt.environment = {{
systemd.services.{service}.environment = {{
HIVE_PORT = "{port}";
HIVE_LABEL = "{name}";
}};
@ -372,14 +413,35 @@ async fn systemd_daemon_reload() -> Result<()> {
/// is reachable on the host) and `EXTRA_NSPAWN_FLAGS` (the runtime-dir bind).
/// The start script expands `$EXTRA_NSPAWN_FLAGS` unquoted into the
/// `systemd-nspawn` command.
fn set_nspawn_flags(container: &str, agent_dir: &Path, claude_dir: &Path) -> Result<()> {
/// Where in the container's filesystem the manager sees its agents tree.
/// Matches the `/agents` path that pre-Phase-8 hosts declared via
/// `containers.hm1nd.bindMounts."/agents"`.
pub const CONTAINER_MANAGER_AGENTS_MOUNT: &str = "/agents";
/// The on-host root that gets bind-mounted to `/agents` inside the manager.
/// Hard-coded to match `AGENT_STATE_ROOT` in coordinator.rs (kept duplicated
/// here so lifecycle stays usable as a leaf module).
const HOST_AGENTS_ROOT: &str = "/var/lib/hyperhive/agents";
fn set_nspawn_flags(container: &str, runtime_dir: &Path, claude_dir: &Path) -> Result<()> {
let path = format!("/etc/nixos-containers/{container}.conf");
let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?;
let bind_flag = format!(
"EXTRA_NSPAWN_FLAGS=\"--bind={runtime}:{CONTAINER_RUNTIME_MOUNT} --bind={claude}:{CONTAINER_CLAUDE_MOUNT}\"",
runtime = agent_dir.display(),
let mut binds = format!(
"--bind={runtime}:{CONTAINER_RUNTIME_MOUNT} --bind={claude}:{CONTAINER_CLAUDE_MOUNT}",
runtime = runtime_dir.display(),
claude = claude_dir.display(),
);
if container == MANAGER_NAME {
// Manager edits sub-agent proposed/ repos and its own. RW so it can
// git-commit. Sub-agents see only their own /run/hive socket and
// /root/.claude (no /agents).
use std::fmt::Write as _;
let _ = write!(
binds,
" --bind={HOST_AGENTS_ROOT}:{CONTAINER_MANAGER_AGENTS_MOUNT}"
);
}
let bind_flag = format!("EXTRA_NSPAWN_FLAGS=\"{binds}\"");
let mut lines: Vec<String> = original
.lines()
.filter(|line| {