diff --git a/hive-c0re/src/flake_check.rs b/hive-c0re/src/flake_check.rs index 658b24ee..71550270 100644 --- a/hive-c0re/src/flake_check.rs +++ b/hive-c0re/src/flake_check.rs @@ -53,14 +53,15 @@ async fn read_lock_at_tag(repo: &Path, tag: &str) -> Result> { .with_context(|| format!("git show {spec} in {}", repo.display()))?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr); - // git uses different messages for "path not in tree" depending - // on version + whether the path collides with an on-disk file. - // Treat any of them as "no flake.lock in this commit" rather - // than a hard failure — an agent with empty `inputs = { }` is - // a legitimate, dedup-clean state. + // git uses two different messages for "path not in tree" + // depending on whether the path also collides with an on-disk + // file. Both translate to "no flake.lock in this commit" — + // a legitimate, dedup-clean state for an agent with empty + // `inputs = { }`. Any other git failure (permission denied, + // ref-not-found, etc.) propagates as a hard error rather than + // being silently swallowed. if stderr.contains("does not exist") || stderr.contains("exists on disk, but not in") - || stderr.contains("Path '") { return Ok(None); } @@ -242,6 +243,12 @@ async fn lock_in_sync_inner(worktree: &Path) -> Result<()> { .await .ok(); + // An agent that declares inputs in flake.nix but ships no + // flake.lock at all hits this branch (committed = None, + // regenerated = Some(...)). That's a deliberate reject: every + // agent with inputs MUST commit its lock, otherwise meta's + // dedup pass has nothing to introspect and the broken state + // leaks downstream. Treated identically to a stale lock. if committed.as_deref() != regenerated.as_deref() { anyhow::bail!( "flake.lock is out of sync with flake.nix — `nix flake lock` produces a different lock. \ diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index ee682ca6..c7258650 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -635,8 +635,11 @@ async fn submit_apply_commit( .map_err(|e| anyhow::anyhow!("persist fetched_sha: {e:#}"))?; // #317 pre-flight gates: both reject the apply before approval if // the agent's flake state would inflate meta's lock with duplicates - // or lie about what nix will fetch. Order matters — sync first so - // the dedup pass acts on the lock nix would actually produce. + // or lie about what nix will fetch. Both checks independently read + // `:flake.lock` via git — they don't share state. Order matters + // only for early-exit + messaging: sync first means a stale lock + // bails with the actionable "run `nix flake lock`" hint rather than + // a dedup pass on a lock nix would never produce. // // Runs after `set_fetched_sha` so the failed row carries the sha // that broke. Both failure paths mark + emit, then bail.