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

@@ -1,6 +1,9 @@
import { describe, it, expect } from "vitest";
import type { StepStatus } from "../types.js";
import {
BLOCKING_TASK_STATUSES,
HARD_BLOCKING_TASK_STATUSES,
SCHEDULER_TRANSIENT_STATUSES,
getTaskCompletionBlocker,
getTaskHardMergeBlocker,
getTaskMergeBlocker,
@@ -143,6 +146,53 @@ describe("getTaskMergeBlocker", () => {
.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", () => {
// Defensive: if this transient marker surfaces in in-review, the task
// needs investigation rather than auto-merge.

View File

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

View File

@@ -5232,6 +5232,12 @@ export async function findWorktreeUser(
}
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 */
onAgentText?: (delta: string) => void;
/** Called with agent tool usage */
@@ -6720,7 +6726,7 @@ export async function aiMergeTask(
branchDeleted: false,
};
}
const mergeBlocker = getTaskMergeBlocker(task);
const mergeBlocker = getTaskMergeBlocker(task, { manual: options.manual === true });
if (mergeBlocker) {
throw new Error(`Cannot merge ${taskId}: ${mergeBlocker}`);
}

View File

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