From 433c0d212efc2cc97b973452a4f4cb00cc25119e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Thu, 14 May 2026 23:09:35 +0200 Subject: [PATCH 1/7] Phase 5b: per-agent config flakes; approve validates + advances commit --- hive-c0re/src/coordinator.rs | 13 +++- hive-c0re/src/lifecycle.rs | 134 ++++++++++++++++++++++++++++++-- hive-c0re/src/main.rs | 14 ++-- hive-c0re/src/manager_server.rs | 5 +- hive-c0re/src/server.rs | 28 +++++-- nix/modules/hive-c0re.nix | 13 +++- 6 files changed, 182 insertions(+), 25 deletions(-) diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index ce2032e6..caed7aa6 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -14,22 +14,25 @@ use crate::broker::Broker; const AGENT_RUNTIME_ROOT: &str = "/run/hyperhive/agents"; const MANAGER_RUNTIME_ROOT: &str = "/run/hyperhive/manager"; +const AGENT_STATE_ROOT: &str = "/var/lib/hyperhive/agents"; pub struct Coordinator { pub broker: Arc, pub approvals: Arc, - pub agent_flake: String, + /// URL of the hyperhive flake (no fragment). Inlined into per-agent + /// `flake.nix` files as `inputs.hyperhive.url`. + pub hyperhive_flake: String, agents: Mutex>, } impl Coordinator { - pub fn open(db_path: &Path, agent_flake: String) -> Result { + pub fn open(db_path: &Path, hyperhive_flake: String) -> Result { let broker = Broker::open(db_path).context("open broker")?; let approvals = Approvals::open(db_path).context("open approvals")?; Ok(Self { broker: Arc::new(broker), approvals: Arc::new(approvals), - agent_flake, + hyperhive_flake, agents: Mutex::new(HashMap::new()), }) } @@ -69,4 +72,8 @@ impl Coordinator { pub fn manager_socket_path() -> PathBuf { Self::manager_dir().join("mcp.sock") } + + pub fn agent_config_dir(name: &str) -> PathBuf { + PathBuf::from(format!("{AGENT_STATE_ROOT}/{name}/config")) + } } diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 7399ff74..2bedff0b 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -1,4 +1,4 @@ -//! Thin async wrappers over `nixos-container`. +//! `nixos-container` lifecycle + per-agent config flake generation. use std::path::Path; @@ -16,6 +16,9 @@ pub const MANAGER_NAME: &str = "hm1nd"; /// Mount point of the per-agent runtime directory inside the container. pub const CONTAINER_RUNTIME_MOUNT: &str = "/run/hive"; +const GIT_NAME: &str = "hive-c0re"; +const GIT_EMAIL: &str = "hive-c0re@hyperhive"; + pub fn container_name(name: &str) -> String { format!("{AGENT_PREFIX}{name}") } @@ -33,10 +36,17 @@ fn validate(name: &str) -> Result<()> { Ok(()) } -pub async fn spawn(name: &str, agent_flake: &str, agent_dir: &Path) -> Result<()> { +pub async fn spawn( + name: &str, + hyperhive_flake: &str, + agent_dir: &Path, + config_dir: &Path, +) -> Result<()> { validate(name)?; + setup_config(config_dir, name, hyperhive_flake).await?; let container = container_name(name); - run(&["create", &container, "--flake", agent_flake]).await?; + let flake_ref = format!("{}#default", config_dir.display()); + run(&["create", &container, "--flake", &flake_ref]).await?; set_nspawn_flags(&container, agent_dir)?; run(&["start", &container]).await } @@ -47,11 +57,18 @@ pub async fn kill(name: &str) -> Result<()> { run(&["stop", &container]).await } -pub async fn rebuild(name: &str, agent_flake: &str, agent_dir: &Path) -> Result<()> { +pub async fn rebuild( + name: &str, + hyperhive_flake: &str, + agent_dir: &Path, + config_dir: &Path, +) -> Result<()> { validate(name)?; + setup_config(config_dir, name, hyperhive_flake).await?; let container = container_name(name); + let flake_ref = format!("{}#default", config_dir.display()); set_nspawn_flags(&container, agent_dir)?; - run(&["update", &container, "--flake", agent_flake]).await?; + run(&["update", &container, "--flake", &flake_ref]).await?; // Restart so any nspawn-level changes (bind mounts, networking, etc.) apply. run(&["stop", &container]).await?; run(&["start", &container]).await @@ -78,6 +95,113 @@ 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()))?; + + let flake_path = config_dir.join("flake.nix"); + let flake_body = format!( + r#"{{ + description = "hyperhive sub-agent {name}"; + inputs.hyperhive.url = "{hyperhive_flake}"; + outputs = + {{ hyperhive, ... }}: + {{ + nixosConfigurations.default = hyperhive.nixosConfigurations.agent-base.extendModules {{ + modules = [ ./agent.nix ]; + }}; + }}; +}} +"#, + ); + std::fs::write(&flake_path, flake_body) + .with_context(|| format!("write {}", flake_path.display()))?; + + let agent_path = config_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) + .with_context(|| format!("write {}", agent_path.display()))?; + } + + if !config_dir.join(".git").exists() { + git(config_dir, &["init", "--initial-branch=main"]).await?; + } + git(config_dir, &["add", "-A"]).await?; + let clean = git_status(config_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?; + } + 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() + .await + .with_context(|| format!("git cat-file in {}", config_dir.display()))?; + if !st.success() { + bail!( + "commit {commit_ref} not found in {}", + config_dir.display() + ); + } + git(config_dir, &["update-ref", "refs/heads/main", commit_ref]).await?; + git(config_dir, &["reset", "--hard", commit_ref]).await?; + Ok(()) +} + +async fn git(dir: &Path, args: &[&str]) -> Result<()> { + let out = Command::new("git") + .current_dir(dir) + .args(args) + .output() + .await + .with_context(|| format!("git {} in {}", args.join(" "), dir.display()))?; + if !out.status.success() { + bail!( + "git {} failed ({}): {}", + args.join(" "), + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(()) +} + +/// Returns true if the command exits 0. +async fn git_status(dir: &Path, args: &[&str]) -> Result { + let st = Command::new("git") + .current_dir(dir) + .args(args) + .status() + .await + .with_context(|| format!("git {} in {}", args.join(" "), dir.display()))?; + Ok(st.success()) +} + /// Idempotently rewrite the `EXTRA_NSPAWN_FLAGS` line in /// `/etc/nixos-containers/.conf`. The start script expands this /// variable unquoted into the `systemd-nspawn` command. diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index ce5aa10e..fd8329e1 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -31,9 +31,10 @@ struct Cli { enum Cmd { /// Run the coordinator daemon. Serve { - /// Flake reference for the agent base template. - #[arg(long, default_value = "/etc/hyperhive#agent-base")] - agent_flake: String, + /// URL of the hyperhive flake. Inlined into each per-agent + /// `flake.nix` as the `hyperhive` input. + #[arg(long, default_value = "/etc/hyperhive")] + hyperhive_flake: String, /// Path to the sqlite message store. #[arg(long, default_value = "/var/lib/hyperhive/broker.sqlite")] db: PathBuf, @@ -65,8 +66,11 @@ async fn main() -> Result<()> { let cli = Cli::parse(); match cli.cmd { - Cmd::Serve { agent_flake, db } => { - let coord = Arc::new(Coordinator::open(&db, agent_flake)?); + Cmd::Serve { + hyperhive_flake, + db, + } => { + let coord = Arc::new(Coordinator::open(&db, hyperhive_flake)?); manager_server::start(coord.clone())?; server::serve(&cli.socket, coord).await } diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 086c9ba3..6f4e6be6 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -95,7 +95,10 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse tracing::info!(%name, "manager: spawn"); let result: Result<()> = async { let agent_dir = coord.register_agent(name)?; - if let Err(e) = lifecycle::spawn(name, &coord.agent_flake, &agent_dir).await { + let config_dir = Coordinator::agent_config_dir(name); + if let Err(e) = + lifecycle::spawn(name, &coord.hyperhive_flake, &agent_dir, &config_dir).await + { coord.unregister_agent(name); return Err(e); } diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 8ed0c15c..cbca5a8a 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -20,7 +20,7 @@ pub async fn serve(socket: &Path, coord: Arc) -> Result<()> { let listener = UnixListener::bind(socket) .with_context(|| format!("bind admin socket {}", socket.display()))?; - tracing::info!(socket = %socket.display(), agent_flake = %coord.agent_flake, "hive-c0re admin listening"); + tracing::info!(socket = %socket.display(), hyperhive_flake = %coord.hyperhive_flake, "hive-c0re admin listening"); loop { let (stream, _) = listener.accept().await.context("accept connection")?; @@ -61,7 +61,10 @@ async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse { HostRequest::Spawn { name } => { tracing::info!(%name, "spawn"); let agent_dir = coord.register_agent(name)?; - if let Err(e) = lifecycle::spawn(name, &coord.agent_flake, &agent_dir).await { + let config_dir = Coordinator::agent_config_dir(name); + if let Err(e) = + lifecycle::spawn(name, &coord.hyperhive_flake, &agent_dir, &config_dir).await + { // Roll back socket registration if container creation failed. coord.unregister_agent(name); return Err(e); @@ -77,18 +80,29 @@ async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse { HostRequest::Rebuild { name } => { tracing::info!(%name, "rebuild"); let agent_dir = coord.register_agent(name)?; - lifecycle::rebuild(name, &coord.agent_flake, &agent_dir).await?; + let config_dir = Coordinator::agent_config_dir(name); + lifecycle::rebuild(name, &coord.hyperhive_flake, &agent_dir, &config_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: rebuilding agent"); + tracing::info!(%approval.id, %approval.agent, %approval.commit_ref, "approval applied: advancing main + rebuilding"); let agent_dir = coord.register_agent(&approval.agent)?; - if let Err(e) = - lifecycle::rebuild(&approval.agent, &coord.agent_flake, &agent_dir).await - { + let config_dir = Coordinator::agent_config_dir(&approval.agent); + let result: anyhow::Result<()> = async { + lifecycle::apply_commit(&config_dir, &approval.commit_ref).await?; + lifecycle::rebuild( + &approval.agent, + &coord.hyperhive_flake, + &agent_dir, + &config_dir, + ) + .await + } + .await; + if let Err(e) = result { let note = format!("{e:#}"); let _ = coord.approvals.mark_failed(approval.id, ¬e); return Err(e); diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index 86ba5909..96b8f34c 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -16,10 +16,15 @@ in defaultText = lib.literalExpression "pkgs.hyperhive"; description = "Package that provides /bin/hive-c0re."; }; - agentFlake = lib.mkOption { + hyperhiveFlake = lib.mkOption { type = lib.types.str; - default = "/etc/hyperhive#agent-base"; - description = "Flake reference passed to `nixos-container create --flake` when spawning sub-agents."; + default = "/etc/hyperhive"; + description = '' + URL of the hyperhive flake (no fragment). Inlined into each + per-agent `flake.nix` at `inputs.hyperhive.url`. The per-agent + flake then pulls `hyperhive.nixosConfigurations.agent-base` to + build the container. + ''; }; }; @@ -31,7 +36,7 @@ in wantedBy = [ "multi-user.target" ]; path = [ "/run/current-system/sw" ]; serviceConfig = { - ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --agent-flake ${cfg.agentFlake}"; + ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake}"; Restart = "on-failure"; RestartSec = 2; RuntimeDirectory = "hyperhive"; From 3c702cf43fe574a43c798bdfe1585feb47d59e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Thu, 14 May 2026 23:10:37 +0200 Subject: [PATCH 2/7] fmt --- hive-c0re/src/lifecycle.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 2bedff0b..3acee14f 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -163,10 +163,7 @@ pub async fn apply_commit(config_dir: &Path, commit_ref: &str) -> Result<()> { .await .with_context(|| format!("git cat-file in {}", config_dir.display()))?; if !st.success() { - bail!( - "commit {commit_ref} not found in {}", - config_dir.display() - ); + bail!("commit {commit_ref} not found in {}", config_dir.display()); } git(config_dir, &["update-ref", "refs/heads/main", commit_ref]).await?; git(config_dir, &["reset", "--hard", commit_ref]).await?; From da655d31c854f1d8ef00c36df2d5bd0c248a383e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Thu, 14 May 2026 23:11:57 +0200 Subject: [PATCH 3/7] manager: /etc/gitconfig so commits in /agents work out of the box --- nix/templates/manager.nix | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/nix/templates/manager.nix b/nix/templates/manager.nix index b1912955..3bf9e87c 100644 --- a/nix/templates/manager.nix +++ b/nix/templates/manager.nix @@ -11,6 +11,14 @@ coreutils-full ]; + environment.etc."gitconfig".text = '' + [user] + name = hm1nd + email = hm1nd@hyperhive + [init] + defaultBranch = main + ''; + systemd.services.hive-m1nd = { description = "hive-m1nd manager harness"; wantedBy = [ "multi-user.target" ]; From f6d681c2e29cb0ecca1b5f089d964e4f0250d4ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Thu, 14 May 2026 23:12:43 +0200 Subject: [PATCH 4/7] CLAUDE.md: Phases 4 + 5 status, approval-flow runbook --- CLAUDE.md | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1f2f80af..0190186f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,11 +140,48 @@ in. hot-reload works under the patch stack (validated empirically on muede-lpt2) - ✅ Phase 2 — per-agent sockets, in-memory broker, agent harness round-trips messages - ✅ Phase 3 — sqlite broker (durable across restart) + claude-or-echo turn loop -- 🔜 Phase 4 — `hm1nd` manager binary with privileged tool surface -- 🔜 Phase 5 — git-commit approval flow (`state-repo` + per-agent config flakes) +- ✅ Phase 4 — `hm1nd` manager binary + manager socket + declarative `containers.hm1nd` +- ✅ Phase 5 — git-commit approval flow: + - 5a — sqlite approval queue (`request_apply_commit` / `pending` / `approve` / `deny`) + - 5b — per-agent config flakes at `/var/lib/hyperhive/agents//config/`; + `approve ` validates the commit + advances `main` + rebuilds - 🔜 Phase 6 — per-agent web UI + dashboard MVP - 🔜 Phase 7 — dashboard commit-view + polish +## Approval flow (Phase 5) + +End-to-end: manager edits per-agent config repo → commits → submits commit sha +for approval → user approves on host CLI → `hive-c0re` advances `main` + rebuilds. + +``` +# Inside the hm1nd container (manager has /agents bind-mounted RW): +cd /agents/alice/config +$EDITOR agent.nix # add `environment.systemPackages = [ pkgs.htop ];` +git commit -am "add htop" +SHA=$(git rev-parse HEAD) +hive-m1nd request-apply-commit alice $SHA +exit + +# On the host: +sudo hive-c0re pending # shows the queued approval with id N +sudo hive-c0re approve N # validates, advances main, rebuilds h-alice +sudo nixos-container run h-alice -- which htop # /run/current-system/sw/bin/htop +``` + +Per-agent flake layout (generated by `setup_config` on every spawn / rebuild): + +``` +/var/lib/hyperhive/agents//config/ +├── .git/ +├── flake.nix # managed by hive-c0re — rewritten when hyperhive flake URL changes +└── agent.nix # manager-editable; per-agent NixOS overrides +``` + +The flake's `inputs.hyperhive.url` is the same URL `hive-c0re` was launched with +(`services.hive-c0re.hyperhiveFlake`), inlined as a string. The flake's +`nixosConfigurations.default` extends `hyperhive.nixosConfigurations.agent-base` +with `./agent.nix`. So adding packages is a one-line edit in `agent.nix`. + See PLAN.md for the full design and the deferred-out-of-scope list. ## Inspirations 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 5/7] 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 caed7aa6..5f7a0295 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 3acee14f..0dc4672b 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 6f4e6be6..408bc4a5 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 cbca5a8a..6c1acb7a 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 } From 967ec7c9d7be0abb1aa72502f8974ea2f9cea1f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Thu, 14 May 2026 23:22:00 +0200 Subject: [PATCH 6/7] fmt --- hive-c0re/src/lifecycle.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 0dc4672b..fd3555f5 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -173,11 +173,7 @@ pub async fn setup_applied(applied_dir: &Path, name: &str, hyperhive_flake: &str /// 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<()> { +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")]) From 2a33697cda3df16b3ac40af25862c35224463a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Thu, 14 May 2026 23:24:28 +0200 Subject: [PATCH 7/7] CLAUDE.md: Phase 5c split --- CLAUDE.md | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0190186f..02030052 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,8 +143,11 @@ in. - ✅ Phase 4 — `hm1nd` manager binary + manager socket + declarative `containers.hm1nd` - ✅ Phase 5 — git-commit approval flow: - 5a — sqlite approval queue (`request_apply_commit` / `pending` / `approve` / `deny`) - - 5b — per-agent config flakes at `/var/lib/hyperhive/agents//config/`; - `approve ` validates the commit + advances `main` + rebuilds + - 5b — per-agent config flakes (proposed + applied repos) + - 5c — split: manager edits `proposed`, hive-c0re writes-only `applied`; the + container builds from `applied`. Approve = read `agent.nix` at the + approved commit from `proposed`, copy into `applied`, commit + rebuild. + Manager cannot move `main` on its own. - 🔜 Phase 6 — per-agent web UI + dashboard MVP - 🔜 Phase 7 — dashboard commit-view + polish @@ -168,19 +171,30 @@ sudo hive-c0re approve N # validates, advances main, rebuilds h-alice sudo nixos-container run h-alice -- which htop # /run/current-system/sw/bin/htop ``` -Per-agent flake layout (generated by `setup_config` on every spawn / rebuild): +Per-agent layout — two separate git repos: ``` -/var/lib/hyperhive/agents//config/ +/var/lib/hyperhive/agents//config/ # proposed — manager edits, hive-c0re reads only ├── .git/ -├── flake.nix # managed by hive-c0re — rewritten when hyperhive flake URL changes -└── agent.nix # manager-editable; per-agent NixOS overrides +└── agent.nix # the only file the manager can change + # (initial commit by hive-c0re on first spawn, + # never touched by hive-c0re again) + +/var/lib/hyperhive/applied// # applied — hive-c0re-only; container builds here +├── .git/ +├── flake.nix # hive-c0re-managed; references hyperhive_flake +└── agent.nix # overwritten by approve from the proposed commit ``` -The flake's `inputs.hyperhive.url` is the same URL `hive-c0re` was launched with -(`services.hive-c0re.hyperhiveFlake`), inlined as a string. The flake's +The container's `--flake` ref is `#default`. The flake's `nixosConfigurations.default` extends `hyperhive.nixosConfigurations.agent-base` -with `./agent.nix`. So adding packages is a one-line edit in `agent.nix`. +with `./agent.nix` plus an inline module setting `environment.etc."gitconfig".text` +with the agent's name as the git committer identity. + +On approve: `git show :agent.nix` from `proposed/`, write the bytes +into `applied//agent.nix`, commit there as `hive-c0re`, then +`nixos-container update`. The manager can only propose; only hive-c0re advances +`applied`'s `main`. See PLAN.md for the full design and the deferred-out-of-scope list.