hive-c0re: seed an agent's proposed config from the forge when it exists

The swarm writes agent-configs/<agent> when it creates an agent, before
any hive is told to deploy it. setup_proposed authored a second copy of
those same bytes locally, so an agent's initial config had two sources
of truth, each unaware of the other and free to disagree. It now clones
that repo and falls back to the template only when there is nothing
there to take.

Preferred-source rather than a new-path-only variant because
provision_container is the Provision node for the swarm deploy and the
approval flow both, and cannot tell them apart. The approval flow
creates agent-configs/<agent> only after the first spawn
(forge_after_first_spawn), so it finds nothing and lands on the
template: the fallback becomes unreachable when hive-level create is
removed, rather than becoming something someone has to find and delete.

clone, not the neighbouring init+fetch. A failed fetch leaves an empty
.git behind, and that .git is exactly the byte setup_proposed reads to
decide whether seeding is still needed, so the fallback would have seen
a seeded repo. git removes a directory it created when a clone fails.
--branch main also makes an empty repo fail cleanly instead of cloning
to an unborn HEAD that would look seeded.
This commit is contained in:
atlas 2026-08-30 23:46:19 +02:00 committed by mara
commit ccf9951e5d
5 changed files with 118 additions and 27 deletions

View file

@ -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};

View file

@ -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/<name>` 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/<name>` 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 <url> <refspec>` 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.

View file

@ -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/<name>` 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()

View file

@ -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");

View file

@ -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