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:
committed by
gsxdsm
parent
854045f430
commit
025683ca60
5
.changeset/FN-5438-manual-merge-bypass-queued.md
Normal file
5
.changeset/FN-5438-manual-merge-bypass-queued.md
Normal 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.
|
||||||
@@ -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 1–3 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 1–3 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).
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}'`;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
46
packages/engine/src/__tests__/manual-merge-bypass.test.ts
Normal file
46
packages/engine/src/__tests__/manual-merge-bypass.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user