diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 3c9cab2..a15b338 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -258,9 +258,9 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp } ManagerRequest::RequestApplyCommit { agent, commit_ref } => { tracing::info!(%agent, %commit_ref, "manager: request_apply_commit"); - match coord.approvals.submit(agent, commit_ref) { - Ok(id) => { - tracing::info!(%id, %agent, %commit_ref, "approval queued"); + match submit_apply_commit(coord, agent, commit_ref).await { + Ok((id, sha)) => { + tracing::info!(%id, %agent, manager_ref = %commit_ref, %sha, "approval queued + proposal tag planted"); ManagerResponse::Ok } Err(e) => ManagerResponse::Err { @@ -271,6 +271,66 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp } } +/// Submit-time half of the apply flow: queue the approval row, then +/// fetch the manager's commit from the proposed repo into applied and +/// pin it as `refs/tags/proposal/`. From this point on the manager +/// repo is irrelevant for this approval — even if the manager amends +/// or force-pushes, the canonical sha hive-c0re will eventually +/// approve/deny lives in applied's object DB. +/// +/// If anything fails after the row is inserted (sha missing in +/// proposed, fs error, git plumbing crash) we mark the row failed and +/// surface the error to the manager. We don't try to roll the row +/// back — the failure is part of the audit trail. +async fn submit_apply_commit( + coord: &Arc, + agent: &str, + commit_ref: &str, +) -> anyhow::Result<(i64, String)> { + let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(agent); + let applied_dir = crate::coordinator::Coordinator::agent_applied_dir(agent); + if !proposed_dir.exists() { + anyhow::bail!( + "proposed repo missing for agent '{agent}' (expected at {})", + proposed_dir.display() + ); + } + if !applied_dir.join(".git").exists() { + anyhow::bail!( + "applied repo at {} is uninitialised — spawn the agent first", + applied_dir.display() + ); + } + let id = coord + .approvals + .submit(agent, commit_ref) + .map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?; + let tag = format!("proposal/{id}"); + let sha = match crate::lifecycle::git_fetch_to_tag( + &applied_dir, + &proposed_dir, + commit_ref, + &tag, + ) + .await + { + Ok(s) => s, + Err(e) => { + // Surface the failure on the approval row so the + // dashboard reflects it instead of leaving a phantom + // pending entry. The note doubles as the operator-visible + // explanation of why the approval can't be approved. + let _ = coord.approvals.mark_failed(id, &format!("{e:#}")); + return Err(anyhow::anyhow!("git_fetch_to_tag: {e:#}")); + } + }; + coord + .approvals + .set_fetched_sha(id, &sha) + .map_err(|e| anyhow::anyhow!("persist fetched_sha: {e:#}"))?; + Ok((id, sha)) +} + /// On `AskOperator { ttl_seconds: Some(n) }`, sleep n seconds and then /// try to resolve the question with `[expired]`. If the operator (or /// any other path) already answered it, `answer()` returns Err and