diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 54a73f31..e188f672 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -46,20 +46,12 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { run_approval_init_config(&coord, approval, proposed_dir, claude_dir, notes_dir).await } ApprovalKind::ApplyCommit => { - coord - .rebuild_queue - .enqueue_full(crate::rebuild_queue::FullEnqueue { - kind: crate::rebuild_queue::QueueKind::Rebuild, - agent: approval.agent.clone(), - source: crate::rebuild_queue::QueueSource::Approval, - reason: format!("approval #{id} apply commit"), - parent_id: None, - inputs: Vec::new(), - approval_id: Some(id), - perm_payload: None, - depends_on: Vec::new(), - }); - coord.emit_rebuild_queue_snapshot(); + enqueue_approval_rebuild( + &coord, + &approval.agent, + id, + format!("approval #{id} apply commit"), + ); Ok(()) } ApprovalKind::UpdateMetaInputs => { @@ -134,25 +126,43 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { // dispatches MergeConfigPr approvals to `run_merge_config_pr` // (verify the reviewed PR head, ff the forge config repo's // main to it, mark merged, then the shared deploy tail). - coord - .rebuild_queue - .enqueue_full(crate::rebuild_queue::FullEnqueue { - kind: crate::rebuild_queue::QueueKind::Rebuild, - agent: approval.agent.clone(), - source: crate::rebuild_queue::QueueSource::Approval, - reason: format!("approval #{id} merge config pr"), - parent_id: None, - inputs: Vec::new(), - approval_id: Some(id), - perm_payload: None, - depends_on: Vec::new(), - }); - coord.emit_rebuild_queue_snapshot(); + enqueue_approval_rebuild( + &coord, + &approval.agent, + id, + format!("approval #{id} merge config pr"), + ); Ok(()) } } } +/// Enqueue a `Rebuild` queue entry tied to an approval id. Shared by the +/// `ApplyCommit` and `MergeConfigPr` dispatch arms — both end in a container +/// rebuild routed through the queue, differing only in the queue `reason`. +/// The queue worker branches on the approval's kind to pick the right handler. +fn enqueue_approval_rebuild( + coord: &Arc, + agent: &str, + approval_id: i64, + reason: String, +) { + coord + .rebuild_queue + .enqueue_full(crate::rebuild_queue::FullEnqueue { + kind: crate::rebuild_queue::QueueKind::Rebuild, + agent: agent.to_owned(), + source: crate::rebuild_queue::QueueSource::Approval, + reason, + parent_id: None, + inputs: Vec::new(), + approval_id: Some(approval_id), + perm_payload: None, + depends_on: Vec::new(), + }); + coord.emit_rebuild_queue_snapshot(); +} + /// Worker entry point for `ApprovalKind::ApplyCommit` queue entries. /// Re-fetches the approval row, runs the commit pipeline, and fires /// `ApprovalResolved` + the lifecycle event (`Rebuilt` / `Spawned` @@ -446,22 +456,19 @@ fn finish_approval( sha: approval.fetched_sha.clone(), }); } - ApprovalKind::ApplyCommit => coord.notify_manager(&HelperEvent::Rebuilt { - agent: approval.agent.clone(), - ok, - note, - sha: approval.fetched_sha.clone(), - tag: terminal_tag, - }), - // MergeConfigPr ends in a container rebuild like ApplyCommit, so - // surface the same Rebuilt lifecycle event. - ApprovalKind::MergeConfigPr => coord.notify_manager(&HelperEvent::Rebuilt { - agent: approval.agent.clone(), - ok, - note, - sha: approval.fetched_sha.clone(), - tag: terminal_tag, - }), + // MergeConfigPr ends in a container rebuild just like a + // non-first-spawn ApplyCommit, so both surface the same Rebuilt + // lifecycle event. (MergeConfigPr is never a first spawn — the + // agent already exists — so it never hits the Spawned arm above.) + ApprovalKind::ApplyCommit | ApprovalKind::MergeConfigPr => { + coord.notify_manager(&HelperEvent::Rebuilt { + agent: approval.agent.clone(), + ok, + note, + sha: approval.fetched_sha.clone(), + tag: terminal_tag, + }); + } // UpdateMetaInputs / SchedulePrompt: ApprovalResolved already // carries the result. No separate lifecycle event needed. ApprovalKind::UpdateMetaInputs | ApprovalKind::SchedulePrompt => {} @@ -478,12 +485,8 @@ fn finish_approval( /// (`deployed/`) or annotate `failed/` with the build error /// and reset the working tree back to the last known-good main. main /// never advances on a failed build, so a crash-and-recover doesn't -/// leave the agent pointing at a tree it can't evaluate. -#[allow( - clippy::too_many_lines, - reason = "one sequential build/tag/notify pipeline; splitting the steps \ - across helpers would obscure the linear flow without shrinking it" -)] +/// leave the agent pointing at a tree it can't evaluate. The shared +/// ff/deploy/rebuild/finalize tail lives in `deploy_applied_target`. async fn run_apply_commit( coord: &Arc, approval: &hive_sh4re::Approval, @@ -574,28 +577,76 @@ async fn run_apply_commit( ); } + // Fast-forward applied/main to the proposal, run the meta deploy + + // container rebuild, and finalize/roll-back — the tail shared with the + // PR-merge flow. ApplyCommit's target == finalize sha source is + // `fetched_sha` (or the proposal ref when unset), matching the prior + // inline behavior exactly. + let (result, tag) = deploy_applied_target( + coord, + &approval.agent, + agent_dir, + applied_dir, + &proposal_ref, + approval.fetched_sha.as_deref().unwrap_or(&proposal_ref), + id, + &prev_main_sha, + is_first_spawn, + queue_entry_id, + ) + .await; + (result, tag, is_first_spawn) +} + +/// Shared deploy tail for config-applying approvals (`ApplyCommit` + the +/// PR-merge flow). Fast-forwards `applied/main` to `target_ref`, syncs the +/// working tree, runs the meta two-phase deploy + container rebuild, and +/// plants the `deployed/` / `failed/` bookkeeping tags. +/// On build failure it rolls `applied/main` back to `prev_main_sha` and aborts +/// the staged meta lock so the agent stays on its last-good tree. Returns the +/// build result + the terminal tag name. +/// +/// Caller-specific bits stay OUT of here: the source fetch (proposal tag vs +/// forge fetch), the `approved/building` tags, `verify_commit`, and any forge +/// ff-push / mark-merged. `is_first_spawn` gates the one-time meta +/// `sync_agents` step (only `ApplyCommit`'s first spawn passes `true`; +/// the PR-merge flow always passes `false` — the agent already exists). +/// `finalize_sha` is the sha recorded by `meta::finalize_deploy`; `target_ref` +/// is what `applied/main` fast-forwards to (a proposal ref or a commit sha). +#[allow( + clippy::too_many_arguments, + clippy::too_many_lines, + reason = "one sequential ff/deploy/rebuild/finalize pipeline shared by both \ + config-apply callers; splitting it would obscure the linear flow" +)] +async fn deploy_applied_target( + coord: &Arc, + agent: &str, + agent_dir: &std::path::Path, + applied_dir: &std::path::Path, + target_ref: &str, + finalize_sha: &str, + tag_base: i64, + prev_main_sha: &str, + is_first_spawn: bool, + queue_entry_id: Option, +) -> (Result<()>, Option) { + let id = tag_base; + coord.set_queue_step(queue_entry_id, "fast-forward applied/main"); - // Fast-forward applied/main to proposal/ + sync the working - // tree. Meta input pins `?ref=main`, so this is what makes nix - // re-lock to the proposal commit on the prepare_deploy step - // below. On build failure we roll main back to prev_main_sha so - // a crash leaves the agent on its last-good tree. - if let Err(e) = lifecycle::git_update_ref(applied_dir, "refs/heads/main", &proposal_ref).await { - return ( - Err(anyhow::anyhow!("ff main to {proposal_ref}: {e:#}")), - None, - is_first_spawn, - ); + // Fast-forward applied/main to target_ref + sync the working tree. + // Meta input pins `?ref=main`, so this is what makes nix re-lock to + // the target commit on the prepare_deploy step below. On build + // failure we roll main back to prev_main_sha so a crash leaves the + // agent on its last-good tree. + if let Err(e) = lifecycle::git_update_ref(applied_dir, "refs/heads/main", target_ref).await { + return (Err(anyhow::anyhow!("ff main to {target_ref}: {e:#}")), None); } if let Err(e) = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await { // main is ahead; working tree didn't sync. Roll main back to // keep the two consistent before bailing. - let _ = lifecycle::git_update_ref(applied_dir, "refs/heads/main", &prev_main_sha).await; - return ( - Err(anyhow::anyhow!("read-tree to main: {e:#}")), - None, - is_first_spawn, - ); + let _ = lifecycle::git_update_ref(applied_dir, "refs/heads/main", prev_main_sha).await; + return (Err(anyhow::anyhow!("read-tree to main: {e:#}")), None); } // First spawn: sync_agents must add this agent to the meta flake @@ -603,40 +654,34 @@ async fn run_apply_commit( // exist yet if this is the agent's first deploy). if is_first_spawn { coord.set_queue_step(queue_entry_id, "meta sync_agents (first spawn)"); - let agents = match lifecycle::agents_for_meta_listing_with(&approval.agent).await { + let agents = match lifecycle::agents_for_meta_listing_with(agent).await { Ok(a) => a, Err(e) => { let _ = - lifecycle::git_update_ref(applied_dir, "refs/heads/main", &prev_main_sha).await; + lifecycle::git_update_ref(applied_dir, "refs/heads/main", prev_main_sha).await; let _ = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await; return ( Err(anyhow::anyhow!("agents_for_meta_listing_with: {e:#}")), None, - is_first_spawn, ); } }; if let Err(e) = crate::meta::sync_agents(&coord.hive_env(), &agents).await { - let _ = lifecycle::git_update_ref(applied_dir, "refs/heads/main", &prev_main_sha).await; + let _ = lifecycle::git_update_ref(applied_dir, "refs/heads/main", prev_main_sha).await; let _ = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await; return ( Err(anyhow::anyhow!("meta sync_agents for first spawn: {e:#}")), None, - is_first_spawn, ); } } coord.set_queue_step(queue_entry_id, "meta prepare_deploy"); // Phase 1 of the meta two-phase deploy: relock without committing. - if let Err(e) = crate::meta::prepare_deploy(&approval.agent).await { - let _ = lifecycle::git_update_ref(applied_dir, "refs/heads/main", &prev_main_sha).await; + if let Err(e) = crate::meta::prepare_deploy(agent).await { + let _ = lifecycle::git_update_ref(applied_dir, "refs/heads/main", prev_main_sha).await; let _ = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await; - return ( - Err(anyhow::anyhow!("meta prepare_deploy: {e:#}")), - None, - is_first_spawn, - ); + return (Err(anyhow::anyhow!("meta prepare_deploy: {e:#}")), None); } // Container-level rebuild (or first-time create) against meta#. @@ -644,9 +689,9 @@ async fn run_apply_commit( // the dashboard reflects actual phase progress rather than a static // "nixos-container update" label for the whole multi-minute window. let hive = coord.hive_env(); - let paths = Coordinator::agent_paths(&approval.agent, agent_dir.to_path_buf()); + let paths = Coordinator::agent_paths(agent, agent_dir.to_path_buf()); let build_result = lifecycle::rebuild_no_meta( - &approval.agent, + agent, &hive, &paths, &|step| coord.set_queue_step(queue_entry_id, step), @@ -664,54 +709,47 @@ async fn run_apply_commit( Ok(()) => { coord.set_queue_step(queue_entry_id, "finalize deploy"); let tag = format!("deployed/{id}"); - if let Err(e) = lifecycle::git_tag(applied_dir, &tag, &proposal_ref).await { - tracing::warn!(agent = %approval.agent, %id, error = ?e, "plant deployed tag failed"); + if let Err(e) = lifecycle::git_tag(applied_dir, &tag, target_ref).await { + tracing::warn!(%agent, %id, error = ?e, "plant deployed tag failed"); } - if let Err(e) = crate::meta::finalize_deploy( - &approval.agent, - approval.fetched_sha.as_deref().unwrap_or(&proposal_ref), - &tag, - ) - .await - { + if let Err(e) = crate::meta::finalize_deploy(agent, finalize_sha, &tag).await { // The build itself succeeded — meta lock landed but // couldn't be committed. Surface as a soft warn so the // operator can git-commit by hand if they care. - tracing::warn!(agent = %approval.agent, %id, error = ?e, "meta finalize_deploy failed"); + tracing::warn!(%agent, %id, error = ?e, "meta finalize_deploy failed"); } // Wake the agent on its next turn so claude sees the // config change took effect. Same hint pattern as // auto_update::rebuild_agent — manager approved a // proposal, agent picks up where it left off with the // new env / packages. - coord.kick_agent(&approval.agent, "config update applied"); - (Ok(()), Some(tag), is_first_spawn) + coord.kick_agent(agent, "config update applied"); + (Ok(()), Some(tag)) } Err(e) => { let tag = format!("failed/{id}"); let body = format!("{e:#}"); if let Err(te) = - lifecycle::git_tag_annotated(applied_dir, &tag, &proposal_ref, &body).await + lifecycle::git_tag_annotated(applied_dir, &tag, target_ref, &body).await { - tracing::warn!(agent = %approval.agent, %id, error = ?te, "annotate failed tag failed"); + tracing::warn!(%agent, %id, error = ?te, "annotate failed tag failed"); } // Roll main back to last known-good so the on-disk state // matches what nixos-container last successfully built. if let Err(re) = - lifecycle::git_update_ref(applied_dir, "refs/heads/main", &prev_main_sha).await + lifecycle::git_update_ref(applied_dir, "refs/heads/main", prev_main_sha).await { - tracing::warn!(agent = %approval.agent, %id, error = ?re, "main rollback failed"); + tracing::warn!(%agent, %id, error = ?re, "main rollback failed"); } if let Err(re) = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await { - tracing::warn!(agent = %approval.agent, %id, error = ?re, "rollback read-tree failed"); + tracing::warn!(%agent, %id, error = ?re, "rollback read-tree failed"); } // Drop the staged meta lock change so the deploy log // only ever shows successes. if let Err(ae) = crate::meta::abort_deploy().await { - tracing::warn!(agent = %approval.agent, %id, error = ?ae, "meta abort_deploy failed"); + tracing::warn!(%agent, %id, error = ?ae, "meta abort_deploy failed"); } - let _ = coord; - (Err(e), Some(tag), is_first_spawn) + (Err(e), Some(tag)) } } }