hyperhive/hive-c0re/src/migrate.rs
atlas e02ac1e86e refactor(#2916): drop the two obsolete startup migrations
Phase 4 (repoint every container onto `meta#<n>`) and phase 5 (rename
the `root` container to `h-root`) were marker-guarded one-shots for
layouts no live hive still has: containers are rendered onto `meta#<n>`
at creation, and the `h-` prefix has been the naming for far longer than
any deployment predates. A one-shot nobody can still trigger is dead
weight, so both are gone along with `repoint_container`,
`rename_manager_container`, `CONTAINER_TIMEOUT` and the two marker paths.

Phase 6 was not obsolete, only misplaced. Ruth's tool groups are now
seeded by `ensure_root_agent` on the one path that creates her, rather
than re-asserted on every hive-c0re boot. The skip-if-already-set guard
survives the move: a destroy+recreate under the same name must not reset
an operator's chosen group set back to MANAGER_DEFAULT.

That also settles a latent bug. Phase 4's marker check was a `return`,
not a skip, so on any hive carrying the marker phases 5 and 6 never ran
at all — the tool-group backfill, whose whole job was preventing a silent
privilege downgrade, has not executed here in a long time. Moving it to
create-time removes the question rather than answering it.

What stays is convergence: three unguarded, idempotent phases that re-run
each boot and no-op once their state is right. The module doc now names
the three categories so the next person can tell which kind they're
adding.
2026-08-02 01:41:37 +02:00

230 lines
8.8 KiB
Rust

//! Startup convergence. Three phases, all idempotent and unguarded:
//! harness files, applied + proposed repos, meta repo. They re-run every
//! boot on purpose — each one is a no-op once its state is already
//! correct.
//!
//! Deliberately *not* here, and the distinction is the point:
//!
//! - **One-shot, marker-guarded migrations.** Two used to live here
//! (repointing containers onto the meta flake, renaming `root` to
//! `h-root`); both targeted layouts no live hive still has. Add one
//! only if it cannot be expressed as convergence, and expect to delete
//! it once every hive has passed it.
//! - **Create-time setup.** Ruth's tool groups were backfilled here on
//! every boot; they are now seeded where she is created
//! (`workers::auto_update::ensure_root_agent`). A thing that is true
//! from birth does not need re-asserting each morning.
//!
//! Kill-switch: `HIVE_SKIP_META_MIGRATION=1`. Full sequence and phase
//! details: `docs/approvals.md::Migration from the pre-tag`.
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use tokio::process::Command;
use crate::coordinator::Coordinator;
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_CONTAINER, MANAGER_NAME};
use crate::meta;
const KILL_SWITCH: &str = "HIVE_SKIP_META_MIGRATION";
/// Per-shellout timeout for the blocking startup convergence. `run` is
/// awaited *before* the daemon starts serving (main.rs), so any child
/// process that wedges here freezes the whole daemon — admin socket +
/// dashboard included — with no diagnostics: a git shellout was observed
/// blocked for 86min under a concurrent `nixos-rebuild`. Every shellout
/// runs under a timeout that kills the child on elapse, so a stuck phase
/// degrades to a logged warning instead of a hung boot.
const GIT_TIMEOUT: Duration = Duration::from_mins(2);
/// Substring that identifies the *current* agent flake boilerplate.
/// Bumped whenever the template changes so the startup migration
/// re-renders existing agents onto the new shape. Today the marker
/// is the `flakeInputs` module-arg forwarding line — older templates
/// (raw `import ./agent.nix`) get rewritten on next hive-c0re start.
const MODULE_FLAKE_MARKER: &str = "_module.args.flakeInputs";
pub async fn run(coord: &Arc<Coordinator>) -> Result<()> {
if std::env::var(KILL_SWITCH).is_ok() {
tracing::info!("migration: {KILL_SWITCH} set — skipping");
return Ok(());
}
// Stale meta index lock: a previous hive-c0re crash mid-`git add`
// can leave `.git/index.lock` behind, which blocks every
// subsequent meta op until somebody `rm`s it manually. We just
// booted so nothing of ours is holding it; safe to clear.
let meta_lock = crate::paths::meta_git_index_lock();
if meta_lock.exists() {
match std::fs::remove_file(&meta_lock) {
Ok(()) => tracing::warn!("cleared stale meta/.git/index.lock"),
Err(e) => tracing::warn!(error = ?e, "clear stale meta lock failed"),
}
}
let names = enumerate_agents().await;
tracing::info!(count = names.len(), "migration: scanning");
// Phase 0: move harness-owned files out of state/ into harness/.
// Idempotent — rename is a no-op if the source doesn't exist and
// the destination already does.
tracing::debug!("migration: phase 0 (harness files)");
for name in &names {
migrate_harness_files(name);
}
// Phase 1 + 2: per-agent applied + proposed.
tracing::debug!("migration: phase 1+2 (applied + proposed repos)");
for name in &names {
tracing::debug!(%name, "migration: applied+proposed");
if let Err(e) = migrate_applied_repo(name.as_str()).await {
tracing::warn!(%name, error = ?e, "migration: applied repo rewrite failed");
}
let proposed_dir = Coordinator::agent_proposed_dir(name);
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"),
Err(_) => {
tracing::warn!(%name, timeout = ?GIT_TIMEOUT, "migration: setup_proposed timed out — skipping");
}
Ok(Ok(())) => {}
}
}
// Phase 3: meta repo.
tracing::debug!("migration: phase 3 (meta sync_agents)");
let agents = lifecycle::agents_for_meta_listing()
.await
.unwrap_or_default();
match tokio::time::timeout(GIT_TIMEOUT, meta::sync_agents(&coord.hive_env(), &agents)).await {
Ok(Err(e)) => tracing::warn!(error = ?e, "migration: meta sync_agents failed"),
Err(_) => {
tracing::warn!(timeout = ?GIT_TIMEOUT, "migration: meta sync_agents timed out — skipping");
}
Ok(Ok(())) => {}
}
Ok(())
}
/// Move harness-owned sqlite/config files out of the agent-visible state dir
/// and into the sibling harness dir. Best-effort: logs warnings but never
/// fails. Idempotent — each file is only moved if present at the old path
/// and absent at the new path.
fn migrate_harness_files(name: &hive_types::Ident) {
const HARNESS_FILES: &[&str] = &[
"hyperhive-events.sqlite",
"hyperhive-turn-stats.sqlite",
"hyperhive-model",
];
let state_dir = Coordinator::agent_notes_dir(name);
let harness_dir = Coordinator::agent_harness_dir(name);
if let Err(e) = std::fs::create_dir_all(&harness_dir) {
tracing::warn!(%name, error = ?e, "migration: create harness dir failed");
return;
}
for file in HARNESS_FILES {
let src = state_dir.join(file);
let dst = harness_dir.join(file);
if !src.exists() || dst.exists() {
continue;
}
match std::fs::rename(&src, &dst) {
Ok(()) => tracing::info!(%name, %file, "migration: moved to harness dir"),
Err(e) => {
tracing::warn!(%name, %file, error = ?e, "migration: move to harness dir failed");
}
}
}
}
async fn enumerate_agents() -> Vec<hive_types::Ident> {
let containers = lifecycle::list().await.unwrap_or_default();
containers
.into_iter()
.filter_map(|c| {
let name = if c == MANAGER_CONTAINER {
MANAGER_NAME
} else {
c.strip_prefix(AGENT_PREFIX)?
};
hive_types::Ident::parse(name).ok()
})
.collect()
}
async fn migrate_applied_repo(name: &str) -> Result<()> {
let dir = crate::paths::applied_dir(name);
if !dir.join(".git").exists() {
return Ok(());
}
let flake_path = dir.join("flake.nix");
let cur = std::fs::read_to_string(&flake_path).unwrap_or_default();
if cur.contains(MODULE_FLAKE_MARKER) {
return Ok(());
}
let want = lifecycle::initial_flake_nix();
std::fs::write(&flake_path, want).with_context(|| format!("write {}", flake_path.display()))?;
raw_git(
&dir,
&[
"-c",
"user.name=c0re",
"-c",
"user.email=c0re@hyperhive.local",
"add",
"flake.nix",
],
)
.await?;
raw_git(
&dir,
&[
"-c",
"user.name=c0re",
"-c",
"user.email=c0re@hyperhive.local",
"commit",
"-m",
"migration: module-only flake",
],
)
.await?;
// Relocate deployed/0 to the migration commit so
// setup_applied's existence check passes.
raw_git(&dir, &["tag", "-f", "deployed/0", "HEAD"]).await?;
tracing::info!(%name, "migration: applied repo migrated to module-only flake");
Ok(())
}
/// Run a command to completion under a timeout, capturing its output. On
/// timeout the child is killed (`kill_on_drop`) and an error is returned,
/// so a wedged shellout can never freeze startup migration. `what` is a
/// human label surfaced in the timeout error + `with_context`.
async fn output_with_timeout(
mut cmd: Command,
timeout: Duration,
what: &str,
) -> Result<std::process::Output> {
cmd.kill_on_drop(true);
match tokio::time::timeout(timeout, cmd.output()).await {
Ok(r) => r.with_context(|| format!("run {what}")),
Err(_) => anyhow::bail!("{what} timed out after {timeout:?} (child killed)"),
}
}
async fn raw_git(dir: &Path, args: &[&str]) -> Result<()> {
let mut cmd = lifecycle::git_command();
cmd.current_dir(dir).args(args);
let label = format!("git {} in {}", args.join(" "), dir.display());
let out = output_with_timeout(cmd, GIT_TIMEOUT, &label).await?;
if !out.status.success() {
anyhow::bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}