Watch
0
0
Fork
You've already forked hyperhive
0

an interrupted meta sync resumes on the next run instead of leaving flake.lock stale

sync_agents returned early whenever the rendered flake.nix matched disk.
A sync stopped after writing flake.nix but before its commit therefore
never re-locked or committed. The early return now also requires that
flake.nix and flake.lock have nothing uncommitted; otherwise the normal
lock+commit path runs and logs that it is resuming.
This commit is contained in:
atlas 2026-10-20 00:37:11 +02:00
commit f864ffba1b
2 changed files with 68 additions and 4 deletions

View file

@ -144,9 +144,11 @@ autoupdate flake-rev bump (one shot before per-agent rebuilds).
carries `hyperhive_flake`, `dashboard_port`, and the rest of the carries `hyperhive_flake`, `dashboard_port`, and the rest of the
per-hive config — is the idempotent reconciler called by `provision_container`, per-hive config — is the idempotent reconciler called by `provision_container`,
`destroy`, `rebuild`, and the startup migration. Renders `flake.nix` `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 `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: The root agent has `/meta` RO-bound inside its container:
`git -C /meta log --oneline` is the swarm-wide deploy log, `git -C /meta log --oneline` is the swarm-wide deploy log,

View file

@ -148,11 +148,22 @@ async fn stage_generated_meta_files(dir: &std::path::Path) -> Result<()> {
Ok(()) 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. /// Idempotently reconcile the meta repo with the current agent set.
/// First call inits the git repo, runs `nix flake lock`, and lands a /// First call inits the git repo, runs `nix flake lock`, and lands a
/// seed commit. Subsequent calls only touch `flake.nix` when the /// seed commit. Subsequent calls only touch `flake.nix` when the
/// rendered contents differ from disk; an unchanged `flake.nix` is a /// 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<()> { pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
let _guard = lock_meta("sync_agents").await; let _guard = lock_meta("sync_agents").await;
// Before anything is written: a hive without a forge URL would deploy a // 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 on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default();
let initial = !dir.join(".git").exists(); 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(()); 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 // Safety guard: refuse to write an empty agent list over a non-empty
// on-disk flake. An empty `agents` slice is never intentional — it // 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 { mod tests {
use super::*; 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. /// A path-limited commit must leave an unrelated staged file untouched.
#[tokio::test] #[tokio::test]
async fn path_limited_commit_leaves_unrelated_staged_file_alone() { async fn path_limited_commit_leaves_unrelated_staged_file_alone() {