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
+100 -2
View File
@@ -166,11 +166,35 @@ pub async fn try_merge(
return Ok(MergeOutcome::refused("branch adds nothing"));
}
merge_and_push(repo, push_url, branch, base, "auto-merge")
.await
.map(|o| match o.merged {
true => MergeOutcome {
merged: true,
reason: format!("additive-only and verified; merged into {base}"),
},
false => o,
})
}
/// The git half of a merge, with no policy in it.
///
/// Split out so an OPERATOR-approved merge runs exactly the same commands as an
/// automatic one — fetch the base as the remote has it, merge onto that, push.
/// The gates differ; the mechanics must not, or the rarely-taken path is the one
/// that breaks.
async fn merge_and_push(
repo: &Path,
push_url: &str,
branch: &str,
base: &str,
label: &str,
) -> Result<MergeOutcome, String> {
// Merge onto the freshly fetched base rather than a local branch.
git(repo, &["checkout", "-B", base, "FETCH_HEAD"]).await?;
if let Err(e) = git(
repo,
&["merge", "--no-ff", "-m", &format!("auto-merge {branch}"), branch],
&["merge", "--no-ff", "-m", &format!("{label} {branch}"), branch],
)
.await
{
@@ -184,14 +208,88 @@ pub async fn try_merge(
git(repo, &["push", push_url, &format!("HEAD:refs/heads/{base}")]).await?;
Ok(MergeOutcome {
merged: true,
reason: format!("additive-only and verified; merged into {base}"),
reason: format!("merged into {base}"),
})
}
/// Merge a delivered branch because an OPERATOR asked for it.
///
/// `MergePolicy::Never` means "do not merge on your own" — it defers to a human,
/// and this is that human. So the additive-only test does not apply: an operator
/// looking at a code change is exactly the judgement the policy was holding out
/// for.
///
/// What is NOT waived:
///
/// - the branch must exist on the remote and differ from the base, so the button
/// cannot report success for a merge of nothing;
/// - a conflict refuses and leaves the repo clean, rather than forcing;
/// - the work happens in a FRESH CLONE, never the mission checkout — that
/// directory is reaped on a timer after the mission ends, so a merge that
/// depended on it would work right after a run and mysteriously fail later.
pub async fn merge_on_operator_approval(
workdir: &Path,
push_url: &str,
branch: &str,
base: &str,
) -> Result<MergeOutcome, String> {
git(workdir, &["fetch", push_url, base]).await?;
git(workdir, &["fetch", push_url, branch]).await?;
git(workdir, &["branch", "-f", branch, "FETCH_HEAD"]).await?;
git(workdir, &["fetch", push_url, base]).await?;
let diff = git(
workdir,
&["diff", "--name-status", &format!("FETCH_HEAD...{branch}")],
)
.await?;
if diff.trim().is_empty() {
return Ok(MergeOutcome::refused(
"branch has nothing the base does not already have",
));
}
merge_and_push(workdir, push_url, branch, base, "merge mission branch").await
}
#[cfg(test)]
mod tests {
use super::*;
/// An operator merge and an automatic one must run the SAME git commands.
///
/// The gates differ — that is the whole point — but if the mechanics
/// diverged, the rarely-taken path would be the untested one. Both go
/// through `merge_and_push`.
#[test]
fn both_merge_paths_share_the_same_mechanics() {
let src = include_str!("auto_merge.rs");
let calls = src.matches("merge_and_push(").count();
// one definition + one call from each path
assert!(
calls >= 3,
"expected try_merge and merge_on_operator_approval to both call \
merge_and_push, found {calls} mention(s)"
);
// And the operator path must NOT re-implement the policy gate it exists
// to bypass — if this string appears there, the button is a no-op.
let op = src
.split("pub async fn merge_on_operator_approval")
.nth(1)
.unwrap_or("");
let body = op.split("\n}").next().unwrap_or("");
assert!(
!body.contains("MergePolicy::AdditiveOnly"),
"the operator path must not apply the additive-only gate"
);
// It must still refuse an empty branch: a button that reports success
// for merging nothing is worse than no button.
assert!(
body.contains("nothing the base does not already have"),
"the operator path must refuse an empty branch"
);
}
#[test]
fn only_pure_additions_qualify() {
assert!(non_additive_changes("A\t60 Papers/a.md\nA\t60 Papers/b.md\n").is_empty());