FN-8273: add mission auto-merge overrides

Allow operators to override project auto-merge behavior for each mission.

- Add create and update API support for mission auto-merge overrides, including clearing inherited behavior.
- Expose inherited, auto-merge, and single-pull-request choices in all mission forms.
- Apply disabled mission auto-merge to newly triaged tasks and cover storage, API, and UI behavior.
- Document the override and add a CLI changeset.

Files changed:
 .changeset/fn-8273-mission-auto-merge.md           |   7 ++
 docs/missions.md                                   |   4 +
 .../mission-store.sync-auto-merge.test.ts          |  22 ++++
 .../__tests__/postgres/mission-store.pg.test.ts    |  35 ++++++
 packages/core/src/async-mission-store.ts           |   5 +
 packages/core/src/mission-store.ts                 |   5 +
 packages/dashboard/app/api/legacy.ts               |   4 +-
 .../dashboard/app/components/MissionManager.tsx    |  55 +++++++++
 .../__tests__/MissionManager.auto-merge.test.tsx   | 132 +++++++++++++++++++++
 packages/dashboard/app/components/mission-types.ts |   2 +
 packages/dashboard/src/mission-routes.ts           |  18 ++-
 .../routes/__tests__/mission-autoMerge-e2e.test.ts |  66 +++++++++++
 12 files changed, 351 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-8273

Fusion-Task-Lineage: d24d7c22-5a14-4446-b3e1-c35b2f0480c3

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 08:01:44 -07:00
parent 6308887b45
commit 69e7a34077
12 changed files with 351 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a mission auto-merge override so a mission's features share one branch and one PR.
category: feature
dev: MissionManager create/edit tri-state control persists Mission.autoMerge; mission triage stamps task.autoMerge=false when the mission override is false. POST accepts autoMerge and PATCH null clears to inherited.

View File

@@ -97,6 +97,10 @@ Supported modes:
The Mission Manager create/edit form exposes this as **Branch strategy** plus a conditional **Branch name** field for `existing` and `custom-new`.
### Mission auto-merge override
The **Merge behavior** control can inherit the project default, explicitly enable auto-merge, or select **Single pull request**. The latter persists `autoMerge: false` on the mission and stamps newly triaged feature tasks with the same false override, while preserving the mission's shared branch group. Returning the control to inherited clears the mission override.
### Shared branch-group invariant across entry points
Across all branch entry points (planning/subtask creation, mission triage, and New Task `shared-group` creation), Fusion enforces one rule:

View File

@@ -0,0 +1,22 @@
/*
FNXC:MissionAutoMerge 2026-07-18-12:00:
The legacy synchronous MissionStore remains a supported fallback even though PostgreSQL
is the production backend. Keep its create-only triage contract aligned with AsyncMissionStore:
only an explicit false mission override is forwarded to TaskStore.createTask.
*/
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("MissionStore synchronous triage auto-merge contract", () => {
it("stamps only false on the synchronous create branch after the duplicate guard", async () => {
const source = await readFile(fileURLToPath(new URL("../mission-store.ts", import.meta.url)), "utf8");
const triageFeature = source.slice(source.indexOf("async triageFeature("), source.indexOf("async triageSlice("));
expect(triageFeature).toContain('if (guard.action === "duplicate" && guard.existing)');
expect(triageFeature).toContain("...(mission?.autoMerge === false ? { autoMerge: false } : {}),");
expect(triageFeature.indexOf('if (guard.action === "duplicate" && guard.existing)'))
.toBeLessThan(triageFeature.indexOf("...(mission?.autoMerge === false ? { autoMerge: false } : {}),"));
});
});

View File

@@ -135,6 +135,41 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => {
expect(tree!.milestones[0]!.slices[0]!.features[0]!.id).toBe(feature.id);
});
it("stamps only autoMerge:false mission triage tasks while preserving the shared branch group", async () => {
const m = missions();
const mission = await m.createMission({ title: "Single PR", autoMerge: false });
const milestone = await m.addMilestone(mission.id, { title: "MS" });
const slice = await m.addSlice(milestone.id, { title: "SL" });
const [single, bulk] = await Promise.all([
m.addFeature(slice.id, { title: "Single" }),
m.addFeature(slice.id, { title: "Bulk" }),
]);
await m.triageFeature(single.id);
await m.triageSlice(slice.id);
const tasks = await h.store().listTasks();
const triaged = tasks.filter((task) => ["Single", "Bulk"].includes(task.title));
expect(triaged).toHaveLength(2);
expect(triaged.map((task) => task.autoMerge)).toEqual([false, false]);
// Single and bulk triage must join the one lazily-created mission group, not merely any group.
expect(new Set(triaged.map((task) => task.branchContext?.groupId))).toEqual(new Set([triaged[0]!.branchContext!.groupId]));
expect(triaged[0]!.branchContext?.groupId).toBeDefined();
});
it("leaves task autoMerge inherited for undefined and true mission overrides", async () => {
const m = missions();
for (const autoMerge of [undefined, true] as const) {
const mission = await m.createMission({ title: `Inherited ${String(autoMerge)}`, autoMerge });
const milestone = await m.addMilestone(mission.id, { title: "MS" });
const slice = await m.addSlice(milestone.id, { title: "SL" });
const feature = await m.addFeature(slice.id, { title: "Feature" });
await m.triageFeature(feature.id);
const task = (await h.store().listTasks()).find((candidate) => candidate.title === "Feature");
expect(task?.autoMerge).toBeUndefined();
}
});
it("listMissionsWithSummaries returns hierarchy counts", async () => {
const m = missions();
const mission = await m.createMission({ title: "Counted" });

View File

@@ -1653,6 +1653,11 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
},
}
: {}),
/*
FNXC:MissionAutoMerge 2026-07-18-12:00:
An autoMerge:false mission stamps each newly triaged task so its shared branch produces one PR instead of per-task auto-merges. Duplicate reuse intentionally bypasses this create-only override.
*/
...(mission?.autoMerge === false ? { autoMerge: false } : {}),
...(branchOptions?.workflowId !== undefined ? { workflowId: branchOptions.workflowId } : {}),
});
if (guard.fingerprint) {

View File

@@ -4066,6 +4066,11 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
FNXC:MissionWorkflows 2026-06-25-00:00:
Apply the selected Missions header workflow atomically during TaskStore.createTask so newly triaged features land in the intended workflow lane. Duplicate-guard reuses skip this create path, preserving existing duplicate tasks without workflow mutation.
*/
/*
FNXC:MissionAutoMerge 2026-07-18-12:00:
An autoMerge:false mission stamps each newly triaged task so its shared branch produces one PR instead of per-task auto-merges. Duplicate reuse intentionally bypasses this create-only override.
*/
...(mission?.autoMerge === false ? { autoMerge: false } : {}),
...(branchOptions?.workflowId !== undefined ? { workflowId: branchOptions.workflowId } : {}),
});

View File

@@ -7249,7 +7249,7 @@ export function fetchMissions(projectId?: string): Promise<MissionWithSummary[]>
}
/** Create a new mission */
export function createMission(input: { title: string; description?: string; autoAdvance?: boolean; autopilotEnabled?: boolean; baseBranch?: string; branchStrategy?: Mission["branchStrategy"] }, projectId?: string): Promise<Mission> {
export function createMission(input: { title: string; description?: string; autoAdvance?: boolean; autopilotEnabled?: boolean; autoMerge?: boolean; baseBranch?: string; branchStrategy?: Mission["branchStrategy"] }, projectId?: string): Promise<Mission> {
return api<Mission>(withProjectId("/missions", projectId), {
method: "POST",
body: JSON.stringify(input),
@@ -7262,7 +7262,7 @@ export function fetchMission(missionId: string, projectId?: string): Promise<Mis
}
/** Update mission */
export function updateMission(missionId: string, updates: Partial<Mission>, projectId?: string): Promise<Mission> {
export function updateMission(missionId: string, updates: Partial<Mission> & { autoMerge?: boolean | null }, projectId?: string): Promise<Mission> {
return api<Mission>(withProjectId(`/missions/${encodeURIComponent(missionId)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),

View File

@@ -286,11 +286,15 @@ interface MissionBranchStrategy {
branchName?: string;
}
type MissionAutoMergeOverride = "inherit" | "on" | "off";
interface MissionFormData {
title: string;
description: string;
status: MissionStatus;
autopilotEnabled: boolean;
/** FNXC:MissionAutoMerge 2026-07-18-12:00: Preserve inherited project behavior until an operator explicitly selects an override. */
autoMergeOverride: MissionAutoMergeOverride;
baseBranch: string;
branchStrategy: MissionBranchStrategy;
}
@@ -321,6 +325,7 @@ const EMPTY_MISSION_FORM: MissionFormData = {
description: "",
status: "planning",
autopilotEnabled: false,
autoMergeOverride: "inherit",
baseBranch: "",
branchStrategy: {
mode: "project-default",
@@ -348,6 +353,14 @@ const EMPTY_FEATURE_FORM: FeatureFormData = {
status: "defined",
};
function missionAutoMergeOverride(autoMerge?: boolean): MissionAutoMergeOverride {
return autoMerge === true ? "on" : autoMerge === false ? "off" : "inherit";
}
function resolveMissionAutoMerge(override: MissionAutoMergeOverride): boolean | undefined {
return override === "on" ? true : override === "off" ? false : undefined;
}
function normalizeMissionBranchStrategy(strategy?: Mission["branchStrategy"]): MissionBranchStrategy {
if (!strategy) {
return { mode: "project-default" };
@@ -1607,6 +1620,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
description: mission.description || "",
status: mission.status,
autopilotEnabled: mission.autopilotEnabled ?? false,
autoMergeOverride: missionAutoMergeOverride(mission.autoMerge),
baseBranch: mission.baseBranch ?? "",
branchStrategy: normalizeMissionBranchStrategy(mission.branchStrategy),
});
@@ -1644,6 +1658,9 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
title: missionForm.title.trim(),
description: missionForm.description.trim() || undefined,
autopilotEnabled: missionForm.autopilotEnabled,
...(resolveMissionAutoMerge(missionForm.autoMergeOverride) !== undefined
? { autoMerge: resolveMissionAutoMerge(missionForm.autoMergeOverride) }
: {}),
baseBranch: missionForm.baseBranch.trim() || undefined,
branchStrategy,
}, projectId);
@@ -1656,6 +1673,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
description: missionForm.description.trim() || undefined,
status: missionForm.status,
autopilotEnabled: missionForm.autopilotEnabled,
// FNXC:MissionAutoMerge 2026-07-18-12:00: JSON drops undefined, so edit inheritance must use null to clear a saved override.
autoMerge: resolveMissionAutoMerge(missionForm.autoMergeOverride) ?? null,
baseBranch: missionForm.baseBranch.trim() || "",
branchStrategy,
};
@@ -2829,6 +2848,18 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<option value="custom-new">{t("missions.branchStrategyCustomNew", "Create custom branch")}</option>
</select>
</label>
<label>
{t("missions.autoMergeOverride", "Merge behavior")}
<select
value={missionForm.autoMergeOverride}
onChange={(e) => setMissionForm({ ...missionForm, autoMergeOverride: e.target.value as MissionAutoMergeOverride })}
aria-label={t("missions.autoMergeOverrideAriaLabel", "Mission auto-merge override")}
>
<option value="inherit">{t("missions.autoMergeInherited", "Use project default")}</option>
<option value="on">{t("missions.autoMergeOn", "Auto-merge")}</option>
<option value="off">{t("missions.singlePullRequest", "Single pull request")}</option>
</select>
</label>
{(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && (
<label>
{t("missions.branchName", "Branch name")}
@@ -4554,6 +4585,18 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<option value="custom-new">{t("missions.branchStrategyCustomNew", "Create custom branch")}</option>
</select>
</label>
<label>
{t("missions.autoMergeOverride", "Merge behavior")}
<select
value={missionForm.autoMergeOverride}
onChange={(e) => setMissionForm({ ...missionForm, autoMergeOverride: e.target.value as MissionAutoMergeOverride })}
aria-label={t("missions.autoMergeOverrideAriaLabel", "Mission auto-merge override")}
>
<option value="inherit">{t("missions.autoMergeInherited", "Use project default")}</option>
<option value="on">{t("missions.autoMergeOn", "Auto-merge")}</option>
<option value="off">{t("missions.singlePullRequest", "Single pull request")}</option>
</select>
</label>
{(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && (
<label>
{t("missions.branchName", "Branch name")}
@@ -4648,6 +4691,18 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<option value="custom-new">{t("missions.branchStrategyCustomNew", "Create custom branch")}</option>
</select>
</label>
<label>
{t("missions.autoMergeOverride", "Merge behavior")}
<select
value={missionForm.autoMergeOverride}
onChange={(e) => setMissionForm({ ...missionForm, autoMergeOverride: e.target.value as MissionAutoMergeOverride })}
aria-label={t("missions.autoMergeOverrideAriaLabel", "Mission auto-merge override")}
>
<option value="inherit">{t("missions.autoMergeInherited", "Use project default")}</option>
<option value="on">{t("missions.autoMergeOn", "Auto-merge")}</option>
<option value="off">{t("missions.singlePullRequest", "Single pull request")}</option>
</select>
</label>
{(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && (
<label>
{t("missions.branchName", "Branch name")}

View File

@@ -0,0 +1,132 @@
/*
FNXC:MissionAutoMerge 2026-07-18-12:00:
Mission edits need an explicit inherited state: the client must send null rather than
undefined so JSON serialization clears an existing mission auto-merge override.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MissionManager } from "../MissionManager";
const mockFetchMissions = vi.fn();
const mockFetchMission = vi.fn();
const mockFetchMissionsHealth = vi.fn();
const mockFetchAiSessions = vi.fn();
const mockFetchMissionInterviewDrafts = vi.fn();
const mockUpdateMission = vi.fn();
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
return {
...actual,
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
};
});
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>();
return {
...actual,
fetchMissions: (...args: unknown[]) => mockFetchMissions(...args),
fetchMission: (...args: unknown[]) => mockFetchMission(...args),
fetchMissionsHealth: (...args: unknown[]) => mockFetchMissionsHealth(...args),
fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
fetchMissionInterviewDrafts: (...args: unknown[]) => mockFetchMissionInterviewDrafts(...args),
updateMission: (...args: unknown[]) => mockUpdateMission(...args),
};
});
const now = "2026-07-18T12:00:00.000Z";
function mission(autoMerge?: boolean) {
return {
id: "M-001",
title: "Single PR Mission",
description: "",
status: "planning",
autoMerge,
milestones: [],
createdAt: now,
updatedAt: now,
};
}
function setDesktopViewport() {
Object.defineProperty(window, "innerWidth", { value: 1440, configurable: true });
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
async function openEditForm(autoMerge?: boolean) {
const detail = mission(autoMerge);
mockFetchMissions.mockResolvedValue([detail]);
mockFetchMission.mockResolvedValue(detail);
render(<MissionManager isInline isOpen onClose={() => {}} addToast={() => {}} projectId="project-1" />);
fireEvent.click(await screen.findByText("Single PR Mission"));
const editButtons = await screen.findAllByRole("button", { name: "Edit mission" });
fireEvent.click(editButtons[0]!);
return screen.getByLabelText("Mission auto-merge override") as HTMLSelectElement;
}
describe("MissionManager auto-merge override", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
setDesktopViewport();
mockFetchMissionsHealth.mockResolvedValue({});
mockFetchAiSessions.mockResolvedValue([]);
mockFetchMissionInterviewDrafts.mockResolvedValue([]);
mockUpdateMission.mockResolvedValue(mission());
});
it.each([
[undefined, "inherit"],
[true, "on"],
[false, "off"],
] as const)("reflects a %s mission override as %s", async (autoMerge, expected) => {
const control = await openEditForm(autoMerge);
expect(control.value).toBe(expected);
});
it("sends null when an existing override is returned to inherited", async () => {
const control = await openEditForm(false);
fireEvent.change(control, { target: { value: "inherit" } });
fireEvent.click(screen.getByRole("button", { name: "Update" }));
await waitFor(() => {
expect(mockUpdateMission).toHaveBeenCalledWith(
"M-001",
expect.objectContaining({ autoMerge: null }),
"project-1",
);
});
});
it.each([
["on", true],
["off", false],
] as const)("sends %s as an explicit %s override", async (selection, expected) => {
const control = await openEditForm();
fireEvent.change(control, { target: { value: selection } });
fireEvent.click(screen.getByRole("button", { name: "Update" }));
await waitFor(() => {
expect(mockUpdateMission).toHaveBeenCalledWith(
"M-001",
expect.objectContaining({ autoMerge: expected }),
"project-1",
);
});
});
});

View File

@@ -58,6 +58,8 @@ export interface Mission {
};
status: MissionStatus;
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
/** Mission-level auto-merge override for linked task branches. */
autoMerge?: boolean;
autoAdvance?: boolean;
autopilotEnabled?: boolean;
autopilotState?: AutopilotState;

View File

@@ -442,7 +442,7 @@ export function createMissionRouter(
router.post(
"/",
catchTypedHandler(async (req, res) => {
const { title, description, autoAdvance, baseBranch, branchStrategy, goalIds } = req.body;
const { title, description, autoAdvance, autoMerge, baseBranch, branchStrategy, goalIds } = req.body;
const validatedTitle = validateTitle(title);
const validatedDescription = validateDescription(description);
@@ -453,6 +453,14 @@ export function createMissionRouter(
description: validatedDescription,
baseBranch: validateDescription(baseBranch),
branchStrategy: validateMissionBranchStrategy(branchStrategy),
...(autoMerge !== undefined
? {
// FNXC:MissionAutoMerge 2026-07-18-12:00: Create accepts only a real boolean; null is reserved for PATCH clear-to-inherited.
autoMerge: typeof autoMerge === "boolean"
? validateBoolean(autoMerge, "autoMerge")
: (() => { throw badRequest("autoMerge must be a boolean"); })(),
}
: {}),
};
const mission = await missionStore.createMission(input);
@@ -1079,7 +1087,7 @@ export function createMissionRouter(
"/:missionId",
catchTypedHandler(async (req, res) => {
const { missionId } = req.params;
const { title, description, status, autoAdvance, autopilotEnabled, baseBranch, branchStrategy, goalIds } = req.body;
const { title, description, status, autoAdvance, autoMerge, autopilotEnabled, baseBranch, branchStrategy, goalIds } = req.body;
if (!validateMissionId(missionId)) {
throw badRequest("Invalid mission ID format");
@@ -1105,6 +1113,12 @@ export function createMissionRouter(
if (autoAdvance !== undefined) {
updates.autoAdvance = validateBoolean(autoAdvance, "autoAdvance");
}
// FNXC:MissionAutoMerge 2026-07-18-12:00: PATCH null explicitly clears a mission override; omission preserves it.
if (autoMerge === null) {
updates.autoMerge = undefined;
} else if (autoMerge !== undefined) {
updates.autoMerge = validateBoolean(autoMerge, "autoMerge");
}
if (autopilotEnabled !== undefined) {
updates.autopilotEnabled = validateBoolean(autopilotEnabled, "autopilotEnabled");
}

View File

@@ -0,0 +1,66 @@
// @vitest-environment node
/*
FNXC:MissionAutoMerge 2026-07-18-12:00:
The mission HTTP surface must preserve boolean overrides and accept PATCH null as the
explicit clear operation. Triage through the production router then stamps only false
onto the created task while retaining its shared mission BranchGroup context.
*/
import { afterEach, beforeEach, expect, it } from "vitest";
import express from "express";
import { TaskStore } from "@fusion/core";
import { createTaskStoreForTest, pgDescribe, type PgTestHarness } from "../../../../core/src/__test-utils__/pg-test-harness.js";
import { createApiRoutes } from "../../routes.js";
import { request as REQUEST } from "../../test-request.js";
pgDescribe("mission autoMerge routes", () => {
let harness: PgTestHarness;
let store: TaskStore;
let app: express.Express;
beforeEach(async () => {
harness = await createTaskStoreForTest();
store = harness.store;
app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
});
afterEach(async () => {
await harness.teardown();
});
const request = (method: "POST" | "PATCH", path: string, body: unknown) =>
REQUEST(app, method, path, JSON.stringify(body), { "content-type": "application/json" });
it("round-trips false, true, and clear-to-inherited while triage stamps false", async () => {
const created = await request("POST", "/api/missions", { title: "Single PR", autoMerge: false });
expect(created.status).toBe(201);
const missionId = (created.body as { id: string; autoMerge?: boolean }).id;
expect((created.body as { autoMerge?: boolean }).autoMerge).toBe(false);
const enabled = await request("PATCH", `/api/missions/${missionId}`, { autoMerge: true });
expect(enabled.status).toBe(200);
expect((enabled.body as { autoMerge?: boolean }).autoMerge).toBe(true);
const cleared = await request("PATCH", `/api/missions/${missionId}`, { autoMerge: null });
expect(cleared.status).toBe(200);
expect((cleared.body as { autoMerge?: boolean }).autoMerge).toBeUndefined();
expect((await store.getMissionStore().getMission(missionId))?.autoMerge).toBeUndefined();
const nullCreate = await request("POST", "/api/missions", { title: "Invalid null", autoMerge: null });
expect(nullCreate.status).toBe(400);
const missionStore = store.getMissionStore();
const falseMission = await missionStore.createMission({ title: "False triage", autoMerge: false });
const milestone = await missionStore.addMilestone(falseMission.id, { title: "Milestone" });
const slice = await missionStore.addSlice(milestone.id, { title: "Slice" });
const feature = await missionStore.addFeature(slice.id, { title: "Task is held for one PR" });
const triaged = await request("POST", `/api/missions/features/${feature.id}/triage`, {});
expect(triaged.status).toBe(200);
const task = await store.getTask((triaged.body as { taskId: string }).taskId);
expect(task?.autoMerge).toBe(false);
expect(task?.branchContext?.groupId).toBeDefined();
});
});