diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index 5dfdb81e..67b8ad73 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -17,9 +17,9 @@ pub use pr_merge::{ }; pub use reconcile::{reconcile_config_apply, reconcile_config_status}; pub use repos::{ - create_agent_repo, ensure_config_repo, ensure_knowledge_repo, ensure_meta_remote, ensure_repo, - ensure_shared_docs_repo, fetch_config_main_into_applied, meta_read_access, push_config, - push_meta, shared_docs_access, + clone_config_into_proposed, create_agent_repo, ensure_config_repo, ensure_knowledge_repo, + ensure_meta_remote, ensure_repo, ensure_shared_docs_repo, fetch_config_main_into_applied, + meta_read_access, push_config, push_meta, shared_docs_access, }; pub use users::{core_token, ensure_user_for, provision_user_token}; diff --git a/hive-c0re/src/forge/repos.rs b/hive-c0re/src/forge/repos.rs index ed80387c..74a7252e 100644 --- a/hive-c0re/src/forge/repos.rs +++ b/hive-c0re/src/forge/repos.rs @@ -593,6 +593,68 @@ pub async fn fetch_config_main_into_applied(name: &str) -> bool { true } +/// Seed an agent's `proposed` repo from `agent-configs/` on the forge. +/// +/// When the swarm creates an agent it writes that agent's config to the forge +/// before any hive is told to deploy it, so the hive's job here is to **take** +/// that config rather than author a second one beside it. The local template +/// is the fallback, not the default. +/// +/// Best-effort like [`fetch_config_main_into_applied`], and `false` is an +/// ordinary answer rather than an error: the forge is absent, the core token +/// is not minted yet, or `agent-configs/` has no `main` — the last of +/// which is the *normal* case for the hive-level create flow, where the repo +/// is only created after the first spawn (`actions::forge_after_first_spawn`). +/// +/// `clone` rather than `init` + `fetch`: git removes a directory it created +/// when the clone fails, and cloning into an existing empty dir leaves no +/// `.git` behind either — so the caller's "does this repo already exist" +/// check reads exactly the same before and after a failed attempt. +pub async fn clone_config_into_proposed(dir: &Path, name: &str) -> bool { + if !is_present().await { + return false; + } + let Some(token) = core_token() else { + return false; + }; + let url = forge_git_url(&format!("{CONFIG_ORG}/{name}")); + // `--branch main` fails outright against a repo with no commits, so an + // empty repo reads as "nothing to take" instead of cloning to an unborn + // HEAD that would then look like a seeded checkout. + let out = crate::lifecycle::git_command_authed(&core_auth_header(&token)) + .args([ + "clone", + "--branch", + "main", + &url, + &dir.display().to_string(), + ]) + .output() + .await; + match out { + Ok(o) if o.status.success() => { + tracing::info!(%name, "forge: seeded proposed repo from agent-configs main"); + true + } + Ok(o) => { + // `info`, not `warn`: on the hive-level create flow every first + // spawn lands here and nothing is wrong. Logged at all because + // "where did this agent's config come from" is worth answering + // from the journal, and proposed is seeded exactly once. + tracing::info!( + %name, + stderr = %String::from_utf8_lossy(&o.stderr).trim(), + "forge: no config to clone for this agent; seeding the template" + ); + false + } + Err(e) => { + tracing::warn!(%name, error = ?e, "forge: proposed clone failed to run"); + false + } + } +} + /// Run a single `git push ` in the applied repo `dir` and /// return the raw output for the caller to classify. Split out so /// [`push_config`] can push tags and `main` as independent pushes. diff --git a/hive-c0re/src/lifecycle/setup.rs b/hive-c0re/src/lifecycle/setup.rs index e61b8950..b695c23d 100644 --- a/hive-c0re/src/lifecycle/setup.rs +++ b/hive-c0re/src/lifecycle/setup.rs @@ -14,12 +14,18 @@ use super::git::{ git, git_command, git_commit, git_read_tree_reset, git_rev_parse, git_root_commit, git_tag, }; -/// Initialize an agent's config repo. Seeds two tracked files: -/// `agent.nix` (the agent's own module) and `flake.nix` (the -/// boilerplate that lets the meta flake import this repo as an input — -/// meta locks at a specific sha and reads `nixosModules.default`, so -/// `flake.nix` must be in the commit). `flake.nix` isn't meant to be -/// edited, but it's tracked so it can be read. +/// Initialize an agent's config repo, preferring the one the forge +/// already has. `agent-configs/` is authored by whoever created +/// the agent — at swarm level that is the controller, which writes the +/// config before any hive is told to deploy it — so this **clones** +/// when there is something to clone and seeds a template only when +/// there is not. Two authors for one file is the failure mode being +/// avoided: whichever wrote second would win, and neither knows about +/// the other. +/// +/// The template ([`seed_template`]) is what the hive-level create flow +/// still lands on, since that flow creates the forge repo *after* the +/// first spawn. /// /// **Seeding is the whole of hive-c0re's write.** Changes to the repo /// arrive as PRs from a clone, via the forge, like any other code @@ -34,19 +40,9 @@ pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> { if fresh { 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()))?; + if !crate::forge::clone_config_into_proposed(proposed_dir, name).await { + seed_template(proposed_dir, name).await?; } - let flake_path = proposed_dir.join("flake.nix"); - if !flake_path.exists() { - std::fs::write(&flake_path, initial_flake_nix()) - .with_context(|| format!("write {}", flake_path.display()))?; - } - git(proposed_dir, &["init", "--initial-branch=main"]).await?; - git(proposed_dir, &["add", "agent.nix", "flake.nix"]).await?; - git_commit(proposed_dir, "hive-c0re init").await?; } // Idempotently wire the `applied` remote — purely for the // manager's ergonomics. The URL is the path inside the manager @@ -56,6 +52,30 @@ pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> { ensure_applied_remote(proposed_dir, name).await } +/// Author the initial config in place: `agent.nix` (the agent's own module) +/// and `flake.nix` (the boilerplate that lets the meta flake import this repo +/// as an input — meta locks at a specific sha and reads +/// `nixosModules.default`, so `flake.nix` must be in the commit). `flake.nix` +/// isn't meant to be edited, but it's tracked so it can be read. +/// +/// The fallback half of [`setup_proposed`]: reached only when the forge has +/// no config for this agent to take. +async fn seed_template(proposed_dir: &Path, name: &str) -> Result<()> { + 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()))?; + } + let flake_path = proposed_dir.join("flake.nix"); + if !flake_path.exists() { + std::fs::write(&flake_path, initial_flake_nix()) + .with_context(|| format!("write {}", flake_path.display()))?; + } + git(proposed_dir, &["init", "--initial-branch=main"]).await?; + git(proposed_dir, &["add", "agent.nix", "flake.nix"]).await?; + git_commit(proposed_dir, "hive-c0re init").await +} + async fn ensure_applied_remote(proposed_dir: &Path, name: &str) -> Result<()> { let want = format!("/applied/{name}/.git"); let existing = git_command() diff --git a/hive-c0re/src/lifecycle/tests.rs b/hive-c0re/src/lifecycle/tests.rs index 81e925eb..c9734efc 100644 --- a/hive-c0re/src/lifecycle/tests.rs +++ b/hive-c0re/src/lifecycle/tests.rs @@ -6,6 +6,10 @@ use super::*; /// Regression test: `setup_proposed` must seed both agent.nix and flake.nix /// in the initial commit. Before commit 5b5a93e flake.nix was missing from /// the scaffold, requiring manual creation (seen with the damocles agent). +/// +/// Exercises the template arm: there is no forge to clone from in a test +/// (`forge::is_present` needs the priv socket), so `setup_proposed` falls +/// through to `seed_template` — which is the arm this asserts about. #[tokio::test] async fn setup_proposed_seeds_flake_nix() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/swarm-controller/src/forge.rs b/swarm-controller/src/forge.rs index 74f38b25..ca4271a9 100644 --- a/swarm-controller/src/forge.rs +++ b/swarm-controller/src/forge.rs @@ -380,12 +380,17 @@ impl Client { /// Seed `repo` with the two files every agent config repo needs: /// `agent.nix` (the agent's own module) and `flake.nix` (the /// boilerplate that lets the meta flake import this repo as a flake - /// input) — same content `hive-c0re::lifecycle::setup::setup_proposed` - /// writes at the per-hive level, committed here in one atomic - /// `repo_change_files` call instead of a local `git commit` (this - /// process has no working tree to commit from — it only ever talks to - /// the forge over HTTP). The whole job of the `InitAgentConfigRepo` - /// node. + /// input) — committed here in one atomic `repo_change_files` call + /// instead of a local `git commit` (this process has no working tree to + /// commit from — it only ever talks to the forge over HTTP). The whole + /// job of the `InitAgentConfigRepo` node. + /// + /// **This is the agent's config, not a copy of it.** A hive told to + /// deploy the agent clones this repo + /// (`hive-c0re::forge::clone_config_into_proposed`) rather than writing + /// the same files again locally; the byte-identical template in + /// `hive-c0re::lifecycle::setup::seed_template` is only reached when no + /// repo exists here to clone. /// /// Idempotent by construction rather than by catching a conflict: an /// unconditional `repo_change_files` against an already-seeded repo