diff --git a/docs/agent-lifecycle/config-changes.md b/docs/agent-lifecycle/config-changes.md index a85ad59f..37814b49 100644 --- a/docs/agent-lifecycle/config-changes.md +++ b/docs/agent-lifecycle/config-changes.md @@ -338,6 +338,10 @@ once already applied: repo, and refreshes the files that follow the agent set (`agent_set::refresh`) from the current agent list. Set `HIVE_SKIP_META_MIGRATION=1` on the service to defer this phase. +- **Legacy meta repo**: deletes the leftover `.git` in + `/var/lib/hyperhive/meta/` and the `meta` remote in each proposed repo. + The files beside the `.git` stay. The `core/meta` repo on the forge is + not touched. No state loss in either migration: claude creds, `/state/` notes, the events DB, and both proposed + applied history all survive. The root diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index ecb27ad1..c42e2453 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -388,8 +388,9 @@ here from merged config PRs) + any other files those PRs commit. denied tag history. Under `/var/lib/hyperhive/meta/` — system-level config files, plain state -with no git history that anything reads. The flake agents build from is the -swarm's `internal/meta`, not here. Contents: +with no git history. The flake agents build from is the +swarm's `internal/meta`, not here. A hive that predates this finds a +leftover `.git` here; `migrate::run` deletes it at startup. Contents: - `topology.json` — the agent roster (`["alice", "bob", "ruth"]`). Written by `topology::reconcile` from `agent_set::refresh`; read by diff --git a/docs/scheduler/coordinator.md b/docs/scheduler/coordinator.md index eb7f5f6b..fb780775 100644 --- a/docs/scheduler/coordinator.md +++ b/docs/scheduler/coordinator.md @@ -320,8 +320,8 @@ The hive keeps no flake of its own. `meta_rev.rs` picks the `internal/meta` commit an agent builds from and records it; `agent_set.rs` keeps the host-side files that follow the agent set (`topology.json`, the gateway's agent-socket map and nginx include) in line with the live agents, and -`meta.rs` keeps the one remaining commit to the legacy `meta/` git repo, the -removal of the permission seed files. +`meta.rs` removes the permission seed files once the swarm has them, and +`migrate.rs` deletes the legacy `meta/` repo's leftover `.git`. --- diff --git a/hive-c0re/src/migrate.rs b/hive-c0re/src/migrate.rs index 3beb067c..7c889216 100644 --- a/hive-c0re/src/migrate.rs +++ b/hive-c0re/src/migrate.rs @@ -1,5 +1,6 @@ //! Startup convergence. Three phases, all idempotent and unguarded: -//! harness files, applied + proposed repos, the agent-set files. They re-run every +//! harness files, applied + proposed 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. //! @@ -68,6 +69,12 @@ pub async fn run() -> Result<()> { migrate_harness_files(name); } + match remove_legacy_meta_git(&crate::paths::meta_root()) { + Ok(true) => tracing::info!("migration: removed the legacy meta repo's .git"), + Ok(false) => {} + 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 @@ -85,6 +92,13 @@ pub async fn run() -> Result<()> { 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"), @@ -148,6 +162,31 @@ fn migrate_harness_files(name: &hive_types::Ident) { } } +/// Remove the `.git` of the legacy per-hive meta repo from `meta`, leaving +/// the plain state beside it (`topology.json`, the swarm limits, any +/// permission seed file not yet imported). `true` when it was there. +fn remove_legacy_meta_git(meta: &Path) -> Result { + let git_dir = meta.join(".git"); + match std::fs::remove_dir_all(&git_dir) { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(e).with_context(|| format!("removing {}", git_dir.display())), + } +} + +/// 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 { + 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> { let containers = lifecycle::list().await?; Ok(containers @@ -248,6 +287,67 @@ async fn raw_git(dir: &Path, args: &[&str]) -> Result<()> { mod tests { use super::*; + const STATE_FILES: [&str; 3] = [ + "topology.json", + "swarm-resource-limits.json", + "capabilities.json", + ]; + + #[test] + fn legacy_meta_git_goes_and_the_state_beside_it_stays() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(dir.path().join(".git/objects")).expect("mkdir .git"); + for name in STATE_FILES { + std::fs::write(dir.path().join(name), "{}").expect("write state"); + } + + assert!(remove_legacy_meta_git(dir.path()).expect("first")); + assert!(!dir.path().join(".git").exists()); + for name in STATE_FILES { + assert!(dir.path().join(name).exists(), "{name} stays"); + } + assert!( + !remove_legacy_meta_git(dir.path()).expect("second"), + "no-op" + ); + 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" + ); + } + + #[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");