From d94712bde8aca8360caaac211914b925e86af9bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sat, 16 May 2026 00:57:58 +0200 Subject: [PATCH 1/3] turn: unify run_turn / compact_session via TurnFiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new TurnFiles bundle (mcp_config + settings + system_prompt + flavor) materialised once per harness boot, passed to drive_turn and compact_session alike. operator-initiated /compact now uses the exact same session shape as a normal turn — same MCP surface, same allowed tools, same role prompt — only the stdin payload differs (/compact vs the wake-up body). web_ui's AppState carries the TurnFiles instead of (label + socket + flavor + ad-hoc file writes per click). bin/hive-ag3nt and bin/hive-m1nd prepare TurnFiles before spawning the web UI and pass them to both surfaces. web_ui::Flavor folds into a type alias for mcp::Flavor — no two-stage enum mapping. removes ClaudeMode + the run_claude variant fork (system prompt was Option, mcp args were skipped on Compact). dead 'mode' plumbing gone. --- hive-ag3nt/src/bin/hive-ag3nt.rs | 43 ++++------ hive-ag3nt/src/bin/hive-m1nd.rs | 53 +++++-------- hive-ag3nt/src/turn.rs | 130 +++++++++++++------------------ hive-ag3nt/src/web_ui.rs | 46 +++++------ 4 files changed, 109 insertions(+), 163 deletions(-) diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index 7c661a0f..ee77d296 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -56,24 +56,16 @@ async fn main() -> Result<()> { let initial = LoginState::from_dir(&claude_dir); tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "harness boot"); let login_state = Arc::new(Mutex::new(initial)); - let ui_state = login_state.clone(); let bus = Bus::new(); - let ui_bus = bus.clone(); - let ui_socket = cli.socket.clone(); - tokio::spawn(async move { - if let Err(e) = web_ui::serve( - label, - port, - ui_state, - ui_bus, - ui_socket, - web_ui::Flavor::Agent, - ) - .await - { - tracing::error!(error = ?e, "web ui failed"); - } - }); + let files = turn::TurnFiles::prepare(&cli.socket, &label, mcp::Flavor::Agent).await?; + tokio::spawn(web_ui::serve( + label, + port, + login_state.clone(), + bus.clone(), + cli.socket.clone(), + files.clone(), + )); match initial { LoginState::Online => { serve( @@ -81,6 +73,7 @@ async fn main() -> Result<()> { Duration::from_millis(poll_ms), login_state, bus, + &files, ) .await } @@ -94,6 +87,7 @@ async fn main() -> Result<()> { Duration::from_millis(poll_ms), login_state, bus, + &files, ) .await } @@ -108,13 +102,10 @@ async fn serve( interval: Duration, state: Arc>, bus: Bus, + files: &turn::TurnFiles, ) -> Result<()> { tracing::info!(socket = %socket.display(), "hive-ag3nt serve"); let _ = state; // reserved for future state transitions (turn-loop -> needs-login) - let mcp_config = turn::write_mcp_config(socket).await?; - let settings = turn::write_settings(socket).await?; - let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hive-ag3nt".into()); - let system_prompt = turn::write_system_prompt(socket, &label, mcp::Flavor::Agent).await?; loop { let recv: Result = client::request(socket, &AgentRequest::Recv { wait_seconds: None }).await; @@ -129,15 +120,7 @@ async fn serve( }); bus.set_state(TurnState::Thinking); let prompt = format_wake_prompt(&from, &body, unread); - let outcome = turn::drive_turn( - &prompt, - &mcp_config, - &system_prompt, - &settings, - &bus, - mcp::Flavor::Agent, - ) - .await; + let outcome = turn::drive_turn(&prompt, files, &bus).await; turn::emit_turn_end(&bus, &outcome); bus.set_state(TurnState::Idle); } diff --git a/hive-ag3nt/src/bin/hive-m1nd.rs b/hive-ag3nt/src/bin/hive-m1nd.rs index bf997162..9e508062 100644 --- a/hive-ag3nt/src/bin/hive-m1nd.rs +++ b/hive-ag3nt/src/bin/hive-m1nd.rs @@ -59,29 +59,23 @@ async fn main() -> Result<()> { let initial = LoginState::from_dir(&claude_dir); tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "hm1nd boot"); let login_state = Arc::new(Mutex::new(initial)); - let ui_state = login_state.clone(); let bus = Bus::new(); - let ui_bus = bus.clone(); - let ui_socket = cli.socket.clone(); - tokio::spawn(async move { - if let Err(e) = web_ui::serve( - label, - port, - ui_state, - ui_bus, - ui_socket, - web_ui::Flavor::Manager, - ) - .await - { - tracing::error!(error = ?e, "web ui failed"); - } - }); + let files = turn::TurnFiles::prepare(&cli.socket, &label, mcp::Flavor::Manager).await?; + tokio::spawn(web_ui::serve( + label, + port, + login_state.clone(), + bus.clone(), + cli.socket.clone(), + files.clone(), + )); match initial { - LoginState::Online => serve(&cli.socket, Duration::from_millis(poll_ms), bus).await, + LoginState::Online => { + serve(&cli.socket, Duration::from_millis(poll_ms), bus, &files).await + } LoginState::NeedsLogin => { turn::wait_for_login(&claude_dir, login_state, poll_ms).await; - serve(&cli.socket, Duration::from_millis(poll_ms), bus).await + serve(&cli.socket, Duration::from_millis(poll_ms), bus, &files).await } } } @@ -89,12 +83,13 @@ async fn main() -> Result<()> { } } -async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> { +async fn serve( + socket: &Path, + interval: Duration, + bus: Bus, + files: &turn::TurnFiles, +) -> Result<()> { tracing::info!(socket = %socket.display(), "hive-m1nd serve"); - let mcp_config = turn::write_mcp_config(socket).await?; - let settings = turn::write_settings(socket).await?; - let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hm1nd".into()); - let system_prompt = turn::write_system_prompt(socket, &label, mcp::Flavor::Manager).await?; loop { let recv: Result = client::request(socket, &ManagerRequest::Recv { wait_seconds: None }).await; @@ -126,15 +121,7 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> { }); let prompt = format_wake_prompt(&from, &body, unread); bus.set_state(TurnState::Thinking); - let outcome = turn::drive_turn( - &prompt, - &mcp_config, - &system_prompt, - &settings, - &bus, - mcp::Flavor::Manager, - ) - .await; + let outcome = turn::drive_turn(&prompt, files, &bus).await; turn::emit_turn_end(&bus, &outcome); bus.set_state(TurnState::Idle); } diff --git a/hive-ag3nt/src/turn.rs b/hive-ag3nt/src/turn.rs index 1c49981c..d85ef8cf 100644 --- a/hive-ag3nt/src/turn.rs +++ b/hive-ag3nt/src/turn.rs @@ -33,6 +33,34 @@ const CLAUDE_SETTINGS: &str = include_str!("../prompts/claude-settings.json"); /// claude exit with a useful error in the live view. const PROMPT_TOO_LONG_MARKER: &str = "Prompt is too long"; +/// The set of files claude reads on every invocation: the MCP server +/// config (`--mcp-config`), static settings (`--settings`), and the +/// pre-rendered role/tools system prompt (`--system-prompt-file`). +/// Materialised once at harness startup; shared between the turn loop +/// and the operator-driven `/compact` path so both invocations look +/// identical to claude (same MCP surface, same allowed tools, same +/// role prompt — only the stdin payload differs). +#[derive(Clone)] +pub struct TurnFiles { + pub mcp_config: PathBuf, + pub settings: PathBuf, + pub system_prompt: PathBuf, + pub flavor: mcp::Flavor, +} + +impl TurnFiles { + /// Write all three files into the per-agent runtime dir alongside + /// `socket`. Idempotent — overwrites whatever was there. + pub async fn prepare(socket: &Path, label: &str, flavor: mcp::Flavor) -> Result { + Ok(Self { + mcp_config: write_mcp_config(socket).await?, + settings: write_settings(socket).await?, + system_prompt: write_system_prompt(socket, label, flavor).await?, + flavor, + }) + } +} + /// Drop the MCP config blob claude reads from `--mcp-config `. /// `socket` is the hyperhive per-container socket (forwarded to the child /// as `--socket `); `binary_subcommand` is e.g. `"mcp"` for sub-agents @@ -99,21 +127,14 @@ pub enum TurnOutcome { /// Drive one turn end-to-end, transparently compacting + retrying once on /// `Prompt is too long`. Both the sub-agent and manager loops call this. -pub async fn drive_turn( - prompt: &str, - mcp_config: &Path, - system_prompt: &Path, - settings: &Path, - bus: &Bus, - flavor: mcp::Flavor, -) -> TurnOutcome { - match run_turn(prompt, mcp_config, system_prompt, settings, bus, flavor).await { +pub async fn drive_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome { + match run_turn(prompt, files, bus).await { TurnOutcome::PromptTooLong => { - if let Err(e) = compact_session(settings, bus).await { + if let Err(e) = compact_session(files, bus).await { tracing::warn!(error = %format!("{e:#}"), "compact failed"); return TurnOutcome::Failed(e); } - run_turn(prompt, mcp_config, system_prompt, settings, bus, flavor).await + run_turn(prompt, files, bus).await } other => other, } @@ -166,25 +187,8 @@ pub async fn wait_for_login(claude_dir: &Path, state: Arc>, po /// prompt). The session is persistent across turns via `--continue` and /// claude's in-session auto-compact is disabled via `--settings` so it /// doesn't stall mid-turn — hyperhive owns compaction. -pub async fn run_turn( - prompt: &str, - mcp_config: &Path, - system_prompt: &Path, - settings: &Path, - bus: &Bus, - flavor: mcp::Flavor, -) -> TurnOutcome { - match run_claude( - prompt, - mcp_config, - Some(system_prompt), - settings, - bus, - flavor, - ClaudeMode::Turn, - ) - .await - { +pub async fn run_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome { + match run_claude(prompt, files, bus).await { Ok(too_long) if too_long => TurnOutcome::PromptTooLong, Ok(_) => TurnOutcome::Ok, Err(e) => TurnOutcome::Failed(e), @@ -192,49 +196,23 @@ pub async fn run_turn( } /// Run claude's built-in `/compact` slash command on the persistent -/// session so the next turn can fit. No MCP tools needed; we just feed -/// `/compact` over stdin and let claude rewrite its own history. -pub async fn compact_session(settings: &Path, bus: &Bus) -> Result<()> { +/// session. Takes the *same* params as `run_turn` because compact +/// re-initialises claude with the full session shape — same MCP +/// surface, same system prompt, same allowed-tools — so the post- +/// compact state matches a normal turn's. Only the prompt over stdin +/// differs (`/compact` vs the wake-up payload). +pub async fn compact_session(files: &TurnFiles, bus: &Bus) -> Result<()> { bus.emit(LiveEvent::Note( "context overflow — running /compact on the persistent session".into(), )); - let _ = run_claude( - "/compact", - Path::new("/dev/null"), - None, - settings, - bus, - mcp::Flavor::Agent, // tool surface unused for /compact - ClaudeMode::Compact, - ) - .await?; + let _ = run_claude("/compact", files, bus).await?; bus.emit(LiveEvent::Note("/compact done".into())); Ok(()) } -#[derive(Clone, Copy)] -enum ClaudeMode { - Turn, - Compact, -} - -async fn run_claude( - prompt: &str, - mcp_config: &Path, - system_prompt: Option<&Path>, - settings: &Path, - bus: &Bus, - flavor: mcp::Flavor, - mode: ClaudeMode, -) -> Result { +async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result { let model = bus.model(); - // /compact must always run against the existing session — otherwise - // there's nothing to compact. Only normal turns honor the - // operator's "new session" one-shot flag. - let resume = match mode { - ClaudeMode::Turn => !bus.take_skip_continue(), - ClaudeMode::Compact => true, - }; + let resume = !bus.take_skip_continue(); if !resume { bus.emit(LiveEvent::Note( "fresh session (--continue suppressed for this turn)".into(), @@ -258,22 +236,18 @@ async fn run_claude( .arg("--model") .arg(&model) .arg("--settings") - .arg(settings); + .arg(&files.settings); if resume { cmd.arg("--continue"); } - if let Some(p) = system_prompt { - cmd.arg("--system-prompt-file").arg(p); - } - if let ClaudeMode::Turn = mode { - cmd.arg("--mcp-config") - .arg(mcp_config) - .arg("--strict-mcp-config") - .arg("--tools") - .arg(mcp::builtin_tools_arg()) - .arg("--allowedTools") - .arg(mcp::allowed_tools_arg(flavor)); - } + cmd.arg("--system-prompt-file").arg(&files.system_prompt); + cmd.arg("--mcp-config") + .arg(&files.mcp_config) + .arg("--strict-mcp-config") + .arg("--tools") + .arg(mcp::builtin_tools_arg()) + .arg("--allowedTools") + .arg(mcp::allowed_tools_arg(files.flavor)); let mut child = cmd .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index c9d759e5..931ec2d6 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -28,6 +28,8 @@ use crate::client; use crate::events::Bus; use crate::login::LoginState; use crate::login_session::{LoginSession, drop_if_finished}; +use crate::mcp; +use crate::turn::TurnFiles; /// Live login state for the web UI. The harness updates this in place as it /// transitions between `NeedsLogin` and `Online`; the UI reads on each @@ -41,16 +43,25 @@ struct AppState { session: Arc>>>, bus: Bus, socket: PathBuf, - flavor: Flavor, + /// Same `TurnFiles` the harness's turn loop uses. Shared so + /// `/api/compact` re-uses the exact MCP config / system prompt / + /// settings claude saw on the last regular turn — keeps the + /// session shape identical across compact + normal turns. + files: TurnFiles, +} + +impl AppState { + fn flavor(&self) -> Flavor { + self.files.flavor + } } /// Which wire protocol the per-agent UI's `/send` handler should speak. -/// Sub-agent → `AgentRequest::OperatorMsg`; manager → `ManagerRequest::OperatorMsg`. -#[derive(Debug, Clone, Copy)] -pub enum Flavor { - Agent, - Manager, -} +/// Sub-agent → `AgentRequest::OperatorMsg`; manager → +/// `ManagerRequest::OperatorMsg`. Reuses the MCP-side enum so a +/// single value drives both the send protocol and (in +/// `post_compact`) the allowed-tools surface claude sees. +pub type Flavor = mcp::Flavor; pub async fn serve( label: String, @@ -58,7 +69,7 @@ pub async fn serve( login: LoginStateCell, bus: Bus, socket: PathBuf, - flavor: Flavor, + files: TurnFiles, ) -> Result<()> { let state = AppState { label, @@ -66,7 +77,7 @@ pub async fn serve( session: Arc::new(Mutex::new(None)), bus, socket, - flavor, + files, }; let app = Router::new() .route("/", get(serve_index)) @@ -208,7 +219,7 @@ async fn api_state(State(state): State) -> axum::Json { .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(7000); - let inbox = recent_inbox(&state.socket, state.flavor).await; + let inbox = recent_inbox(&state.socket, state.flavor()).await; let (turn_state, turn_state_since) = state.bus.state_snapshot(); let model = state.bus.model(); axum::Json(StateSnapshot { @@ -268,7 +279,7 @@ async fn post_send(State(state): State, Form(form): Form) -> if body.is_empty() { return error_response("send: `body` required"); } - let result = match state.flavor { + let result = match state.flavor() { Flavor::Agent => match client::request::<_, hive_sh4re::AgentResponse>( &state.socket, &hive_sh4re::AgentRequest::OperatorMsg { body }, @@ -396,22 +407,13 @@ async fn post_set_model(State(state): State, Form(form): Form) -> Response { let bus = state.bus.clone(); - let socket = state.socket.clone(); + let files = state.files.clone(); tokio::spawn(async move { bus.emit(crate::events::LiveEvent::Note( "operator: /compact — running on persistent session".into(), )); - let settings = match crate::turn::write_settings(&socket).await { - Ok(p) => p, - Err(e) => { - bus.emit(crate::events::LiveEvent::Note(format!( - "/compact failed: settings write — {e:#}" - ))); - return; - } - }; bus.set_state(crate::events::TurnState::Compacting); - let r = crate::turn::compact_session(&settings, &bus).await; + let r = crate::turn::compact_session(&files, &bus).await; bus.set_state(crate::events::TurnState::Idle); if let Err(e) = r { bus.emit(crate::events::LiveEvent::Note(format!( From 220e9b4af6f40bda5428a26ef04c7b57e87c3957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sat, 16 May 2026 00:59:35 +0200 Subject: [PATCH 2/3] =?UTF-8?q?meta:=20commit=20before=20lock=20=E2=80=94?= =?UTF-8?q?=20git+file://=20only=20sees=20tracked=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runtime error on first deploy attempt: 'source tree referenced by git+file:///var/lib/hyperhive/meta does not contain /flake.nix'. cause: sync_agents wrote flake.nix then ran 'nix flake lock' against a directory nix had just discovered as a git repo (auto-upgraded to git+file://), which only sees TRACKED content. fresh flake.nix was untracked, so nix saw an empty source tree. fix: commit flake.nix before locking. sync_agents now does write → init (if first) → git add + commit → nix flake lock → commit lock if changed. two commits per change — one 'regenerate meta flake' and one 'lock update' — instead of one combined; cleaner history. same git+file:// gotcha bit the two-phase deploy: prepare_ deploy used to write the lock without committing, expecting nixos-container update to read the working tree. it doesn't — it reads the tracked commit. prepare_deploy now commits with a placeholder 'deploy (building)' message; finalize_deploy amends to 'deploy deployed/ ' on success; abort_deploy git-reset --hard HEAD~1's it on failure. meta history still records only successful deploys. --- hive-c0re/src/meta.rs | 100 +++++++++++++++++++++++++++++++++--------- 1 file changed, 80 insertions(+), 20 deletions(-) diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 023ded47..cfd2f307 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -73,50 +73,110 @@ pub async fn sync_agents( if initial { git(&dir, &["init", "--initial-branch=main"]).await?; } - nix(&dir, &["flake", "lock"]).await?; - git(&dir, &["add", "-A"]).await?; + // Commit flake.nix *before* running nix flake lock — when meta is + // a git repo, nix treats it as a `git+file://` self-reference and + // only sees TRACKED files. Locking against an untracked flake.nix + // surfaces as "source tree does not contain '/flake.nix'". + git(&dir, &["add", "flake.nix"]).await?; let msg = if initial { format!("seed meta from {} agent(s)", agents.len()) } else { "regenerate meta flake".to_owned() }; git_commit(&dir, &msg).await?; + nix(&dir, &["flake", "lock"]).await?; + if !git_is_clean(&dir).await? { + git(&dir, &["add", "flake.lock"]).await?; + git_commit(&dir, "lock update").await?; + } Ok(()) } /// Phase 1 of an apply-commit deploy. Updates the locked rev of /// `agent-` to whatever `applied//main` currently points -/// at. **Doesn't commit** — caller must follow with -/// `finalize_deploy` on build success or `abort_deploy` on failure. +/// at **and commits** the bump immediately — `git+file://` semantics +/// mean nixos-container would otherwise build against the previously +/// tracked lock, ignoring the working-tree change. `finalize_deploy` +/// later amends the message with the deployed tag; `abort_deploy` +/// drops the commit entirely so meta history shows only successful +/// deploys. #[allow(dead_code)] // wired up by actions::run_apply_commit in a later commit pub async fn prepare_deploy(name: &str) -> Result<()> { let dir = meta_dir(); let input = format!("agent-{name}"); - nix(&dir, &["flake", "update", &input]).await -} - -/// Phase 2-success. Commits the staged `flake.lock` change with a -/// deploy-shaped message. No-op (clean working tree) is tolerated — -/// some lock-updates resolve to the same rev that's already locked. -#[allow(dead_code)] -pub async fn finalize_deploy(name: &str, sha: &str, tag: &str) -> Result<()> { - let dir = meta_dir(); + nix(&dir, &["flake", "update", &input]).await?; if git_is_clean(&dir).await? { + // Lock unchanged (rev already matches). Nothing to commit; + // finalize_deploy will be a no-op too. return Ok(()); } git(&dir, &["add", "flake.lock"]).await?; - let short = &sha[..sha.len().min(12)]; - git_commit(&dir, &format!("deploy {name} {tag} {short}")).await + git_commit(&dir, &format!("deploy {name} (building)")).await } -/// Phase 2-failure. Drops the uncommitted `flake.lock` change so meta -/// stays pinned at the previously-deployed shas. The failed proposal -/// is still captured in `applied/`'s annotated `failed/` tag — -/// meta's history only carries successful deploys. +/// Phase 2-success. Amend the prepare-deploy commit's message with +/// the canonical deployed tag + sha. No-op when prepare didn't +/// commit (input was already at the right rev). +#[allow(dead_code)] +pub async fn finalize_deploy(name: &str, sha: &str, tag: &str) -> Result<()> { + let dir = meta_dir(); + // Detect prepare's commit by its placeholder message; if HEAD is + // some other commit (e.g. prepare no-op'd, or a concurrent change + // landed) just add a fresh commit instead of amending. + let head_msg = git_head_msg(&dir).await.unwrap_or_default(); + let short = &sha[..sha.len().min(12)]; + let new_msg = format!("deploy {name} {tag} {short}"); + if head_msg.starts_with(&format!("deploy {name} (building)")) { + git( + &dir, + &[ + "-c", + &format!("user.name={GIT_NAME}"), + "-c", + &format!("user.email={GIT_EMAIL}"), + "commit", + "--amend", + "-m", + &new_msg, + ], + ) + .await + } else { + Ok(()) + } +} + +/// Phase 2-failure. Drop the prepare-deploy commit so meta history +/// only carries successful deploys. The failed proposal is still +/// captured in `applied/`'s annotated `failed/` tag. #[allow(dead_code)] pub async fn abort_deploy() -> Result<()> { let dir = meta_dir(); - git(&dir, &["restore", "flake.lock"]).await + let head_msg = git_head_msg(&dir).await.unwrap_or_default(); + if head_msg.starts_with("deploy ") && head_msg.contains("(building)") { + // hard reset drops the commit + its working-tree changes. + git(&dir, &["reset", "--hard", "HEAD~1"]).await + } else { + // Prepare no-op'd (lock unchanged); also restore any lingering + // lock-only changes as a safety belt. + git(&dir, &["restore", "flake.lock"]).await + } +} + +async fn git_head_msg(dir: &Path) -> Result { + let out = lifecycle::git_command() + .current_dir(dir) + .args(["log", "-1", "--format=%s"]) + .output() + .await + .with_context(|| format!("git log in {}", dir.display()))?; + if !out.status.success() { + bail!( + "git log -1 failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned()) } /// One-shot used by the manual-rebuild path: relock just one From 63e8a98df217d7ca69859f18767e93b3e567f59e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sat, 16 May 2026 01:02:47 +0200 Subject: [PATCH 3/3] meta: stage before lock, single commit per change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git+file://'s dirty-tree fetcher reads tracked + staged content from the index (not the working tree, not untracked files). so staging is enough to make a new flake.nix or flake.lock visible to nix without committing first. sync_agents now stages flake .nix, runs lock, stages the resulting flake.lock, then commits both together in a single 'regenerate meta flake' (or 'seed meta from N agents') commit — no more two-commit churn. prepare_deploy applies the same trick to the two-phase deploy: runs nix flake update, stages flake.lock so nixos-container update sees it, doesn't commit yet. finalize_deploy commits with the deployed/ message on build success; abort_deploy git-restores the staged lock back to HEAD on failure. meta history continues to record only successful deploys (and now one commit per success instead of one + amend). --- hive-c0re/src/meta.rs | 127 ++++++++++++++++-------------------------- 1 file changed, 49 insertions(+), 78 deletions(-) diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index cfd2f307..afb30b9a 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -73,110 +73,81 @@ pub async fn sync_agents( if initial { git(&dir, &["init", "--initial-branch=main"]).await?; } - // Commit flake.nix *before* running nix flake lock — when meta is - // a git repo, nix treats it as a `git+file://` self-reference and - // only sees TRACKED files. Locking against an untracked flake.nix - // surfaces as "source tree does not contain '/flake.nix'". + // Stage flake.nix *before* running nix flake lock. When meta is + // a git repo, nix treats it as a `git+file://` self-reference; + // its dirty-tree fetcher includes index entries (tracked + + // staged) but skips untracked files, so without the stage step + // an untracked flake.nix surfaces as "source tree does not + // contain '/flake.nix'". Lock then commit once with both + // flake.nix and flake.lock — single commit per change. git(&dir, &["add", "flake.nix"]).await?; + nix(&dir, &["flake", "lock"]).await?; + if std::path::Path::new(&dir).join("flake.lock").exists() { + git(&dir, &["add", "flake.lock"]).await?; + } let msg = if initial { format!("seed meta from {} agent(s)", agents.len()) } else { "regenerate meta flake".to_owned() }; git_commit(&dir, &msg).await?; - nix(&dir, &["flake", "lock"]).await?; - if !git_is_clean(&dir).await? { - git(&dir, &["add", "flake.lock"]).await?; - git_commit(&dir, "lock update").await?; - } Ok(()) } /// Phase 1 of an apply-commit deploy. Updates the locked rev of /// `agent-` to whatever `applied//main` currently points -/// at **and commits** the bump immediately — `git+file://` semantics -/// mean nixos-container would otherwise build against the previously -/// tracked lock, ignoring the working-tree change. `finalize_deploy` -/// later amends the message with the deployed tag; `abort_deploy` -/// drops the commit entirely so meta history shows only successful -/// deploys. +/// at and **stages** the lock so `nixos-container update --flake +/// meta#` (which reads via `git+file://`) sees the new rev via +/// the index. Doesn't commit — `finalize_deploy` commits on build +/// success, `abort_deploy` drops the staged change on failure so +/// meta history only carries successful deploys. #[allow(dead_code)] // wired up by actions::run_apply_commit in a later commit pub async fn prepare_deploy(name: &str) -> Result<()> { let dir = meta_dir(); let input = format!("agent-{name}"); nix(&dir, &["flake", "update", &input]).await?; - if git_is_clean(&dir).await? { - // Lock unchanged (rev already matches). Nothing to commit; - // finalize_deploy will be a no-op too. - return Ok(()); - } - git(&dir, &["add", "flake.lock"]).await?; - git_commit(&dir, &format!("deploy {name} (building)")).await + // Stage the new lock — git+file://'s dirty-tree fetcher reads + // index entries, so the upcoming nixos-container update sees the + // bumped rev without a commit yet. + git(&dir, &["add", "flake.lock"]).await } -/// Phase 2-success. Amend the prepare-deploy commit's message with -/// the canonical deployed tag + sha. No-op when prepare didn't -/// commit (input was already at the right rev). +/// Phase 2-success. Commit the staged lock with the deployed tag + +/// sha as the message. No-op when the rev was already at the right +/// place (nothing staged → nothing to commit). #[allow(dead_code)] pub async fn finalize_deploy(name: &str, sha: &str, tag: &str) -> Result<()> { let dir = meta_dir(); - // Detect prepare's commit by its placeholder message; if HEAD is - // some other commit (e.g. prepare no-op'd, or a concurrent change - // landed) just add a fresh commit instead of amending. - let head_msg = git_head_msg(&dir).await.unwrap_or_default(); - let short = &sha[..sha.len().min(12)]; - let new_msg = format!("deploy {name} {tag} {short}"); - if head_msg.starts_with(&format!("deploy {name} (building)")) { - git( - &dir, - &[ - "-c", - &format!("user.name={GIT_NAME}"), - "-c", - &format!("user.email={GIT_EMAIL}"), - "commit", - "--amend", - "-m", - &new_msg, - ], - ) - .await - } else { - Ok(()) + if !has_staged_changes(&dir).await? { + return Ok(()); } + let short = &sha[..sha.len().min(12)]; + git_commit(&dir, &format!("deploy {name} {tag} {short}")).await } -/// Phase 2-failure. Drop the prepare-deploy commit so meta history -/// only carries successful deploys. The failed proposal is still +/// Phase 2-failure. Unstage + restore the lock so meta returns to +/// the previously-committed shas. The failed proposal is still /// captured in `applied/`'s annotated `failed/` tag. #[allow(dead_code)] pub async fn abort_deploy() -> Result<()> { let dir = meta_dir(); - let head_msg = git_head_msg(&dir).await.unwrap_or_default(); - if head_msg.starts_with("deploy ") && head_msg.contains("(building)") { - // hard reset drops the commit + its working-tree changes. - git(&dir, &["reset", "--hard", "HEAD~1"]).await - } else { - // Prepare no-op'd (lock unchanged); also restore any lingering - // lock-only changes as a safety belt. - git(&dir, &["restore", "flake.lock"]).await - } + git(&dir, &["restore", "--staged", "flake.lock"]).await?; + git(&dir, &["restore", "flake.lock"]).await } -async fn git_head_msg(dir: &Path) -> Result { - let out = lifecycle::git_command() +async fn has_staged_changes(dir: &Path) -> Result { + let st = lifecycle::git_command() .current_dir(dir) - .args(["log", "-1", "--format=%s"]) - .output() + .args(["diff", "--cached", "--quiet"]) + .status() .await - .with_context(|| format!("git log in {}", dir.display()))?; - if !out.status.success() { - bail!( - "git log -1 failed: {}", - String::from_utf8_lossy(&out.stderr).trim() - ); + .with_context(|| format!("git diff --cached in {}", dir.display()))?; + // exit 1 = differences present, 0 = no diff, other = error + match st.code() { + Some(0) => Ok(false), + Some(1) => Ok(true), + _ => bail!("git diff --cached exited unexpectedly"), } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned()) } /// One-shot used by the manual-rebuild path: relock just one @@ -188,11 +159,11 @@ pub async fn lock_update_for_rebuild(name: &str) -> Result<()> { let dir = meta_dir(); let input = format!("agent-{name}"); nix(&dir, &["flake", "update", &input]).await?; - if !git_is_clean(&dir).await? { - git(&dir, &["add", "flake.lock"]).await?; - git_commit(&dir, &format!("rebuild {name}: lock update")).await?; + if git_is_clean(&dir).await? { + return Ok(()); } - Ok(()) + git(&dir, &["add", "flake.lock"]).await?; + git_commit(&dir, &format!("rebuild {name}: lock update")).await } /// One-shot used by the auto-update path: pin the latest hyperhive @@ -202,11 +173,11 @@ pub async fn lock_update_for_rebuild(name: &str) -> Result<()> { pub async fn lock_update_hyperhive() -> Result<()> { let dir = meta_dir(); nix(&dir, &["flake", "update", "hyperhive"]).await?; - if !git_is_clean(&dir).await? { - git(&dir, &["add", "flake.lock"]).await?; - git_commit(&dir, "bump hyperhive").await?; + if git_is_clean(&dir).await? { + return Ok(()); } - Ok(()) + git(&dir, &["add", "flake.lock"]).await?; + git_commit(&dir, "bump hyperhive").await } fn render_flake(hyperhive_flake: &str, dashboard_port: u16, agents: &[AgentSpec]) -> String {