Watch
0
0
Fork
You've already forked hyperhive
0

drop the proposed config repo: applied seeds from forge main, and the dead pending-init topology list goes with it

This commit is contained in:
damocles 2026-11-01 14:25:46 +01:00 • committed by mara
commit 25295fd33f
17 changed files with 225 additions and 520 deletions

View file

@ -119,7 +119,7 @@ async fn ff_applied_main(
}
/// Tear down a sub-agent container. By default this is non-destructive to
/// persistent state: the proposed/applied config repos and the Claude
/// persistent state: the applied config repo and the Claude
/// credentials dir under `/var/lib/hyperhive/{agents,applied}/<name>/` are
/// kept, so recreating an agent of the same name reuses prior config + creds
/// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/`

View file

@ -102,19 +102,14 @@ pub fn all_agents_in(topo: &BTreeSet<String>) -> Vec<String> {
/// caller. The write is atomic, as a sorted array (`BTreeSet` iterates in
/// key order) to keep git diffs minimal.
///
/// `pending` lists agents that have a provisioned proposed config repo
/// but no container yet (provisioned, not yet spawned). They are KEPT
/// (not dropped) so an agent that exists on disk but has never booted is
/// still a name the hive knows about.
///
/// Fails without writing when the existing file can't be read or parsed:
/// rebuilding it from the live set alone would drop every pending name.
pub fn reconcile(agent_names: &[String], pending: &[String]) -> std::io::Result<bool> {
reconcile_at(&topology_path(), agent_names, pending)
/// rebuilding it from the live set alone would silently rewrite it.
pub fn reconcile(agent_names: &[String]) -> std::io::Result<bool> {
reconcile_at(&topology_path(), agent_names)
}
fn reconcile_at(path: &Path, agent_names: &[String], pending: &[String]) -> std::io::Result<bool> {
let (next, changed) = apply_reconcile(&read_from(path)?, agent_names, pending);
fn reconcile_at(path: &Path, agent_names: &[String]) -> std::io::Result<bool> {
let (next, changed) = apply_reconcile(&read_from(path)?, agent_names);
if changed {
super::write_map(path, &next)?;
}
@ -122,13 +117,12 @@ fn reconcile_at(path: &Path, agent_names: &[String], pending: &[String]) -> std:
}
/// Pure form of [`reconcile`] for unit tests. Adds missing live agents,
/// drops entries for agents that are neither live nor pending-init, and
/// reports whether anything changed.
/// drops entries for agents that are not live, and reports whether anything
/// changed.
#[must_use]
pub fn apply_reconcile(
current: &BTreeSet<String>,
agent_names: &[String],
pending: &[String],
) -> (BTreeSet<String>, bool) {
let mut next = current.clone();
let mut changed = false;
@ -137,8 +131,7 @@ pub fn apply_reconcile(
changed = true;
}
}
let known: std::collections::HashSet<&String> =
agent_names.iter().chain(pending.iter()).collect();
let known: std::collections::HashSet<&String> = agent_names.iter().collect();
next.retain(|name| {
let keep = known.contains(name);
if !keep {
@ -166,7 +159,7 @@ mod tests {
#[test]
fn apply_reconcile_adds_missing_live_agent() {
let live = vec!["alice".to_owned(), "newbie".to_owned()];
let (next, changed) = apply_reconcile(&BTreeSet::new(), &live, &[]);
let (next, changed) = apply_reconcile(&BTreeSet::new(), &live);
assert!(changed);
assert!(next.contains("newbie"));
assert!(next.contains("alice"));
@ -175,30 +168,17 @@ mod tests {
#[test]
fn apply_reconcile_drops_vanished_agent() {
let live = vec!["alice".to_owned()];
let (next, changed) = apply_reconcile(&roster_three(), &live, &[]);
let (next, changed) = apply_reconcile(&roster_three(), &live);
assert!(changed);
assert!(!next.contains("bob"));
assert!(!next.contains("carol"));
assert!(next.contains("alice"));
}
#[test]
fn apply_reconcile_keeps_pending_init_agent() {
// `dora` was provisioned but has no container yet, so it's absent
// from the live set. It must NOT be dropped.
let mut roster = roster_three();
roster.insert("dora".to_owned());
let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()];
let pending = vec!["dora".to_owned()];
let (next, changed) = apply_reconcile(&roster, &live, &pending);
assert!(!changed, "no change expected: {next:?}");
assert!(next.contains("dora"));
}
#[test]
fn apply_reconcile_is_a_noop_when_already_in_sync() {
let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()];
let (next, changed) = apply_reconcile(&roster_three(), &live, &[]);
let (next, changed) = apply_reconcile(&roster_three(), &live);
assert!(!changed);
assert_eq!(next, roster_three());
}
@ -255,8 +235,7 @@ mod tests {
fn reconcile_leaves_a_corrupt_file_untouched() {
let (_dir, path) = corrupt_file();
let live = vec!["alice".to_owned(), "bob".to_owned()];
let err =
reconcile_at(&path, &live, &[]).expect_err("a corrupt file must not be rewritten");
let err = reconcile_at(&path, &live).expect_err("a corrupt file must not be rewritten");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert_eq!(std::fs::read(&path).expect("read"), TRUNCATED.as_bytes());
}
@ -273,7 +252,7 @@ mod tests {
let path = dir.path().join(TOPOLOGY_FILE);
assert!(read_from(&path).expect("missing file").is_empty());
let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()];
assert!(reconcile_at(&path, &live, &[]).expect("reconcile"));
assert!(reconcile_at(&path, &live).expect("reconcile"));
assert_eq!(read_from(&path).expect("read"), roster_three());
let entries: Vec<_> = std::fs::read_dir(dir.path())
.expect("read_dir")

View file

@ -37,11 +37,7 @@ pub async fn refresh(agents: &[hive_meta_flake::AgentSpec]) -> Result<()> {
};
remove_stale_ca_files(&dir);
let pending: Vec<String> = crate::coordinator::Coordinator::pending_init_names()
.into_iter()
.map(hive_types::Ident::into_string)
.collect();
crate::topology::reconcile(&agent_names, &pending)
crate::topology::reconcile(&agent_names)
.with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?;
// Every agent binds a unix socket (`HIVE_WEB_SOCKET`); the gateway routes

View file

@ -286,8 +286,6 @@ mod serve_config_tests {
pub struct AgentPaths {
/// Runtime socket dir (tmpfs, recreated per boot).
pub agent: PathBuf,
/// Manager-editable proposed config repo.
pub proposed: PathBuf,
/// Hive-c0re-authoritative applied config repo.
pub applied: PathBuf,
/// Claude OAuth credentials (survives purge boundary).
@ -438,7 +436,6 @@ impl Coordinator {
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
Ok(AgentPaths {
agent: agent_dir,
proposed: Self::agent_proposed_dir(&name),
applied: crate::paths::applied_dir(name.as_str()),
claude: Self::agent_claude_dir(&name),
notes: Self::agent_notes_dir(&name),
@ -829,17 +826,6 @@ impl Coordinator {
crate::paths::agent_runtime_dir(name).join("mcp.sock")
}
/// The *proposed* config repo: where a config change lands before it is
/// applied, and what an approved deploy promotes into `applied_dir`.
///
/// **Not bind-mounted into any container.** An agent that edits a config
/// clones it from the forge itself; `/agents/<name>/config` shows the
/// applied (deployed) tree instead — see `config_bind_source` in
/// `lifecycle/host_config.rs`.
pub fn agent_proposed_dir(name: &hive_types::Ident) -> PathBuf {
crate::paths::agent_state_dir(name).join("config")
}
/// Per-agent Claude credentials dir. Bind-mounted RW into the agent
/// container at `/root/.claude` so OAuth state survives container
/// destroy/recreate. Each agent owns its own token lineage — sharing
@ -950,23 +936,6 @@ impl Coordinator {
out.sort();
out
}
/// Agents that have an operator-approved proposed config repo but
/// were never deployed — proposed `.git` exists, applied `.git` does
/// not. Their `topology.json` parent edge (written before the first
/// spawn) must survive `topology::reconcile` until the first
/// apply-commit spawns the container. Distinct from tombstones,
/// which have an applied repo from a prior deploy.
#[must_use]
pub fn pending_init_names() -> Vec<hive_types::Ident> {
Self::kept_state_names()
.into_iter()
.filter(|n| {
Self::agent_proposed_dir(n).join(".git").exists()
&& !crate::paths::applied_dir(n.as_str()).join(".git").exists()
})
.collect()
}
}
/// `push_todo` summary text for a rebuild outcome, for

View file

@ -37,7 +37,7 @@ pub struct TombstoneView {
/// ones** — a mid-spawn agent (state dir seeded by `Provision`, container not
/// yet made by `Create`) is indistinguishable from a tombstone here, because
/// *nothing records a destroy*. Every definition-side artifact — state
/// subvolume, proposed + applied repos, `deployed/0`, meta registration,
/// subvolume, applied repo, `deployed/0`, meta registration,
/// topology entry — is written before the container exists and survives
/// `lifecycle::destroy`, which removes the container and leaves the rest.
///

View file

@ -12,7 +12,7 @@ mod repos;
pub(crate) use internal_meta::internal_meta_source;
pub use reader::reader_token;
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
pub use repos::{clone_config_into_proposed, fetch_config_main_into_applied};
pub use repos::fetch_config_main_into_applied;
use std::future::IntoFuture;
use std::sync::OnceLock;

View file

@ -1,26 +1,23 @@
//! Repo plumbing on the local Forgejo: reseeding an agent's `applied` and
//! `proposed` repos from `agent-configs/<name>`. The typed API-client
//! constructor + org-name constants live in the module root (`super`).
use std::path::Path;
//! Repo plumbing on the local Forgejo: seeding an agent's `applied` repo from
//! `agent-configs/<name>`. The typed API-client constructor + org-name
//! constants live in the module root (`super`).
use super::{CONFIG_ORG, forge_git_url, reader_token};
/// Reseed a revived agent's `applied` repo — missing its `.git` entirely,
/// e.g. after `destroy --purge` — by fetching `agent-configs/<name>`'s
/// `main` from the forge. Unlike `proposed` (seeded once at first spawn and
/// never touched again — see `lifecycle::setup_proposed`'s own doc
/// comment), forge `main` advances with every operator-merged config PR, so
/// this reflects the last merged config rather than a stale creation-time
/// snapshot. A config deployed through hive-c0re without a forge merge never
/// reaches it.
/// Seed an agent's `applied` repo, missing its `.git` entirely (first spawn,
/// or a revival after `destroy --purge`), by fetching `agent-configs/<name>`'s
/// `main` from the forge. Forge `main` advances with every operator-merged
/// config PR, so this reflects the last merged config. A config deployed
/// through hive-c0re without a forge merge never reaches it.
///
/// Best-effort: returns `false` (not an error) rather than bailing when there
/// is no [`reader_token`], `applied` already has a `.git` (nothing to do), or
/// any git step fails — callers fall back to a less-current source or the
/// original hard error. Only `true` once `applied` is
/// actually seeded and tagged `deployed/0`, mirroring first-spawn's own
/// `setup_applied` seed shape exactly.
/// is no [`reader_token`], `applied` already has a `.git` (nothing to do), the
/// forge has no `main` for the agent yet (the normal case right after
/// `swarm-controller` creates it, before it has pushed anything), or any git
/// step fails. Callers fall back to the template or the original hard error. A
/// failed seed leaves no `.git` behind, so a later "is it seeded" check reads
/// the same as before the attempt. Only `true` once `applied` is seeded and
/// tagged `deployed/0`.
pub async fn fetch_config_main_into_applied(name: &str) -> bool {
let Some(reader) = reader_token().await else {
return false;
@ -29,111 +26,60 @@ pub async fn fetch_config_main_into_applied(name: &str) -> bool {
if applied.join(".git").exists() {
return false;
}
if let Err(e) = std::fs::create_dir_all(&applied) {
tracing::warn!(%name, error = ?e, "forge: applied reseed mkdir failed");
return false;
}
if let Err(e) = crate::lifecycle::git(&applied, &["init", "--initial-branch=main"]).await {
tracing::warn!(%name, error = ?e, "forge: applied reseed init failed");
return false;
}
let url = forge_git_url(&format!("{CONFIG_ORG}/{name}"));
let auth = reader.auth_header();
let out = crate::lifecycle::git_command_authed(&auth)
.current_dir(&applied)
.args([
"fetch",
"--no-tags",
"--update-head-ok",
&url,
"refs/heads/main:refs/heads/main",
])
.output()
.await;
match out {
Ok(o) if o.status.success() => {}
Ok(o) => {
tracing::warn!(
%name,
stderr = %String::from_utf8_lossy(&o.stderr).trim(),
"forge: applied reseed fetch failed"
);
let seeded = async {
if let Err(e) = std::fs::create_dir_all(&applied) {
tracing::warn!(%name, error = ?e, "forge: applied reseed mkdir failed");
return false;
}
Err(e) => {
tracing::warn!(%name, error = ?e, "forge: applied reseed fetch failed");
if let Err(e) = crate::lifecycle::git(&applied, &["init", "--initial-branch=main"]).await {
tracing::warn!(%name, error = ?e, "forge: applied reseed init failed");
return false;
}
let url = forge_git_url(&format!("{CONFIG_ORG}/{name}"));
let auth = reader.auth_header();
let out = crate::lifecycle::git_command_authed(&auth)
.current_dir(&applied)
.args([
"fetch",
"--no-tags",
"--update-head-ok",
&url,
"refs/heads/main:refs/heads/main",
])
.output()
.await;
match out {
Ok(o) if o.status.success() => {}
Ok(o) => {
// `info`, not `warn`: the normal case right after
// `swarm-controller` creates the agent, before it has pushed
// anything into the repo it just made, and nothing is wrong.
tracing::info!(
%name,
stderr = %String::from_utf8_lossy(&o.stderr).trim(),
"forge: no config to fetch for this agent yet"
);
return false;
}
Err(e) => {
tracing::warn!(%name, error = ?e, "forge: applied reseed fetch failed");
return false;
}
}
if let Err(e) = crate::lifecycle::git_read_tree_reset(&applied, "refs/heads/main").await {
tracing::warn!(%name, error = ?e, "forge: applied reseed read-tree failed");
return false;
}
if let Err(e) = crate::lifecycle::git_tag(&applied, "deployed/0", "refs/heads/main").await {
tracing::warn!(%name, error = ?e, "forge: applied reseed tag failed");
return false;
}
tracing::info!(%name, "forge: seeded applied repo from agent-configs main");
true
}
if let Err(e) = crate::lifecycle::git_read_tree_reset(&applied, "refs/heads/main").await {
tracing::warn!(%name, error = ?e, "forge: applied reseed read-tree failed");
return false;
}
if let Err(e) = crate::lifecycle::git_tag(&applied, "deployed/0", "refs/heads/main").await {
tracing::warn!(%name, error = ?e, "forge: applied reseed tag failed");
return false;
}
tracing::info!(%name, "forge: reseeded applied repo from agent-configs main");
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: there is no [`reader_token`], the
/// forge is unreachable, or `agent-configs/<name>` has no `main` — the last of
/// which is the *normal* case right after `swarm-controller` creates the
/// agent, before it has pushed anything into the repo it just made.
///
/// `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 {
let Some(reader) = reader_token().await 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(&reader.auth_header())
.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`: the normal case right after
// `swarm-controller` creates the agent, before it has pushed
// anything into the repo it just made, 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
}
.await;
if !seeded {
let _ = std::fs::remove_dir_all(applied.join(".git"));
}
seeded
}

View file

@ -731,7 +731,7 @@ async fn run_rebuild_bookkeeping(
Ok(())
}
/// First-spawn pre-create provisioning: proposed/applied repos, state
/// First-spawn pre-create provisioning: the applied repo, state
/// subvolume, and the agent-set refresh.
async fn run_provision(host: &impl Host, name: &str) -> Result<()> {
let agent_dir = crate::paths::agent_runtime_dir(name);

View file

@ -282,7 +282,7 @@ pub fn rebuild(builder: &JobBuilder, agent: &str) -> Vec<hive_jobq::NodeGuid> {
}
/// First deploy of an agent this hive has never seen, asked for by the swarm:
/// `Provision` (proposed/applied repos, state subvolume, agent-set registration)
/// `Provision` (applied repo, state subvolume, agent-set registration)
/// then `Create` (`nixos-container create`), drop-in write, then `Reconcile`
/// starts the container (`wanted = Up`, seeded by
/// `swarm_status::queue_first_deploy`). All-or-nothing: `Provision`

View file

@ -1,4 +1,4 @@
//! Git shellout helpers for the per-agent proposed/applied repos: run
//! Git shellout helpers for the per-agent applied repo: run
//! `git` with the hive-c0re identity, resolve/plant refs and tags, and
//! fetch proposal commits into the applied repo.
@ -71,25 +71,6 @@ async fn drain<R: AsyncRead + Unpin>(pipe: Option<R>) -> std::io::Result<Vec<u8>
const GIT_NAME: &str = "c0re";
const GIT_EMAIL: &str = "c0re@hyperhive.local";
/// Return the SHA of the root (oldest, no-parent) commit in a repo.
/// Used to seed the applied repo at the template baseline rather than at
/// `main`, so `deployed/0` records the template, not the manager's first commit.
pub(super) async fn git_root_commit(dir: &Path) -> Result<String> {
let out = git_command()
.current_dir(dir)
.args(["rev-list", "--max-parents=0", "HEAD"])
.output()
.await
.with_context(|| format!("git rev-list --max-parents=0 HEAD in {}", dir.display()))?;
if !out.status.success() {
anyhow::bail!(
"git rev-list --max-parents=0 failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
}
pub(super) async fn git_commit(dir: &Path, message: &str) -> Result<()> {
git(
dir,

View file

@ -338,7 +338,7 @@ fn config_bind_source(name: &str) -> PathBuf {
///
/// ⚠️ The config-repo seeding hive-c0re does at spawn is **not** affected by
/// the `config` flag and must not be read as a reason to widen it: that runs
/// as hive-c0re against the host path (see `lifecycle::setup_proposed`), and
/// as hive-c0re against the host path (see `lifecycle::setup_applied`), and
/// `read_only` on a bind constrains writers *inside* the container only.
fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
let Ok(child) = hive_types::Ident::parse(child) else {

View file

@ -18,7 +18,7 @@ pub use git::{
pub use host_config::write_dropins;
pub use setup::{
ensure_agent_state_subvolume, ensure_claude_dir, ensure_state_dir, initial_flake_nix,
setup_applied, setup_proposed,
setup_applied,
};
use anyhow::{Context, Result, bail};
@ -205,8 +205,8 @@ async fn port_collision(self_name: &str) -> Result<Option<String>> {
}
/// First-spawn pre-create provisioning — the job queue's `Provision`
/// node. Fail fast on a port collision, set up the proposed/applied
/// repos + state subvolume + claude/notes dirs, then register the new
/// node. Fail fast on a port collision, set up the applied
/// repo + state subvolume + claude/notes dirs, then register the new
/// agent in the host-side files that follow the agent set
/// ([`crate::agent_set::refresh`]). Does NOT
/// create the container — that's `create_only` / the `Create` node.
@ -222,8 +222,7 @@ pub async fn provision_container(name: &str, paths: &AgentPaths) -> Result<()> {
// repo, and the build takes it from the archetype.
crate::archetypes::refresh(name).await?;
if crate::archetypes::of(name)?.is_none() {
setup_proposed(&paths.proposed, name).await?;
setup_applied(&paths.applied, Some(&paths.proposed), name).await?;
setup_applied(&paths.applied, name).await?;
}
ensure_agent_state_subvolume(name).await?;
ensure_claude_dir(&paths.claude)?;
@ -250,12 +249,9 @@ pub async fn create_only(name: &str, toplevel: &str) -> Result<()> {
/// in `provision_container`. Recover from `agent-configs/<name>` on the
/// forge when that's the case (forge `main` is the last operator-merged
/// config — see `forge::fetch_config_main_into_applied`'s own doc comment)
/// rather than bailing outright. Deliberately **no fallback to
/// `proposed`**: that repo is seeded once at first spawn and never touched
/// again (`setup_proposed`'s own doc comment), so it can be arbitrarily
/// stale for a long-lived agent — restoring from it would silently revive
/// the *wrong* config rather than recovering the right one, which is worse
/// than the clear bail this replaces.
/// rather than bailing outright. Deliberately **no template fallback**: a
/// template would silently revive the *wrong* config rather than recovering
/// the right one, which is worse than the clear bail this replaces.
pub async fn prepare_rebuild_dirs(name: &str, paths: &AgentPaths) -> Result<()> {
validate(name)?;
if let Some(other) = port_collision(name).await? {
@ -276,7 +272,7 @@ pub async fn prepare_rebuild_dirs(name: &str, paths: &AgentPaths) -> Result<()>
paths.applied.display()
);
}
setup_applied(&paths.applied, None, name).await?;
setup_applied(&paths.applied, name).await?;
}
ensure_agent_state_subvolume(name).await?;
ensure_claude_dir(&paths.claude)?;

View file

@ -1,159 +1,53 @@
//! First-spawn provisioning: seed the agent's proposed config repo and
//! the hive-c0re-owned applied repo, and ensure the per-agent state /
//! claude-credentials dirs (btrfs subvolume when available) exist.
//! First-spawn provisioning: seed the hive-c0re-owned applied repo, and
//! ensure the per-agent state / claude-credentials dirs (btrfs subvolume when
//! available) exist.
//!
//! Seeding is the only write either repo gets from here. Later config
//! changes arrive as forge PRs authored in a clone — nothing edits a
//! working tree in place.
//! Seeding is the only write the repo gets from here. Later config changes
//! arrive as forge PRs authored in a clone - nothing edits a working tree in
//! place.
use std::path::Path;
use anyhow::{Context, Result, bail};
use super::git::{
git, git_command, git_commit, git_read_tree_reset, git_rev_parse, git_root_commit, git_tag,
};
use super::git::{git, git_commit, git_rev_parse, git_tag};
/// 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.
/// Seed an agent's applied repo on first spawn, preferring the config the
/// forge already has. `agent-configs/<name>` is authored by whoever created
/// the agent (at swarm level the controller, which writes the config before
/// any hive is told to deploy it), so this takes the forge's `main` when
/// there is one and writes 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 this still falls back to when
/// there's genuinely nothing to clone yet — `swarm-controller` creates
/// `agent-configs/<name>` before telling a hive to deploy, but hasn't
/// necessarily pushed `main` to it yet, or the forge is unreachable, or
/// this hive has no forge read token.
/// The template ([`seed_template`]) is the fallback when there is genuinely
/// nothing to take yet: `swarm-controller` creates `agent-configs/<name>`
/// before telling a hive to deploy, but hasn't necessarily pushed `main` to
/// it, or the forge is unreachable, or this hive has no forge read token.
///
/// **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
/// change — nothing edits a working tree in place, here or in the
/// agent's bind-mounted `config/` (which is a read-only copy).
/// Seeding is the only write hive-c0re makes here. Later config changes
/// arrive as forge PRs and are fetched from the forge's `main` (see
/// `actions::advance_applied_to_rev`). `flake.nix` is tracked in the seed
/// and rides along on every fetch.
///
/// Touched by hive-c0re only on first spawn — never again — so a
/// hive-c0re commit or working-tree reset can't surprise anyone
/// holding a clone.
pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> {
let fresh = !proposed_dir.join(".git").exists();
if fresh {
std::fs::create_dir_all(proposed_dir)
.with_context(|| format!("create {}", proposed_dir.display()))?;
if !crate::forge::clone_config_into_proposed(proposed_dir, name).await {
seed_template(proposed_dir, name).await?;
}
}
// Idempotently wire the `applied` remote — purely for the
// manager's ergonomics. The URL is the path inside the manager
// container (`/applied/<n>/.git`), where the RO bind in
// `set_nspawn_flags` makes it real. hive-c0re itself never
// dereferences this remote.
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()
.current_dir(proposed_dir)
.args(["remote", "get-url", "applied"])
.output()
.await
.with_context(|| format!("git remote get-url applied in {}", proposed_dir.display()))?;
if existing.status.success() {
let current = String::from_utf8_lossy(&existing.stdout).trim().to_owned();
if current == want {
return Ok(());
}
// URL drifted (path scheme changed, etc.) — re-point it.
return git(proposed_dir, &["remote", "set-url", "applied", &want]).await;
}
git(proposed_dir, &["remote", "add", "applied", &want]).await
}
/// Set up the applied repo. First-spawn only: init the repo, pull
/// proposed's initial commit in via `git fetch`, tag it `deployed/0`.
/// This is the *only* time hive-c0re reads from `proposed` for an
/// agent — subsequent config changes are fetched from the forge's `main`
/// (see `actions::advance_applied_to_rev`).
///
/// `proposed_dir` is `None` on rebuild paths where the repo already
/// exists — we just verify it's the right shape and bail otherwise.
/// Unlike the pre-overhaul code path, `flake.nix` is no longer
/// regenerated at the host level: it's tracked in proposed (seeded by
/// `setup_proposed`) and rides along on every fetch.
pub async fn setup_applied(
applied_dir: &Path,
proposed_dir: Option<&Path>,
name: &str,
) -> Result<()> {
/// A repo that is already there is only checked for its shape (a rebuild
/// finds it already seeded): a missing `deployed/0` tag means it predates the
/// meta-flake layout and is an error.
pub async fn setup_applied(applied_dir: &Path, name: &str) -> Result<()> {
std::fs::create_dir_all(applied_dir)
.with_context(|| format!("create {}", applied_dir.display()))?;
if !applied_dir.join(".git").exists() {
let Some(proposed) = proposed_dir else {
bail!(
"applied repo at {} is missing its .git directory; \
cannot rebuild without a proposed source to seed from. \
destroy --purge and re-spawn this agent.",
applied_dir.display()
);
};
git(applied_dir, &["init", "--initial-branch=main"]).await?;
let proposed_str = proposed.display().to_string();
// Seed the applied repo at the root (template) commit of proposed,
// not at `main`, so `deployed/0` is the template baseline rather
// than whatever the manager may have already committed on top.
let root_sha = git_root_commit(proposed).await?;
git(
applied_dir,
// --update-head-ok lets us fetch into refs/heads/main while
// HEAD still points there. git's default safeguard refuses
// to avoid index/working-tree desync, but the working tree
// is empty (we just `init`'d) and we read-tree-reset right
// after, so the safeguard is moot here.
&[
"fetch",
"--no-tags",
"--update-head-ok",
&proposed_str,
&format!("{root_sha}:refs/heads/main"),
],
)
.await?;
git_read_tree_reset(applied_dir, "refs/heads/main").await?;
if crate::forge::fetch_config_main_into_applied(name).await {
return Ok(());
}
seed_template(applied_dir, name).await?;
git_tag(applied_dir, "deployed/0", "refs/heads/main").await?;
} else if git_rev_parse(applied_dir, "refs/tags/deployed/0")
.await
.is_err()
{
// Pre-overhaul applied repo — no deployed/* tag scheme,
// Pre-overhaul applied repo - no deployed/* tag scheme,
// flake.nix may be untracked, agent.nix possibly authored by
// hive-c0re directly. The startup auto-migration fixes this
// in place; if it didn't run (or got skipped), surface a
@ -168,6 +62,30 @@ pub async fn setup_applied(
Ok(())
}
/// 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_applied`]: reached only when the forge has
/// no config for this agent to take.
async fn seed_template(dir: &Path, name: &str) -> Result<()> {
let agent_path = 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 = dir.join("flake.nix");
if !flake_path.exists() {
std::fs::write(&flake_path, initial_flake_nix())
.with_context(|| format!("write {}", flake_path.display()))?;
}
git(dir, &["init", "--initial-branch=main"]).await?;
git(dir, &["add", "agent.nix", "flake.nix"]).await?;
git_commit(dir, "hive-c0re init").await
}
/// Create the per-agent Claude credentials dir if missing. Mode 0755 — hive-core
/// needs read+execute to list the directory so `claude_has_session` can detect a
/// valid session; credential files inside (`.credentials.json` etc.) are 0600 so

View file

@ -3,27 +3,31 @@
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).
/// Regression test: `setup_applied` must seed both agent.nix and flake.nix
/// in the initial commit, and tag it `deployed/0`. 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.
/// Exercises the template arm: there is no forge to take a config from in a
/// test (no reader token), so `setup_applied` falls through to
/// `seed_template` — which is the arm this asserts about.
#[tokio::test]
async fn setup_proposed_seeds_flake_nix() {
async fn setup_applied_seeds_flake_nix() {
let dir = tempfile::tempdir().expect("tempdir");
let proposed = dir.path().join("proposed");
setup_proposed(&proposed, "test-agent")
let applied = dir.path().join("applied");
setup_applied(&applied, "test-agent")
.await
.expect("setup_proposed");
.expect("setup_applied");
git_rev_parse(&applied, "refs/tags/deployed/0")
.await
.expect("the seed commit is tagged deployed/0");
// Both files must exist on disk.
assert!(proposed.join("agent.nix").exists(), "agent.nix missing");
assert!(proposed.join("flake.nix").exists(), "flake.nix missing");
assert!(applied.join("agent.nix").exists(), "agent.nix missing");
assert!(applied.join("flake.nix").exists(), "flake.nix missing");
// flake.nix must export nixosModules.default (the meta-flake contract).
let flake = std::fs::read_to_string(proposed.join("flake.nix")).unwrap();
let flake = std::fs::read_to_string(applied.join("flake.nix")).unwrap();
assert!(
flake.contains("nixosModules.default"),
"flake.nix does not export nixosModules.default"
@ -31,7 +35,7 @@ async fn setup_proposed_seeds_flake_nix() {
// Both files must be tracked in the initial git commit.
let out = git_command()
.current_dir(&proposed)
.current_dir(&applied)
.args(["show", "--name-only", "--format=", "HEAD"])
.output()
.await
@ -128,22 +132,22 @@ fn network_isolation_errors_name_the_offending_variable() {
);
}
/// `setup_proposed` is idempotent: calling it on an existing repo is a
/// no-op (the fresh guard skips all writes).
/// `setup_applied` is idempotent: calling it on an already seeded repo is a
/// no-op (the shape check passes and nothing is written).
#[tokio::test]
async fn setup_proposed_idempotent() {
async fn setup_applied_idempotent() {
let dir = tempfile::tempdir().expect("tempdir");
let proposed = dir.path().join("proposed");
setup_proposed(&proposed, "test-agent")
let applied = dir.path().join("applied");
setup_applied(&applied, "test-agent")
.await
.expect("first call");
// Second call must not error even though .git already exists.
setup_proposed(&proposed, "test-agent")
setup_applied(&applied, "test-agent")
.await
.expect("second call");
// Still one commit.
let out = git_command()
.current_dir(&proposed)
.current_dir(&applied)
.args(["rev-list", "--count", "HEAD"])
.output()
.await
@ -158,10 +162,10 @@ async fn setup_proposed_idempotent() {
/// Build a two-commit repo and return `(dir, repo_path, first, second)`.
async fn two_commit_repo() -> (tempfile::TempDir, std::path::PathBuf, String, String) {
let dir = tempfile::tempdir().expect("tempdir");
let repo = dir.path().join("proposed");
setup_proposed(&repo, "test-agent")
let repo = dir.path().join("applied");
setup_applied(&repo, "test-agent")
.await
.expect("setup_proposed");
.expect("setup_applied");
let first = git_rev_parse(&repo, "HEAD").await.expect("rev-parse first");
std::fs::write(repo.join("second.txt"), "second").expect("write second.txt");

View file

@ -241,8 +241,7 @@ async fn cmd_serve(
let dashboard_port = env.dashboard_port;
let coord = Arc::new(Coordinator::open(&db, env, model_prices, build_slots)?);
// Idempotent pre-flight: rewrite pre-meta-layout applied
// repos, ensure proposed repos carry the `applied`
// remote, refresh the files that follow the agent set.
// repos, refresh the files that follow the agent set.
// Runs before the boot reconcile.
if let Err(e) = migrate::run().await {
tracing::warn!(error = ?e, "startup migration failed");

View file

@ -1,5 +1,5 @@
//! Startup convergence. Three phases, all idempotent and unguarded:
//! harness files, applied + proposed repos, the agent-set files, plus the
//! harness files, the applied repos, the agent-set files, plus the
//! removal of the legacy meta repo's leftovers. They re-run every
//! boot on purpose — each one is a no-op once its state is already
//! correct.
@ -80,10 +80,10 @@ pub async fn run() -> Result<()> {
Err(e) => tracing::warn!(error = ?e, "migration: removing the legacy meta .git failed"),
}
// Phase 1 + 2: per-agent applied + proposed.
tracing::debug!("migration: phase 1+2 (applied + proposed repos)");
// An archetype agent has neither repo, and seeding one here would hand it
// a config it is not built from.
// Phase 1 + 2: per-agent applied repo.
tracing::debug!("migration: phase 1+2 (applied repo)");
// An archetype agent has no applied repo, and seeding one here would hand
// it a config it is not built from.
let archetypes = crate::archetypes::read_at(&crate::archetypes::path()).unwrap_or_else(|e| {
tracing::warn!(error = ?e, "migration: stored archetypes unreadable; treating every agent as custom");
crate::archetypes::Stored::new()
@ -92,26 +92,10 @@ pub async fn run() -> Result<()> {
if archetypes.contains_key(name.as_str()) {
continue;
}
tracing::debug!(%name, "migration: applied+proposed");
tracing::debug!(%name, "migration: applied");
if let Err(e) = migrate_applied_repo(name.as_str()).await {
tracing::warn!(%name, error = ?e, "migration: applied repo rewrite failed");
}
let proposed_dir = Coordinator::agent_proposed_dir(name);
match remove_meta_remote(&proposed_dir).await {
Ok(true) => tracing::info!(%name, "migration: removed the legacy meta remote"),
Ok(false) => {}
Err(e) => {
tracing::warn!(%name, error = ?e, "migration: removing the meta remote failed");
}
}
let proposed = lifecycle::setup_proposed(&proposed_dir, name.as_str());
match tokio::time::timeout(GIT_TIMEOUT, proposed).await {
Ok(Err(e)) => tracing::warn!(%name, error = ?e, "migration: setup_proposed failed"),
Err(_) => {
tracing::warn!(%name, timeout = ?GIT_TIMEOUT, "migration: setup_proposed timed out — skipping");
}
Ok(Ok(())) => {}
}
}
// Phase 3: the files that follow the agent set.
@ -190,19 +174,6 @@ fn remove_core_token(state: &Path) -> Result<bool> {
}
}
/// Remove the `meta` remote from the proposed repo at `dir`. `true` when it
/// was there; a missing remote, or a `dir` that is no repo, is `false`.
async fn remove_meta_remote(dir: &Path) -> Result<bool> {
let mut cmd = lifecycle::git_command();
cmd.current_dir(dir).args(["remote", "get-url", "meta"]);
let probe = output_with_timeout(cmd, GIT_TIMEOUT, "git remote get-url meta").await?;
if !probe.status.success() {
return Ok(false);
}
raw_git(dir, &["remote", "remove", "meta"]).await?;
Ok(true)
}
async fn enumerate_agents() -> Result<Vec<hive_types::Ident>> {
let containers = lifecycle::list().await?;
Ok(containers
@ -329,35 +300,6 @@ mod tests {
assert!(dir.path().join("topology.json").exists());
}
#[tokio::test]
async fn meta_remote_goes_and_the_others_stay() {
let dir = tempfile::tempdir().expect("tempdir");
raw_git(dir.path(), &["init", "-q", "--initial-branch=main"])
.await
.expect("init");
for (name, url) in [
("meta", "http://forge/core/meta.git"),
("applied", "/applied/x/.git"),
] {
raw_git(dir.path(), &["remote", "add", name, url])
.await
.expect("add remote");
}
assert!(remove_meta_remote(dir.path()).await.expect("first"));
let remotes = lifecycle::git_command()
.current_dir(dir.path())
.arg("remote")
.output()
.await
.expect("list");
assert_eq!(String::from_utf8_lossy(&remotes.stdout).trim(), "applied");
assert!(
!remove_meta_remote(dir.path()).await.expect("second"),
"no-op"
);
}
#[test]
fn core_token_goes_once_and_its_neighbours_stay() {
let dir = tempfile::tempdir().expect("tempdir");
@ -370,12 +312,6 @@ mod tests {
assert!(!remove_core_token(dir.path()).expect("second"), "no-op");
}
#[tokio::test]
async fn a_dir_that_is_no_repo_has_no_meta_remote() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(!remove_meta_remote(dir.path()).await.expect("probe"));
}
#[tokio::test]
async fn unreadable_flake_is_left_untouched() {
let dir = tempfile::tempdir().expect("tempdir");

View file

@ -114,8 +114,7 @@ pub fn agents_root() -> PathBuf {
PathBuf::from(AGENTS_ROOT)
}
/// `applied/` — per-agent *applied* (deployed) config repos + rev markers,
/// distinct from the proposed configs under `agents/<name>/config`.
/// `applied/` — per-agent *applied* (deployed) config repos + rev markers.
// nix: read by hive-c0re only, but paired with `agents/` in the deploy flow.
#[must_use]
pub fn applied_root() -> PathBuf {