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
@@ -11,11 +11,13 @@
// research phase can leave dozens of documents.
import { useCallback, useState } from "react";
import { FileText } from "lucide-react";
import { FileText, GitMerge } from "lucide-react";
import {
getArtifactContent,
mergeMissionBranch,
type ArtifactContent,
type MergeResult,
type MissionDetail,
} from "@/lib/api/missions";
import { MarkdownBlock } from "./MarkdownBlock";
@@ -32,6 +34,8 @@ export function MissionArtifacts({
}) {
const missionId = mission.id;
const artifacts = mission.artifacts;
const [merging, setMerging] = useState(false);
const [merge, setMerge] = useState<MergeResult | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
const [text, setText] = useState<ArtifactContent | null>(null);
const [loading, setLoading] = useState(false);
@@ -65,6 +69,32 @@ export function MissionArtifacts({
[missionId, openId],
);
// The branch delivery actually pushed, read from the artifact that recorded
// it — not rebuilt from the mission id, so a phase that never pushed shows no
// button rather than a button that cannot work.
const branch = artifacts
.map((a) => (a.metadata ?? {}) as Record<string, unknown>)
.filter((m) => m.pushed === true)
.map((m) => (typeof m.branch === "string" ? m.branch : null))
.filter(Boolean)
.pop() as string | null;
const doMerge = async () => {
if (merging) return;
setMerging(true);
setMerge(null);
try {
setMerge(await mergeMissionBranch(mission.id));
} catch (e) {
setMerge({
merged: false,
reason: e instanceof Error ? e.message : "merge request failed",
});
} finally {
setMerging(false);
}
};
if (artifacts.length === 0) {
return (
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#8a8a92", padding: 12 }}>
@@ -75,6 +105,42 @@ export function MissionArtifacts({
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{branch && (
<div
style={{
padding: 11,
borderRadius: 10,
border: "1px solid rgba(124,214,224,.22)",
background: "rgba(124,214,224,.05)",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<GitMerge size={16} style={{ color: "#7cd6e0", flex: "none" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: "#f3f3f5", fontWeight: 500 }}>
Merge this mission&rsquo;s work
</div>
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#8a8a92" }}>
{merge ? merge.reason : `${branch} → default branch`}
</div>
</div>
<button
type="button"
onClick={doMerge}
disabled={merging || merge?.merged === true}
style={{
...secondaryBtn,
padding: "5px 10px",
fontSize: 11,
opacity: merging || merge?.merged ? 0.6 : 1,
}}
>
{merging ? "Merging…" : merge?.merged ? "Merged" : "Merge to main"}
</button>
</div>
)}
{artifacts.map((a) => {
const isOpen = openId === a.id;
return (