feat(FN-5438): add manual merge blocker bypass mode for queued tasks

Adds a manual merge blocker mode (FN-5438) that prevents automatic merging and provides a bypass mechanism to resume, wired through the merger, project engine, and task workflow API routes. Includes tests across core, engine route registration, and project engine layers, plus a changeset and documen

Fusion-Task-Id: FN-5438
This commit is contained in:
Fusion (runfusion.ai)
2026-05-21 18:28:07 -07:00
committed by gsxdsm
parent 854045f430
commit 025683ca60
10 changed files with 195 additions and 6 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Manual merge ("Merge now") no longer rejects in-review tasks that the scheduler has stamped with status: "queued". Auto-merge still honors all existing blockers.

View File

@@ -314,6 +314,7 @@ Hard-won rules (FN-2370 silently reverted three commits' worth of work):
10. **Layer 2.5 scope auto-widen before partition (FN-5226).** When Layer 3 sees out-of-scope conflict files and `scopeOverride !== true`, merger first runs a fail-closed scope auto-widen pass. A file is widened only when every branch-side touching commit is attributed to the current task, no other active non-terminal non-deleted task declares that exact path in `## File Scope`, and the path is neither `.fusion/*` nor gitignored. Successful widens are persisted by appending ``- `<path>` <!-- scopeAutoWiden FN-XXXX -->`` inside `## File Scope`, logged to agent logs, and audited via `merge:scope:auto-widen` (`{ taskId, file, attribution, commits }`). Any failed check (or persist failure) falls through to the existing strip-to-main path. 10. **Layer 2.5 scope auto-widen before partition (FN-5226).** When Layer 3 sees out-of-scope conflict files and `scopeOverride !== true`, merger first runs a fail-closed scope auto-widen pass. A file is widened only when every branch-side touching commit is attributed to the current task, no other active non-terminal non-deleted task declares that exact path in `## File Scope`, and the path is neither `.fusion/*` nor gitignored. Successful widens are persisted by appending ``- `<path>` <!-- scopeAutoWiden FN-XXXX -->`` inside `## File Scope`, logged to agent logs, and audited via `merge:scope:auto-widen` (`{ taskId, file, attribution, commits }`). Any failed check (or persist failure) falls through to the existing strip-to-main path.
11. **Auto-prerebase on hot-file/threshold divergence (FN-4958).** Before Stage 1 remote rebase, merger may prerebase the task branch onto local main when hot-file overlap or divergence threshold triggers (`packages/engine/src/merger-auto-prerebase.ts`). Failures are fail-soft (`merge:auto-prerebase:failed`) and fall through to the existing Stage 1/2/Layer 13 cascade; worktrunk-enabled paths defer this layer. 11. **Auto-prerebase on hot-file/threshold divergence (FN-4958).** Before Stage 1 remote rebase, merger may prerebase the task branch onto local main when hot-file overlap or divergence threshold triggers (`packages/engine/src/merger-auto-prerebase.ts`). Failures are fail-soft (`merge:auto-prerebase:failed`) and fall through to the existing Stage 1/2/Layer 13 cascade; worktrunk-enabled paths defer this layer.
12. **Integration branch advance is ref-only (FN-5350).** After the task worktree squash succeeds, the merger advances `refs/heads/<integration-branch>` via `git update-ref refs/heads/<integration> <new-sha> <expected-current-sha>` against the task-worktree git root, never via `git checkout <integration> && git merge --ff-only`. Compare-and-swap (`expected-current-sha`) preserves the concurrent-advance rule: if integration moved between detach and advance, `update-ref` refuses, the merger throws `IntegrationBranchConcurrentAdvanceError`, the task parks in `in-review` (`status: "failed"`), and upstream re-rebase machinery (FN-4500 / FN-5083 / standard re-execution) recovers on the next pass. Dirty + untracked files in the user's checked-out integration-branch worktree at `projectRootDir` are never touched and never block a merge. On successful advance, the merger logs `<integration> advanced to <sha> via update-ref; your checked-out worktree at <projectRootDir> is now behind` — informational, not an error. 12. **Integration branch advance is ref-only (FN-5350).** After the task worktree squash succeeds, the merger advances `refs/heads/<integration-branch>` via `git update-ref refs/heads/<integration> <new-sha> <expected-current-sha>` against the task-worktree git root, never via `git checkout <integration> && git merge --ff-only`. Compare-and-swap (`expected-current-sha`) preserves the concurrent-advance rule: if integration moved between detach and advance, `update-ref` refuses, the merger throws `IntegrationBranchConcurrentAdvanceError`, the task parks in `in-review` (`status: "failed"`), and upstream re-rebase machinery (FN-4500 / FN-5083 / standard re-execution) recovers on the next pass. Dirty + untracked files in the user's checked-out integration-branch worktree at `projectRootDir` are never touched and never block a merge. On successful advance, the merger logs `<integration> advanced to <sha> via update-ref; your checked-out worktree at <projectRootDir> is now behind` — informational, not an error.
13. **Manual merge queued bypass (FN-5438).** Manual merges (`ProjectEngine.onMerge` / dashboard “Merge now”) call `getTaskMergeBlocker(task, { manual: true })`, bypassing scheduler-transient `queued` while preserving all hard merge guards.
Audit verification surface for FN-5348/FN-5349/FN-5350 invariants: `merge:integration-worktree-state` (captures integration checkout/dirty state and selected integration mode before handoff), `merge:cwd-integration-fallback-refused` (records terminal park when reuse handoff refusal cannot be recovered), and `merge:integration-ref-advance` (records every integration ref advance attempt outcome with resolved branch/ref metadata). Audit verification surface for FN-5348/FN-5349/FN-5350 invariants: `merge:integration-worktree-state` (captures integration checkout/dirty state and selected integration mode before handoff), `merge:cwd-integration-fallback-refused` (records terminal park when reuse handoff refusal cannot be recovered), and `merge:integration-ref-advance` (records every integration ref advance attempt outcome with resolved branch/ref metadata).

View File

@@ -1,6 +1,9 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import type { StepStatus } from "../types.js"; import type { StepStatus } from "../types.js";
import { import {
BLOCKING_TASK_STATUSES,
HARD_BLOCKING_TASK_STATUSES,
SCHEDULER_TRANSIENT_STATUSES,
getTaskCompletionBlocker, getTaskCompletionBlocker,
getTaskHardMergeBlocker, getTaskHardMergeBlocker,
getTaskMergeBlocker, getTaskMergeBlocker,
@@ -143,6 +146,53 @@ describe("getTaskMergeBlocker", () => {
.toContain("queued"); .toContain("queued");
}); });
it("bypasses queued status when merge is manual", () => {
expect(getTaskMergeBlocker({ ...baseTask, status: "queued" }, { manual: true }))
.toBeUndefined();
});
it("still blocks hard statuses for manual merge", () => {
for (const status of HARD_BLOCKING_TASK_STATUSES) {
expect(getTaskMergeBlocker({ ...baseTask, status }, { manual: true }))
.toContain(status);
}
});
it("manual merge preserves non-status hard guards", () => {
expect(getTaskMergeBlocker({ ...baseTask, paused: true }, { manual: true }))
.toBe("task is paused");
expect(getTaskMergeBlocker({ ...baseTask, column: "todo" }, { manual: true }))
.toContain("must be in 'in-review'");
expect(getTaskMergeBlocker({
...baseTask,
steps: [{ name: "Step 1", status: "pending" }],
}, { manual: true })).toBe("task has incomplete steps");
expect(getTaskMergeBlocker({
...baseTask,
workflowStepResults: [{
workflowStepId: "WS-001",
workflowStepName: "Pre-merge Check",
phase: "pre-merge",
status: "failed",
}],
}, { manual: true })).toBe("task has failed pre-merge workflow steps");
});
it("manual false preserves default blocking behavior", () => {
expect(getTaskMergeBlocker({ ...baseTask, status: "queued" }, { manual: false }))
.toContain("queued");
});
it("blocking status partitions remain backward compatible", () => {
expect(SCHEDULER_TRANSIENT_STATUSES.has("queued")).toBe(true);
for (const status of HARD_BLOCKING_TASK_STATUSES) {
expect(BLOCKING_TASK_STATUSES.has(status)).toBe(true);
}
for (const status of SCHEDULER_TRANSIENT_STATUSES) {
expect(BLOCKING_TASK_STATUSES.has(status)).toBe(true);
}
});
it("returns reason when task is stuck-killed", () => { it("returns reason when task is stuck-killed", () => {
// Defensive: if this transient marker surfaces in in-review, the task // Defensive: if this transient marker surfaces in in-review, the task
// needs investigation rather than auto-merge. // needs investigation rather than auto-merge.

View File

@@ -33,7 +33,7 @@ export function resolveTaskMergeTarget(
return { branch: legacyFallback, source: "legacy-main" }; return { branch: legacyFallback, source: "legacy-main" };
} }
const BLOCKING_TASK_STATUSES = new Set([ export const HARD_BLOCKING_TASK_STATUSES = new Set([
"failed", "failed",
// ── User-attention / awaiting-handoff states ───────────────────────── // ── User-attention / awaiting-handoff states ─────────────────────────
"awaiting-inspection", "awaiting-inspection",
@@ -51,14 +51,22 @@ const BLOCKING_TASK_STATUSES = new Set([
"needs-replan", // scheduler/executor/triage signaled re-plan "needs-replan", // scheduler/executor/triage signaled re-plan
// ── Mission-level validation in flight ─────────────────────────────── // ── Mission-level validation in flight ───────────────────────────────
"mission-validation", "mission-validation",
// ── Scheduler-side transient state ───────────────────────────────────
"queued", // scheduler placed the task in line; not finalized
// ── Abnormal termination — defensive guard ─────────────────────────── // ── Abnormal termination — defensive guard ───────────────────────────
// Task was killed by the stuck detector. If it surfaces in in-review, // Task was killed by the stuck detector. If it surfaces in in-review,
// it needs investigation, not auto-merge. // it needs investigation, not auto-merge.
"stuck-killed", "stuck-killed",
]); ]);
export const SCHEDULER_TRANSIENT_STATUSES = new Set([
// scheduler placed the task in line; not finalized
"queued",
]);
export const BLOCKING_TASK_STATUSES = new Set([
...HARD_BLOCKING_TASK_STATUSES,
...SCHEDULER_TRANSIENT_STATUSES,
]);
const NON_TERMINAL_STEP_STATUSES = new Set([ const NON_TERMINAL_STEP_STATUSES = new Set([
"pending", "pending",
"in-progress", "in-progress",
@@ -74,6 +82,7 @@ const NON_TERMINAL_WORKFLOW_STATUSES = new Set<WorkflowStepResult["status"]>([
*/ */
export function getTaskMergeBlocker( export function getTaskMergeBlocker(
task: Pick<Task, "column" | "paused" | "status" | "error" | "steps" | "workflowStepResults">, task: Pick<Task, "column" | "paused" | "status" | "error" | "steps" | "workflowStepResults">,
options: { manual?: boolean } = {},
): string | undefined { ): string | undefined {
if (task.column !== "in-review") { if (task.column !== "in-review") {
return `task is in '${task.column}', must be in 'in-review'`; return `task is in '${task.column}', must be in 'in-review'`;
@@ -83,7 +92,8 @@ export function getTaskMergeBlocker(
return "task is paused"; return "task is paused";
} }
if (task.status && BLOCKING_TASK_STATUSES.has(task.status)) { const blockingStatuses = options.manual === true ? HARD_BLOCKING_TASK_STATUSES : BLOCKING_TASK_STATUSES;
if (task.status && blockingStatuses.has(task.status)) {
return task.error return task.error
? `task is marked '${task.status}': ${task.error}` ? `task is marked '${task.status}': ${task.error}`
: `task is marked '${task.status}'`; : `task is marked '${task.status}'`;

View File

@@ -0,0 +1,34 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import express from "express";
import type { TaskStore } from "@fusion/core";
import { createApiRoutes } from "../../routes.js";
import { request as REQUEST } from "../../test-request.js";
describe("task workflow merge route", () => {
it("invokes engine.onMerge for manual merge requests", async () => {
const store: TaskStore = {
getRootDir: vi.fn(() => process.cwd()),
mergeTask: vi.fn(),
} as unknown as TaskStore;
const onMerge = vi.fn(async (id: string) => ({
task: { id, column: "done" },
branch: `fusion/${id.toLowerCase()}`,
merged: true,
worktreeRemoved: false,
branchDeleted: false,
}));
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { onMerge }));
const res = await REQUEST(app, "POST", "/api/tasks/FN-5438/merge");
expect(res.status).toBe(200);
expect(onMerge).toHaveBeenCalledWith("FN-5438");
expect((res.body as { merged: boolean }).merged).toBe(true);
});
});

View File

@@ -997,7 +997,10 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
try { try {
const { store: scopedStore, engine } = await getProjectContext(req); const { store: scopedStore, engine } = await getProjectContext(req);
const merge = engine const merge = engine
? (id: string) => engine.onMerge(id) ? (id: string) => {
// Manual merge: bypasses scheduler-transient status blockers (FN-5438). Hard guards still apply.
return engine.onMerge(id);
}
: options?.onMerge ?? ((id: string) => scopedStore.mergeTask(id)); : options?.onMerge ?? ((id: string) => scopedStore.mergeTask(id));
const result = await merge(req.params.id); const result = await merge(req.params.id);
res.json(result); res.json(result);

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import type { Task, TaskStore } from "@fusion/core";
import { aiMergeTask } from "../merger.js";
const sentinel = new Error("sentinel-getSettings");
function createTask(status: Task["status"]): Task {
return {
id: "FN-5438",
title: "Manual merge queued bypass",
description: "",
column: "in-review",
status,
paused: false,
steps: [],
currentStep: 0,
log: [],
workflowStepResults: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
dependencies: [],
} as unknown as Task;
}
function createStore(task: Task): TaskStore {
return {
getTask: async () => task,
getSettings: async () => {
throw sentinel;
},
} as unknown as TaskStore;
}
describe("aiMergeTask manual queued bypass", () => {
it("blocks queued status for auto merge", async () => {
const store = createStore(createTask("queued"));
await expect(aiMergeTask(store, process.cwd(), "FN-5438")).rejects.toThrow(
"Cannot merge FN-5438: task is marked 'queued'",
);
});
it("bypasses queued status for manual merge", async () => {
const store = createStore(createTask("queued"));
await expect(aiMergeTask(store, process.cwd(), "FN-5438", { manual: true })).rejects.toBe(sentinel);
});
});

View File

@@ -1127,6 +1127,39 @@ describe("ProjectEngine shutdown merge handling", () => {
}); });
}); });
describe("ProjectEngine manual merge plumbing", () => {
beforeEach(() => {
vi.clearAllMocks();
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
mockStore.store.getTask.mockResolvedValue({
id: "FN-5438",
column: "in-review",
paused: false,
mergeRetries: 0,
status: "queued",
} as any);
mocks.currentStore = mockStore.store;
});
it("passes manual=true to aiMergeTask for onMerge requests", async () => {
mocks.aiMergeTask.mockResolvedValue({ merged: true, task: { id: "FN-5438" } } as any);
const engine = createEngine();
await engine.start();
await engine.onMerge("FN-5438");
expect(mocks.aiMergeTask).toHaveBeenCalledWith(
expect.anything(),
expect.any(String),
"FN-5438",
expect.objectContaining({ manual: true }),
);
await engine.stop();
});
});
describe("ProjectEngine merge queue priority ordering", () => { describe("ProjectEngine merge queue priority ordering", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -5232,6 +5232,12 @@ export async function findWorktreeUser(
} }
export interface MergerOptions { export interface MergerOptions {
/**
* When true, skip scheduler-transient status blockers (`queued`).
* Hard guards (paused, column, incomplete steps, in-flight merge,
* failed pre-merge workflow steps) still apply. Set by `ProjectEngine.onMerge`.
*/
manual?: boolean;
/** Called with agent text output */ /** Called with agent text output */
onAgentText?: (delta: string) => void; onAgentText?: (delta: string) => void;
/** Called with agent tool usage */ /** Called with agent tool usage */
@@ -6720,7 +6726,7 @@ export async function aiMergeTask(
branchDeleted: false, branchDeleted: false,
}; };
} }
const mergeBlocker = getTaskMergeBlocker(task); const mergeBlocker = getTaskMergeBlocker(task, { manual: options.manual === true });
if (mergeBlocker) { if (mergeBlocker) {
throw new Error(`Cannot merge ${taskId}: ${mergeBlocker}`); throw new Error(`Cannot merge ${taskId}: ${mergeBlocker}`);
} }

View File

@@ -1531,6 +1531,7 @@ export class ProjectEngine {
this.activeMergeTaskId = taskId; this.activeMergeTaskId = taskId;
this.mergeAbortController = new AbortController(); this.mergeAbortController = new AbortController();
return aiMergeTask(store, cwd, taskId, { return aiMergeTask(store, cwd, taskId, {
manual: !!manualResolver,
pool, pool,
usageLimitPauser, usageLimitPauser,
agentStore, agentStore,