feat(missions): an operator button to merge a mission's branch into main

`MergePolicy::Never` — the default for anything touching code — has always meant
"do not merge on your own", deferring to a human. There was no way for that human
to say yes: `auto_merge` was reachable only from the paper-harvest path, no
workflow template declares `merge_policy`, and every mission ended at a branch.

`POST /api/missions/{id}/merge` is that yes, with a button on the artifacts tab.
The additive-only gate does NOT apply here, deliberately: an operator reading a
code change is exactly the judgement the policy was holding out for.

What is not waived:

  - the branch comes from the artifact delivery RECORDED, not rebuilt from the
    mission id, and must have `pushed: true`. A phase that never pushed shows no
    button instead of one that cannot work.
  - an empty branch is refused. A button reporting success for merging nothing
    is worse than no button.
  - a conflict refuses, aborts, and leaves the repo clean rather than forcing.

It works in a FRESH CLONE under `_merge/<mission>`, never the mission checkout:
that directory is reaped on a timer after a mission ends, so a merge using it
would succeed right after a run and fail inexplicably an hour later. The clone is
made by the server process, so nothing runs as root and ordinary cleanup works —
unlike the copies in `root_copy`.

`merge_and_push` is split out so the operator path and the automatic path run the
SAME git commands; only the gates differ. A test asserts both call it, that the
operator path does not re-apply the additive gate it exists to bypass, and that
it still refuses an empty branch.

Harness 43/43 across all five recipes before this change, with `_gate`, `_bench`
and `_verify` all at zero.

246 lib tests, 20 binaries, 89 frontend tests, clean build.
This commit is contained in:
Omar Sobh
2026-08-07 18:53:38 -07:00
parent a8b8efba6a
commit 3616bc4733
6 changed files with 284 additions and 5 deletions
+101
View File
@@ -397,6 +397,107 @@ pub async fn artifact_content(
})))
}
/// POST /api/missions/{id}/merge — merge this mission's branch into the base.
///
/// The operator's button. `MergePolicy::Never` — the default for anything that
/// touches code — means "do not merge on your own", deferring to a human; this
/// endpoint is that human saying yes. So the additive-only test does not apply
/// here, and deliberately so.
///
/// It works in a FRESH CLONE under `_merge/<mission>`, never the mission
/// checkout: that directory is reaped on a timer after a mission ends, so a
/// merge that used it would succeed right after a run and fail inexplicably an
/// hour later. The clone is made by the server process, so nothing here runs as
/// root and the ordinary cleanup works — unlike the copies in `root_copy`.
pub async fn merge_branch(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
let repo_id = mission.repo_id.ok_or(ApiError::BadRequest)?;
let repo = cm_db::repo::repos::get(&state.pool, repo_id, user.workspace_id)
.await
.map_err(|_| ApiError::NotFound)?;
let clone_url = repo.clone_url.as_deref().ok_or(ApiError::BadRequest)?;
let base = repo.default_branch.as_deref().unwrap_or("main");
// The branch is whatever delivery actually pushed — read from the artifact
// it recorded, not reconstructed from the mission id. A phase that never
// pushed has no branch, and that must be a refusal rather than a guess.
let artifacts = cm_db::repo::missions::artifacts_for(&state.pool, id).await?;
let delivered = artifacts.iter().rev().find_map(|a| {
let m = a.metadata.as_object()?;
let branch = m.get("branch")?.as_str()?.to_string();
(m.get("pushed").and_then(|v| v.as_bool()) == Some(true)).then_some(branch)
});
let Some(branch) = delivered else {
return Ok(Json(serde_json::json!({
"merged": false,
"reason": "this mission has no pushed branch to merge",
})));
};
let auth = crate::mission_workspace::with_ambient_auth(clone_url);
let workdir = crate::mission_workspace::missions_root()
.join("_merge")
.join(id.to_string());
let _ = tokio::fs::remove_dir_all(&workdir).await;
if let Some(parent) = workdir.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let clone = tokio::process::Command::new("git")
.args(["clone", "--quiet", &auth.url])
.arg(&workdir)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.await
.map_err(|_| ApiError::Internal)?;
if !clone.status.success() {
eprintln!(
"missions::merge_branch: clone for {id} failed: {}",
String::from_utf8_lossy(&clone.stderr)
.chars()
.take(300)
.collect::<String>()
);
return Ok(Json(serde_json::json!({
"merged": false,
"reason": "could not clone the repository to merge",
})));
}
let outcome =
crate::auto_merge::merge_on_operator_approval(&workdir, &auth.url, &branch, base).await;
let _ = tokio::fs::remove_dir_all(&workdir).await;
match outcome {
Ok(o) => {
eprintln!(
"missions::merge_branch: mission {id} branch {branch} -> {base}: {}",
o.reason
);
Ok(Json(serde_json::json!({
"merged": o.merged,
"reason": o.reason,
"branch": branch,
"base": base,
})))
}
Err(e) => {
eprintln!("missions::merge_branch: mission {id} failed: {e}");
Ok(Json(serde_json::json!({
"merged": false,
"reason": format!("merge failed: {e}"),
"branch": branch,
"base": base,
})))
}
}
}
/// POST /api/missions/{id}/benchmark — run the benchmark harness
/// against a phase. Slot='baseline' records iteration 0's
/// before_metrics; slot='after' with iteration=N records the