diff --git a/docs/agent-lifecycle/config-changes.md b/docs/agent-lifecycle/config-changes.md index fc058d19..cd5862d4 100644 --- a/docs/agent-lifecycle/config-changes.md +++ b/docs/agent-lifecycle/config-changes.md @@ -144,9 +144,11 @@ autoupdate flake-rev bump (one shot before per-agent rebuilds). carries `hyperhive_flake`, `dashboard_port`, and the rest of the per-hive config — is the idempotent reconciler called by `provision_container`, `destroy`, `rebuild`, and the startup migration. Renders `flake.nix` -from the agent list; if it differs from disk, runs +from the agent list; if it differs from disk, or `flake.nix` or `flake.lock` +has an uncommitted change (a sync stopped before its commit), runs `nix flake lock` + commits as `regenerate meta flake` (or -`seed meta from N agent(s)` on the first call). +`seed meta from N agent(s)` on the first call). A clean tree whose +`flake.nix` already matches returns without running nix. The root agent has `/meta` RO-bound inside its container: `git -C /meta log --oneline` is the swarm-wide deploy log, diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 4333a208..715886b6 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -148,11 +148,22 @@ async fn stage_generated_meta_files(dir: &std::path::Path) -> Result<()> { Ok(()) } +/// Meta files `sync_agents` writes and locks; an uncommitted change to them +/// means an earlier sync stopped before its commit. +const SYNC_OWNED_PATHS: &[&str] = &["flake.nix", "flake.lock"]; + +/// Whether `sync_agents` has nothing to do: the repo exists, the rendered +/// flake equals the one on disk, and no sync-owned file is uncommitted. +fn can_skip_sync(initial: bool, flake_unchanged: bool, uncommitted: bool) -> bool { + !initial && flake_unchanged && !uncommitted +} + /// Idempotently reconcile the meta repo with the current agent set. /// First call inits the git repo, runs `nix flake lock`, and lands a /// seed commit. Subsequent calls only touch `flake.nix` when the /// rendered contents differ from disk; an unchanged `flake.nix` is a -/// no-op. +/// no-op unless `flake.nix` or `flake.lock` is uncommitted, in which case +/// the lock and commit run again. pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> { let _guard = lock_meta("sync_agents").await; // Before anything is written: a hive without a forge URL would deploy a @@ -174,9 +185,18 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> { let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default(); let initial = !dir.join(".git").exists(); - if !initial && on_disk == new_flake { + let flake_unchanged = on_disk == new_flake; + let uncommitted = if !initial && flake_unchanged { + paths_dirty(&dir, SYNC_OWNED_PATHS).await? + } else { + false + }; + if can_skip_sync(initial, flake_unchanged, uncommitted) { return Ok(()); } + if uncommitted { + tracing::info!("sync_agents: resuming an uncommitted flake.nix/flake.lock change"); + } // Safety guard: refuse to write an empty agent list over a non-empty // on-disk flake. An empty `agents` slice is never intentional — it @@ -1162,6 +1182,48 @@ async fn nix(dir: &Path, args: &[&str]) -> Result<()> { mod tests { use super::*; + #[test] + fn sync_skips_only_when_clean_unchanged_and_not_initial() { + assert!(can_skip_sync(false, true, false)); + assert!(!can_skip_sync(false, true, true), "interrupted sync"); + assert!(!can_skip_sync(false, false, false), "flake changed"); + assert!(!can_skip_sync(true, true, false), "no repo yet"); + } + + /// An interrupted sync leaves the new `flake.nix` written with the old + /// lock; the dirty check over the sync-owned paths must see it, and stay + /// quiet on a clean tree and on files sync does not own. + #[tokio::test] + async fn interrupted_sync_leaves_sync_owned_paths_dirty() { + let tmp = tempfile::tempdir().expect("tempdir"); + let dir = tmp.path(); + git(dir, &["init", "--initial-branch=main"]) + .await + .expect("git init"); + std::fs::write(dir.join("flake.nix"), "old").expect("write"); + std::fs::write(dir.join("flake.lock"), "lock-old").expect("write"); + std::fs::write(dir.join("other.json"), "{}").expect("write"); + git(dir, &["add", "-A"]).await.expect("add"); + git_commit(dir, "seed").await.expect("seed commit"); + assert!(!paths_dirty(dir, SYNC_OWNED_PATHS).await.expect("check")); + + std::fs::write(dir.join("other.json"), r#"{"x":1}"#).expect("write"); + assert!( + !paths_dirty(dir, SYNC_OWNED_PATHS).await.expect("check"), + "a file sync does not own is not an interrupted sync" + ); + + std::fs::write(dir.join("flake.nix"), "new").expect("write"); + git(dir, &["add", "flake.nix"]).await.expect("stage"); + assert!( + paths_dirty(dir, SYNC_OWNED_PATHS).await.expect("check"), + "new flake.nix written but not committed" + ); + git(dir, &["add", "-A"]).await.expect("add"); + git_commit(dir, "resume").await.expect("commit"); + assert!(!paths_dirty(dir, SYNC_OWNED_PATHS).await.expect("check")); + } + /// A path-limited commit must leave an unrelated staged file untouched. #[tokio::test] async fn path_limited_commit_leaves_unrelated_staged_file_alone() {