From 0b86295776e5e22a5c6d572cd0ca4c02e24e09eb Mon Sep 17 00:00:00 2001 From: damocles Date: Tue, 23 Jun 2026 11:20:38 +0200 Subject: [PATCH] feat(#1838): run_merge_config_pr handler (verify pr head, ff forge main, deploy) --- hive-c0re/src/actions.rs | 153 +++++++++++++++++++++++++++++++++ hive-c0re/src/forge.rs | 37 ++++++++ hive-c0re/src/rebuild_queue.rs | 18 +++- 3 files changed, 207 insertions(+), 1 deletion(-) diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index e188f672..dfa733ea 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -192,6 +192,159 @@ pub async fn run_approval_apply_commit( finish_approval(coord, &approval, result, terminal_tag, is_first_spawn) } +/// Worker entry point for `ApprovalKind::MergeConfigPr` queue entries — +/// the PR-based config flow's counterpart to `run_approval_apply_commit`. +/// Re-fetches the approval row, runs the merge pipeline, and fires +/// `ApprovalResolved` + the `Rebuilt` lifecycle event via `finish_approval`. +/// Unlike the apply-commit path it does NOT call `push_config` afterwards: +/// `run_merge_config_pr` already fast-forwarded the forge repo's `main` to +/// the reviewed head (that IS the merge), so a mirror push would be a no-op. +/// A `MergeConfigPr` is never a first spawn (the agent already exists). +pub async fn run_approval_merge_config_pr( + coord: &Arc, + queue_entry_id: Option, + approval_id: i64, +) -> Result<()> { + let approval = fetch_approval_for_worker(coord, approval_id, ApprovalKind::MergeConfigPr)?; + let agent_dir = coord.ensure_runtime(&approval.agent)?; + let applied_dir = Coordinator::agent_applied_dir(&approval.agent); + coord.set_queue_step(queue_entry_id, "merge config pr"); + let (result, terminal_tag) = + run_merge_config_pr(coord, &approval, &agent_dir, &applied_dir, queue_entry_id).await; + finish_approval(coord, &approval, result, terminal_tag, false) +} + +/// PR-merge config pipeline. `approval.commit_ref` is the PR number; +/// `approval.fetched_sha` is the PR head sha the operator reviewed. Steps: +/// 1. drift gate — re-read the live PR head; if it moved since review, abort +/// WITHOUT mutating anything (the operator must re-review the new head); +/// 2. fetch the reviewed head into the applied repo so later git ops resolve +/// it locally; +/// 3. eval-verify the reviewed commit against the meta flake BEFORE the +/// irreversible push (same gate `run_apply_commit` uses); +/// 4. fast-forward the forge repo's `main` to the reviewed head — THE merge; +/// 5. mark the PR merged (best-effort: `main` is already at the head, so a +/// failure here is logged, not fatal); +/// 6. run the shared deploy tail (`deploy_applied_target`): ff applied/main, +/// meta deploy, container rebuild, finalize/rollback. +/// +/// Returns `(build result, terminal tag)` like `deploy_applied_target`. Any +/// pre-merge abort returns `Err` with no mutation; the operator re-reviews. +async fn run_merge_config_pr( + coord: &Arc, + approval: &hive_sh4re::Approval, + agent_dir: &std::path::Path, + applied_dir: &std::path::Path, + queue_entry_id: Option, +) -> (Result<()>, Option) { + let id = approval.id; + let pr: u64 = match approval.commit_ref.parse() { + Ok(n) => n, + Err(e) => { + return ( + Err(anyhow::anyhow!( + "parse PR number from commit_ref {:?}: {e}", + approval.commit_ref + )), + None, + ); + } + }; + let reviewed = match approval.fetched_sha.as_deref() { + Some(s) => s.to_owned(), + None => { + return ( + Err(anyhow::anyhow!( + "merge config pr approval {id} has no reviewed head sha" + )), + None, + ); + } + }; + let repo = crate::forge::config_repo(&approval.agent); + + // 1. Drift gate: the live PR head must still equal what was reviewed. + coord.set_queue_step(queue_entry_id, "verify PR head"); + let head = match crate::forge::pr_head_sha(&repo, pr).await { + Ok(h) => h, + Err(e) => return (Err(anyhow::anyhow!("read PR #{pr} head: {e}")), None), + }; + if head != reviewed { + return ( + Err(anyhow::anyhow!( + "PR #{pr} head drifted since review (reviewed {reviewed}, now {head}); re-review before merging" + )), + None, + ); + } + + // 2. Fetch the reviewed head into applied so ff/verify/deploy resolve it. + coord.set_queue_step(queue_entry_id, "fetch PR head"); + if let Err(e) = crate::forge::fetch_pr_head_into_applied(&repo, pr).await { + return ( + Err(anyhow::anyhow!("fetch PR #{pr} head into applied: {e}")), + None, + ); + } + + // 3. Eval-verify BEFORE the irreversible push (bad nix fails fast here). + coord.set_queue_step(queue_entry_id, "verify proposal (eval)"); + if let Err(e) = crate::meta::verify_commit(&approval.agent, applied_dir, &reviewed).await { + return ( + Err(anyhow::anyhow!("verify merge head {reviewed}: {e:#}")), + None, + ); + } + + // Capture the currently-deployed sha for the deploy tail's rollback. + let prev_main_sha = match lifecycle::git_rev_parse(applied_dir, "refs/heads/main").await { + Ok(s) => s, + Err(e) => return (Err(anyhow::anyhow!("read applied/main: {e:#}")), None), + }; + + // 4. THE merge: fast-forward the forge repo's main to the reviewed head. + coord.set_queue_step(queue_entry_id, "fast-forward forge main"); + match crate::forge::ff_push_to_main(&repo, &reviewed).await { + Ok(()) => {} + Err(crate::forge::ForgeMergeError::NotFastForward { .. }) => { + return ( + Err(anyhow::anyhow!( + "PR #{pr}: forge main raced ahead of reviewed {reviewed}; re-review before merging" + )), + None, + ); + } + Err(e) => return (Err(anyhow::anyhow!("ff-push PR #{pr} to main: {e}")), None), + } + + // 5. Mark the PR merged. Best-effort: main is already at the reviewed + // head, so the deploy is correct regardless — a failure here (incl. + // HeadDrift vs the forge PR record) is logged, not fatal. + coord.set_queue_step(queue_entry_id, "mark PR merged"); + if let Err(e) = crate::forge::mark_pr_merged(&repo, pr, &reviewed).await { + tracing::warn!( + agent = %approval.agent, %id, %pr, error = ?e, + "mark PR merged failed; main already at reviewed head, continuing to deploy" + ); + } + + // 6. Shared deploy tail. target == finalize == the reviewed head; + // never a first spawn (the agent already exists). + deploy_applied_target( + coord, + &approval.agent, + agent_dir, + applied_dir, + &reviewed, + &reviewed, + id, + &prev_main_sha, + false, + queue_entry_id, + ) + .await +} + /// Inline (non-queued) handler for `ApprovalKind::SchedulePrompt`. /// On approve, decode the `SchedulePromptPayload` JSON from the /// approval's `commit_ref`, insert a row into `scheduled_prompts` diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 8a3c12b2..e2d953c1 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -1225,6 +1225,43 @@ pub async fn pr_head_sha(repo: &str, pr: u64) -> Result Ok(sha.to_string()) } +/// Full `owner/name` path of an agent's config repo on the forge — the +/// `agent-configs` org mirror that the PR-merge flow reads + fast-forwards. +pub fn config_repo(agent: &str) -> String { + format!("{CONFIG_ORG}/{agent}") +} + +/// Fetch PR #`pr`'s head into the agent's applied repo via +/// `refs/pull//head` (which Forgejo always serves — a bare-sha fetch can +/// be refused by uploadpack policy). This makes the reviewed head an object +/// in the applied repo so the ancestor check in [`ff_push_to_main`], the +/// `git_update_ref(main, …)` in the deploy tail, and the eval-verify all +/// resolve it locally before the irreversible push. +/// +/// # Errors +/// `Other` on transport failure or a non-zero git exit. +pub async fn fetch_pr_head_into_applied(repo: &str, pr: u64) -> Result<(), ForgeMergeError> { + let token = core_token() + .ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?; + let url = tokenised_repo_url(repo, &token); + let applied = Coordinator::agent_applied_dir(repo_agent_name(repo)); + let refspec = format!("refs/pull/{pr}/head"); + let out = crate::lifecycle::git_command() + .current_dir(&applied) + .args(["fetch", "--no-tags", &url, &refspec]) + .output() + .await + .context("git fetch PR head into applied")?; + if !out.status.success() { + return Err(ForgeMergeError::Other(anyhow::anyhow!( + "git fetch {repo} {refspec} into applied failed ({}): {}", + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ))); + } + Ok(()) +} + /// Fast-forward the forge repo's `main` to `sha` — THE merge in the PR flow. /// Reads `main`'s current sha (`git ls-remote … refs/heads/main`), verifies it /// is a strict ancestor of `sha` (`git merge-base --is-ancestor`, run in the diff --git a/hive-c0re/src/rebuild_queue.rs b/hive-c0re/src/rebuild_queue.rs index 6a816f84..28b2e727 100644 --- a/hive-c0re/src/rebuild_queue.rs +++ b/hive-c0re/src/rebuild_queue.rs @@ -858,7 +858,23 @@ async fn dispatch( ) -> anyhow::Result<()> { match (entry.kind, entry.approval_id) { (QueueKind::Rebuild, Some(approval_id)) => { - crate::actions::run_approval_apply_commit(coord, Some(entry.id), approval_id).await + // Both ApplyCommit and MergeConfigPr approvals enqueue a Rebuild + // entry (they both end in a container rebuild); branch on the + // approval kind to pick the right pipeline. Fall back to the + // apply-commit path if the row can't be read — it re-fetches + + // surfaces a clean error itself. + let kind = coord + .approvals + .get(approval_id) + .ok() + .flatten() + .map(|a| a.kind); + if kind == Some(hive_sh4re::ApprovalKind::MergeConfigPr) { + crate::actions::run_approval_merge_config_pr(coord, Some(entry.id), approval_id) + .await + } else { + crate::actions::run_approval_apply_commit(coord, Some(entry.id), approval_id).await + } } (QueueKind::Rebuild, None) => { let current_rev =