diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index 3821a0d3d9..a613fda48b 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -1318,6 +1318,44 @@ describe("ProjectEngine U0 merge unification dispatch", () => { expect(mocks.runAiMerge).not.toHaveBeenCalled(); await engine.stop(); }); + + // Regression: the auto-merge park for a WorkspaceTaskMergeError must set status:"failed", + // not status:null. status:null + mergeRetries:0 passes every eligibility gate, so the + // cooldown sweep re-enqueues the task every tick → tight re-throw/re-park loop. status:"failed" + // makes canMergeTask short-circuit; manual retry still works (it bypasses canMergeTask). + it("R7 auto-merge park: workspace task is parked status:'failed' so it is not re-enqueued", async () => { + const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); + mockStore.store.getTask.mockResolvedValue({ + id: "FN-WS-AUTO", + column: "in-review", + paused: false, + mergeRetries: 0, + status: "queued", + workspaceWorktrees: { + "repo-a": { worktreePath: "/tmp/a", branch: "fusion/fn-ws-a" }, + }, + } as any); + mocks.currentStore = mockStore.store; + + const engine = createEngine(); + await engine.start(); + // Auto-merge path (no manual resolver): the R7 door guard throws before runAiMerge, + // and the dispatch catch parks the task. + engine.enqueueMerge("FN-WS-AUTO"); + await vi.waitFor(() => { + expect(mockStore.store.updateTask).toHaveBeenCalledWith( + "FN-WS-AUTO", + expect.objectContaining({ status: "failed", mergeRetries: 0 }), + ); + }); + expect(mocks.runAiMerge).not.toHaveBeenCalled(); + // Guard against regression to the re-enqueue loop (status:null park): + expect(mockStore.store.updateTask).not.toHaveBeenCalledWith( + "FN-WS-AUTO", + expect.objectContaining({ status: null }), + ); + await engine.stop(); + }); }); describe("ProjectEngine merge queue priority ordering", () => { diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 5a343177f4..575464cc00 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -13,7 +13,7 @@ import type { ResearchSynthesisRequest, ResearchSynthesisResult, } from "@fusion/core"; -import { allowsAutoMergeProcessing, assertNotWorkspaceTaskMerge, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId, WorkspaceTaskMergeError } from "@fusion/core"; +import { allowsAutoMergeProcessing, assertNotWorkspaceTaskMerge, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { InProcessRuntime } from "./runtimes/in-process-runtime.js"; @@ -2287,11 +2287,15 @@ export class ProjectEngine { this.activeMergeSession = session; }, }; - // FNXC:Workspace 2026-06-21-19:05: + // FNXC:Workspace 2026-06-21-19:40: // R7 merge-boundary guard (master-plan U0). Reject workspace-mode // tasks BEFORE any git work — they need the per-repo merge loop that // lands in master-plan U6 (which removes this guard). Load the task // here so the dispatch shares the one predicate in @fusion/core. + // This door is a FAST-FAIL only: a getTask failure is swallowed to null + // and the guard is skipped, but the unconditional chokepoint guard inside + // runAiMerge (which re-reads the task) is the authoritative enforcement, + // so a transient read failure here cannot let a workspace task reach git work. const mergeTask = await store.getTask(taskId).catch(() => null); if (mergeTask) assertNotWorkspaceTaskMerge(mergeTask); @@ -2358,19 +2362,24 @@ export class ProjectEngine { continue; } - // FNXC:Workspace 2026-06-21-19:05: + // FNXC:Workspace 2026-06-21-19:40: // R7 workspace merge-boundary park (master-plan U0). A WorkspaceTaskMergeError // is a PERMANENT config error (workspace task hit a merge door before the // per-repo merge loop exists — master-plan U6), NOT a transient merge failure. - // Park the task WITHOUT burning mergeRetries (set to 0) so a human can manually - // retry after addressing the config; the default failed-path below would - // otherwise pin mergeRetries to the cap and permanently block manual retry. + // Park with status:"failed" so the auto-merge cooldown sweep STOPS re-attempting: + // `canMergeTask` short-circuits on status==="failed". (Parking with status:null + + // mergeRetries:0 passes every eligibility gate, so the sweep re-enqueues every tick + // → tight WorkspaceTaskMergeError re-throw/re-park loop.) Keep mergeRetries:0 (not + // the cap) so a human's manual merge after the config is addressed is not blocked by + // exhausted retries — and manual merge flows through the manual-resolver branch + // (rejectMergeResolvers), which bypasses canMergeTask, so "failed" never blocks it. + // Detect by err.name (matches the VerificationError/MergeAbortedError convention and + // is robust across the @fusion/core→@fusion/engine package boundary). const isWorkspaceMergeError = - err instanceof WorkspaceTaskMergeError - || (err as { name?: string } | null)?.name === "WorkspaceTaskMergeError"; + err instanceof Error && err.name === "WorkspaceTaskMergeError"; if (isWorkspaceMergeError) { runtimeLog.error( - `${hasManualResolver ? "Manual" : "Auto"}-merge blocked for ${taskId}: workspace-mode tasks cannot merge until per-repo merge support (master-plan U6) lands; parking without burning mergeRetries so a human can retry after the config is addressed: ${errorMsg}`, + `${hasManualResolver ? "Manual" : "Auto"}-merge blocked for ${taskId}: workspace-mode tasks cannot merge until per-repo merge support (master-plan U6) lands; parking as failed (manual retry still works) without exhausting mergeRetries: ${errorMsg}`, ); await store .logEntry(taskId, `Merge blocked: ${errorMsg}`, "WorkspaceTaskMergeError") @@ -2379,7 +2388,7 @@ export class ProjectEngine { this.rejectMergeResolvers(taskId, err instanceof Error ? err : new Error(errorMsg)); } else { await store - .updateTask(taskId, { status: null, mergeRetries: 0, error: errorMsg }) + .updateTask(taskId, { status: "failed", mergeRetries: 0, error: errorMsg }) .catch(() => undefined); } continue;