diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index 51d44dc2..5d5bb182 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -18,7 +18,8 @@ pub use pr_merge::{ pub use reconcile::{reconcile_config_apply, reconcile_config_status}; pub use repos::{ create_agent_repo, ensure_config_repo, ensure_knowledge_repo, ensure_meta_remote, ensure_repo, - ensure_shared_docs_repo, meta_read_access, push_config, push_meta, shared_docs_access, + ensure_shared_docs_repo, fetch_config_main_into_applied, meta_read_access, push_config, + push_meta, shared_docs_access, }; pub use users::{core_token, ensure_user_for, provision_user_token}; diff --git a/hive-c0re/src/forge/repos.rs b/hive-c0re/src/forge/repos.rs index a65f1bc0..ed80387c 100644 --- a/hive-c0re/src/forge/repos.rs +++ b/hive-c0re/src/forge/repos.rs @@ -519,6 +519,80 @@ pub async fn push_config(name: &str) -> Result<()> { Ok(()) } +/// Reseed a revived agent's `applied` repo — missing its `.git` entirely, +/// e.g. after `destroy --purge` — by fetching `agent-configs/`'s +/// `main` from the forge, [`push_config`]'s mirror target. Unlike +/// `proposed` (seeded once at first spawn and never touched again — see +/// `lifecycle::setup_proposed`'s own doc comment), the forge mirror is +/// kept current after every deploy, so this reflects the agent's actual +/// last-deployed state rather than a stale creation-time snapshot. +/// +/// Best-effort, mirroring [`push_config`]'s own shape: returns `false` +/// (not an error) rather than bailing when the forge is absent, the core +/// token isn't minted yet, `applied` already has a `.git` (nothing to +/// do), or any git step fails — callers fall back to a less-current +/// source or the original hard error. Only `true` once `applied` is +/// actually seeded and tagged `deployed/0`, mirroring first-spawn's own +/// `setup_applied` seed shape exactly. +pub async fn fetch_config_main_into_applied(name: &str) -> bool { + if !is_present().await { + return false; + } + let Some(token) = core_token() else { + return false; + }; + let applied = crate::paths::applied_dir(name); + if applied.join(".git").exists() { + return false; + } + if let Err(e) = std::fs::create_dir_all(&applied) { + tracing::warn!(%name, error = ?e, "forge: applied reseed mkdir failed"); + return false; + } + if let Err(e) = crate::lifecycle::git(&applied, &["init", "--initial-branch=main"]).await { + tracing::warn!(%name, error = ?e, "forge: applied reseed init failed"); + return false; + } + let url = forge_git_url(&format!("{CONFIG_ORG}/{name}")); + let auth = core_auth_header(&token); + let out = crate::lifecycle::git_command_authed(&auth) + .current_dir(&applied) + .args([ + "fetch", + "--no-tags", + "--update-head-ok", + &url, + "refs/heads/main:refs/heads/main", + ]) + .output() + .await; + match out { + Ok(o) if o.status.success() => {} + Ok(o) => { + tracing::warn!( + %name, + stderr = %String::from_utf8_lossy(&o.stderr).trim(), + "forge: applied reseed fetch failed" + ); + return false; + } + Err(e) => { + tracing::warn!(%name, error = ?e, "forge: applied reseed fetch failed"); + return false; + } + } + if let Err(e) = crate::lifecycle::git_read_tree_reset(&applied, "refs/heads/main").await { + tracing::warn!(%name, error = ?e, "forge: applied reseed read-tree failed"); + return false; + } + if let Err(e) = crate::lifecycle::git_tag(&applied, "deployed/0", "refs/heads/main").await { + tracing::warn!(%name, error = ?e, "forge: applied reseed tag failed"); + return false; + } + tracing::info!(%name, "forge: reseeded applied repo from agent-configs main"); + true +} + /// Run a single `git push ` in the applied repo `dir` and /// return the raw output for the caller to classify. Split out so /// [`push_config`] can push tags and `main` as independent pushes. diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index 0e9045ba..36cd6cc1 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -255,11 +255,15 @@ pub async fn create_container(name: &str, hive: &HiveEnv, paths: &AgentPaths) -> /// previously-provisioned agent), but a `destroy --purge` removes it /// (`job_queue::exec::run_purge_state`) — a later revive of the *same* /// agent name is still a rebuild, not a first-spawn, so it lands here, not -/// in `provision_container`. Pass `proposed` as a reseed source (same shape -/// `setup_applied` already uses on first-spawn) whenever it still exists on -/// disk, so that case recovers instead of bailing — only fall back to the -/// no-source `None` (and `setup_applied`'s existing clear bail) when -/// `proposed` is also gone, which is the genuinely unrecoverable case. +/// in `provision_container`. Recover from `agent-configs/` on the +/// forge when that's the case (kept current after every deploy by +/// `forge::push_config` — see `forge::fetch_config_main_into_applied`'s own +/// doc comment) rather than bailing outright. Deliberately **no fallback to +/// `proposed`**: that repo is seeded once at first spawn and never touched +/// again (`setup_proposed`'s own doc comment), so it can be arbitrarily +/// stale for a long-lived agent — restoring from it would silently revive +/// the *wrong* config rather than recovering the right one, which is worse +/// than the clear bail this replaces. pub async fn prepare_rebuild_dirs(name: &str, paths: &AgentPaths) -> Result<()> { validate(name)?; if let Some(other) = port_collision(name).await { @@ -268,8 +272,17 @@ pub async fn prepare_rebuild_dirs(name: &str, paths: &AgentPaths) -> Result<()> agent_web_port(name) ); } - let reseed_from = paths.proposed.exists().then_some(paths.proposed.as_path()); - setup_applied(&paths.applied, reseed_from, name).await?; + if !paths.applied.join(".git").exists() + && !crate::forge::fetch_config_main_into_applied(name).await + { + bail!( + "applied repo at {} is missing its .git directory and could not be reseeded from \ + the forge (agent-configs/{name} unreachable, not yet mirrored, or absent); \ + destroy --purge and re-spawn this agent.", + paths.applied.display() + ); + } + setup_applied(&paths.applied, None, name).await?; ensure_agent_state_subvolume(name).await?; ensure_claude_dir(&paths.claude)?; ensure_state_dir(&paths.notes)?;