From 2fd80dbd685bf50ca2315c36625bc16bded32cd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Thu, 14 May 2026 23:20:32 +0200 Subject: [PATCH] Phase 5c: separate proposed (manager) and applied (hive-c0re) repos; per-agent gitconfig --- hive-c0re/src/coordinator.rs | 16 +++- hive-c0re/src/lifecycle.rs | 155 +++++++++++++++++++++----------- hive-c0re/src/manager_server.rs | 13 ++- hive-c0re/src/server.rs | 27 ++++-- 4 files changed, 147 insertions(+), 64 deletions(-) diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index caed7aa..5f7a029 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -14,7 +14,14 @@ use crate::broker::Broker; const AGENT_RUNTIME_ROOT: &str = "/run/hyperhive/agents"; const MANAGER_RUNTIME_ROOT: &str = "/run/hyperhive/manager"; +/// Manager-editable per-agent config repos. Bind-mounted RW into the manager +/// container as `/agents//`. Hive-c0re only writes to these on first +/// spawn (initial commit); after that it's manager-only. const AGENT_STATE_ROOT: &str = "/var/lib/hyperhive/agents"; +/// Hive-c0re-only authoritative per-agent config repos. Containers build from +/// these. Manager has no filesystem access; the only way to update is via +/// `request_apply_commit` + user approval. +const APPLIED_STATE_ROOT: &str = "/var/lib/hyperhive/applied"; pub struct Coordinator { pub broker: Arc, @@ -73,7 +80,14 @@ impl Coordinator { Self::manager_dir().join("mcp.sock") } - pub fn agent_config_dir(name: &str) -> PathBuf { + /// Manager-editable proposed config repo. Bind-mounted into the manager + /// container as `/agents//config/`. + pub fn agent_proposed_dir(name: &str) -> PathBuf { PathBuf::from(format!("{AGENT_STATE_ROOT}/{name}/config")) } + + /// Authoritative applied config repo. Hive-c0re-only. + pub fn agent_applied_dir(name: &str) -> PathBuf { + PathBuf::from(format!("{APPLIED_STATE_ROOT}/{name}")) + } } diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 3acee14..0dc4672 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -40,12 +40,14 @@ pub async fn spawn( name: &str, hyperhive_flake: &str, agent_dir: &Path, - config_dir: &Path, + proposed_dir: &Path, + applied_dir: &Path, ) -> Result<()> { validate(name)?; - setup_config(config_dir, name, hyperhive_flake).await?; + setup_proposed(proposed_dir, name).await?; + setup_applied(applied_dir, name, hyperhive_flake).await?; let container = container_name(name); - let flake_ref = format!("{}#default", config_dir.display()); + let flake_ref = format!("{}#default", applied_dir.display()); run(&["create", &container, "--flake", &flake_ref]).await?; set_nspawn_flags(&container, agent_dir)?; run(&["start", &container]).await @@ -61,12 +63,12 @@ pub async fn rebuild( name: &str, hyperhive_flake: &str, agent_dir: &Path, - config_dir: &Path, + applied_dir: &Path, ) -> Result<()> { validate(name)?; - setup_config(config_dir, name, hyperhive_flake).await?; + setup_applied(applied_dir, name, hyperhive_flake).await?; let container = container_name(name); - let flake_ref = format!("{}#default", config_dir.display()); + let flake_ref = format!("{}#default", applied_dir.display()); set_nspawn_flags(&container, agent_dir)?; run(&["update", &container, "--flake", &flake_ref]).await?; // Restart so any nspawn-level changes (bind mounts, networking, etc.) apply. @@ -95,15 +97,34 @@ pub async fn list() -> Result> { .collect()) } -/// Ensure `config_dir` exists as a git repo containing a per-agent flake. The -/// `flake.nix` is rewritten every call (so a new hyperhive store path -/// propagates on rebuild); `agent.nix` is written only the first time -/// (manager-editable thereafter). -pub async fn setup_config(config_dir: &Path, name: &str, hyperhive_flake: &str) -> Result<()> { - std::fs::create_dir_all(config_dir) - .with_context(|| format!("create {}", config_dir.display()))?; +/// Initialize the manager-editable proposed repo. Contains only `agent.nix` +/// (the file the manager edits). Touched by hive-c0re only on first spawn — +/// never again — so the manager can't be surprised by hive-c0re commits or +/// working-tree resets. +pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> { + if proposed_dir.join(".git").exists() { + return Ok(()); + } + std::fs::create_dir_all(proposed_dir) + .with_context(|| format!("create {}", proposed_dir.display()))?; + let agent_path = proposed_dir.join("agent.nix"); + if !agent_path.exists() { + std::fs::write(&agent_path, initial_agent_nix(name)) + .with_context(|| format!("write {}", agent_path.display()))?; + } + git(proposed_dir, &["init", "--initial-branch=main"]).await?; + git(proposed_dir, &["add", "agent.nix"]).await?; + git_commit(proposed_dir, "hive-c0re init").await?; + Ok(()) +} + +/// Maintain the authoritative applied repo. Rewrites `flake.nix` every call +/// (so a new hyperhive flake URL propagates on rebuild); seeds `agent.nix` +/// only on first call. `apply_commit` overwrites `agent.nix` later. +pub async fn setup_applied(applied_dir: &Path, name: &str, hyperhive_flake: &str) -> Result<()> { + std::fs::create_dir_all(applied_dir) + .with_context(|| format!("create {}", applied_dir.display()))?; - let flake_path = config_dir.join("flake.nix"); let flake_body = format!( r#"{{ description = "hyperhive sub-agent {name}"; @@ -112,64 +133,96 @@ pub async fn setup_config(config_dir: &Path, name: &str, hyperhive_flake: &str) {{ hyperhive, ... }}: {{ nixosConfigurations.default = hyperhive.nixosConfigurations.agent-base.extendModules {{ - modules = [ ./agent.nix ]; + modules = [ + ./agent.nix + {{ + environment.etc."gitconfig".text = '' + [user] + name = {name} + email = {name}@hyperhive + [init] + defaultBranch = main + ''; + }} + ]; }}; }}; }} "#, ); - std::fs::write(&flake_path, flake_body) - .with_context(|| format!("write {}", flake_path.display()))?; + std::fs::write(applied_dir.join("flake.nix"), flake_body) + .with_context(|| format!("write {}/flake.nix", applied_dir.display()))?; - let agent_path = config_dir.join("agent.nix"); + let agent_path = applied_dir.join("agent.nix"); if !agent_path.exists() { - let initial = format!( - "{{ ... }}:\n{{\n # Per-agent overrides for {name}. The manager edits this\n # file (and commits) to customise the agent's NixOS config.\n}}\n", - ); - std::fs::write(&agent_path, initial) + std::fs::write(&agent_path, initial_agent_nix(name)) .with_context(|| format!("write {}", agent_path.display()))?; } - if !config_dir.join(".git").exists() { - git(config_dir, &["init", "--initial-branch=main"]).await?; + if !applied_dir.join(".git").exists() { + git(applied_dir, &["init", "--initial-branch=main"]).await?; } - git(config_dir, &["add", "-A"]).await?; - let clean = git_status(config_dir, &["diff", "--cached", "--quiet"]).await?; + git(applied_dir, &["add", "-A"]).await?; + let clean = git_status(applied_dir, &["diff", "--cached", "--quiet"]).await?; if !clean { - git( - config_dir, - &[ - "-c", - &format!("user.name={GIT_NAME}"), - "-c", - &format!("user.email={GIT_EMAIL}"), - "commit", - "-m", - "hive-c0re sync", - ], - ) - .await?; + git_commit(applied_dir, "hive-c0re sync").await?; } Ok(()) } -/// Verify `commit_ref` exists in the config repo, advance `main` to it, and -/// reset the working tree. Caller is responsible for the subsequent rebuild. -pub async fn apply_commit(config_dir: &Path, commit_ref: &str) -> Result<()> { - let st = Command::new("git") - .current_dir(config_dir) - .args(["cat-file", "-e", commit_ref]) - .status() +/// Apply a manager-proposed commit: read `agent.nix` at `commit_ref` from the +/// proposed repo, write it into the applied repo, commit. Hive-c0re alone +/// advances `applied`'s `main`; the manager only sees `proposed/`. +pub async fn apply_commit( + applied_dir: &Path, + proposed_dir: &Path, + commit_ref: &str, +) -> Result<()> { + let out = Command::new("git") + .current_dir(proposed_dir) + .args(["show", &format!("{commit_ref}:agent.nix")]) + .output() .await - .with_context(|| format!("git cat-file in {}", config_dir.display()))?; - if !st.success() { - bail!("commit {commit_ref} not found in {}", config_dir.display()); + .with_context(|| format!("git show in {}", proposed_dir.display()))?; + if !out.status.success() { + bail!( + "agent.nix at commit {commit_ref} not found in {}: {}", + proposed_dir.display(), + String::from_utf8_lossy(&out.stderr).trim() + ); + } + std::fs::write(applied_dir.join("agent.nix"), &out.stdout) + .with_context(|| format!("write {}/agent.nix", applied_dir.display()))?; + git(applied_dir, &["add", "agent.nix"]).await?; + let clean = git_status(applied_dir, &["diff", "--cached", "--quiet"]).await?; + if !clean { + git_commit(applied_dir, &format!("apply {commit_ref}")).await?; } - git(config_dir, &["update-ref", "refs/heads/main", commit_ref]).await?; - git(config_dir, &["reset", "--hard", commit_ref]).await?; Ok(()) } +fn initial_agent_nix(name: &str) -> String { + format!( + "{{ ... }}:\n{{\n # Per-agent overrides for {name}. The manager edits this\n # file (and commits) to customise the agent's NixOS config.\n}}\n", + ) +} + +async fn git_commit(dir: &Path, message: &str) -> Result<()> { + git( + dir, + &[ + "-c", + &format!("user.name={GIT_NAME}"), + "-c", + &format!("user.email={GIT_EMAIL}"), + "commit", + "-m", + message, + ], + ) + .await +} + async fn git(dir: &Path, args: &[&str]) -> Result<()> { let out = Command::new("git") .current_dir(dir) diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 6f4e6be..408bc4a 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -95,9 +95,16 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse tracing::info!(%name, "manager: spawn"); let result: Result<()> = async { let agent_dir = coord.register_agent(name)?; - let config_dir = Coordinator::agent_config_dir(name); - if let Err(e) = - lifecycle::spawn(name, &coord.hyperhive_flake, &agent_dir, &config_dir).await + let proposed_dir = Coordinator::agent_proposed_dir(name); + let applied_dir = Coordinator::agent_applied_dir(name); + if let Err(e) = lifecycle::spawn( + name, + &coord.hyperhive_flake, + &agent_dir, + &proposed_dir, + &applied_dir, + ) + .await { coord.unregister_agent(name); return Err(e); diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index cbca5a8..6c1acb7 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -61,9 +61,16 @@ async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse { HostRequest::Spawn { name } => { tracing::info!(%name, "spawn"); let agent_dir = coord.register_agent(name)?; - let config_dir = Coordinator::agent_config_dir(name); - if let Err(e) = - lifecycle::spawn(name, &coord.hyperhive_flake, &agent_dir, &config_dir).await + let proposed_dir = Coordinator::agent_proposed_dir(name); + let applied_dir = Coordinator::agent_applied_dir(name); + if let Err(e) = lifecycle::spawn( + name, + &coord.hyperhive_flake, + &agent_dir, + &proposed_dir, + &applied_dir, + ) + .await { // Roll back socket registration if container creation failed. coord.unregister_agent(name); @@ -80,24 +87,26 @@ async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse { HostRequest::Rebuild { name } => { tracing::info!(%name, "rebuild"); let agent_dir = coord.register_agent(name)?; - let config_dir = Coordinator::agent_config_dir(name); - lifecycle::rebuild(name, &coord.hyperhive_flake, &agent_dir, &config_dir).await?; + let applied_dir = Coordinator::agent_applied_dir(name); + lifecycle::rebuild(name, &coord.hyperhive_flake, &agent_dir, &applied_dir).await?; HostResponse::success() } HostRequest::List => HostResponse::list(lifecycle::list().await?), HostRequest::Pending => HostResponse::pending(coord.approvals.pending()?), HostRequest::Approve { id } => { let approval = coord.approvals.mark_approved(*id)?; - tracing::info!(%approval.id, %approval.agent, %approval.commit_ref, "approval applied: advancing main + rebuilding"); + tracing::info!(%approval.id, %approval.agent, %approval.commit_ref, "approval: applying + rebuilding"); let agent_dir = coord.register_agent(&approval.agent)?; - let config_dir = Coordinator::agent_config_dir(&approval.agent); + let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent); + let applied_dir = Coordinator::agent_applied_dir(&approval.agent); let result: anyhow::Result<()> = async { - lifecycle::apply_commit(&config_dir, &approval.commit_ref).await?; + lifecycle::apply_commit(&applied_dir, &proposed_dir, &approval.commit_ref) + .await?; lifecycle::rebuild( &approval.agent, &coord.hyperhive_flake, &agent_dir, - &config_dir, + &applied_dir, ) .await }