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:
parent
53eed2a757
commit
f864ffba1b
2 changed files with 68 additions and 4 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue