From 5c3170f58c011b2108e0b963adb90d8b238614b5 Mon Sep 17 00:00:00 2001 From: Fusion Date: Tue, 12 May 2026 13:37:34 -0700 Subject: [PATCH] feat(FN-4167): clear paused state in fn_task_done (FN-3964 follow-up) Fixes a FN-3964 follow-up issue where paused state wasn't being cleared on task completion, adding a test to cover the fn_task_done path. Fusion-Task-Id: FN-4167 --- .../src/__tests__/executor-prompt.test.ts | 144 +++++++++++++++++- packages/engine/src/executor.ts | 8 +- 2 files changed, 144 insertions(+), 8 deletions(-) diff --git a/packages/engine/src/__tests__/executor-prompt.test.ts b/packages/engine/src/__tests__/executor-prompt.test.ts index d4940d6dd..61b44564a 100644 --- a/packages/engine/src/__tests__/executor-prompt.test.ts +++ b/packages/engine/src/__tests__/executor-prompt.test.ts @@ -2108,8 +2108,11 @@ describe("TaskExecutor global pause behavior", () => { const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute(todoTask as any); - expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { paused: false, status: null }); - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: null }); + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { + paused: false, + pausedByAgentId: null, + status: null, + }); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress"); expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review"); expect(store.logEntry).toHaveBeenCalledWith( @@ -2124,6 +2127,143 @@ describe("TaskExecutor global pause behavior", () => { ).toBe(true); }); + describe("fn_task_done with paused state (FN-3964 / FN-4167 regression)", () => { + it("advances todo + paused tasks through normal completion handoff", async () => { + const store = createMockStore(); + let capturedCustomTools: any[] = []; + const todoTask = { + id: "FN-001", + title: "Paused todo task", + description: "T", + prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", + column: "todo", + paused: true, + pausedByAgentId: "agent-123", + dependencies: [], + steps: [{ name: "Step 1", status: "pending" }], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + store.getTask.mockResolvedValue(todoTask); + store.getSettings.mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 15000, + autoMerge: false, + globalPause: false, + enginePaused: false, + }); + store.moveTask.mockImplementation(async (_id: string, to: string) => ({ ...todoTask, column: to, paused: false })); + + mockedCreateFnAgent.mockImplementation((async (opts: any) => { + capturedCustomTools = opts.customTools || []; + return { + session: { + prompt: vi.fn().mockImplementation(async () => { + const taskDoneTool = capturedCustomTools.find((tool: any) => tool.name === "fn_task_done"); + if (taskDoneTool) { + await taskDoneTool.execute("call-1", { summary: "done" }); + } + }), + dispose: vi.fn(), + }, + }; + }) as any); + + const executor = new TaskExecutor(store, "/tmp/test"); + const watchdogSpy = vi.spyOn(executor as any, "scheduleCompletedTaskWatchdog"); + + await executor.execute(todoTask as any); + + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { + paused: false, + pausedByAgentId: null, + status: null, + }); + expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress"); + expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); + expect(watchdogSpy).toHaveBeenCalledWith("FN-001", "fn_task_done"); + expect( + store.logEntry.mock.calls.some( + ([id, action]: [string, string]) => + id === "FN-001" && action.includes("Completion handoff deferred — global pause active"), + ), + ).toBe(false); + // globalPause:true deferred behavior is intentionally covered by the test above. + }); + + it("completes in-progress + paused tasks after clearing task-level pause state", async () => { + const store = createMockStore(); + let capturedCustomTools: any[] = []; + const inProgressTask = { + id: "FN-001", + title: "Paused in-progress task", + description: "T", + prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", + column: "in-progress", + paused: true, + pausedByAgentId: "agent-123", + dependencies: [], + steps: [{ name: "Step 1", status: "pending" }], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + store.getTask.mockResolvedValue(inProgressTask); + store.getSettings.mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 15000, + autoMerge: false, + globalPause: false, + enginePaused: false, + }); + + mockedCreateFnAgent.mockImplementation((async (opts: any) => { + capturedCustomTools = opts.customTools || []; + return { + session: { + prompt: vi.fn().mockImplementation(async () => { + const taskDoneTool = capturedCustomTools.find((tool: any) => tool.name === "fn_task_done"); + if (taskDoneTool) { + await taskDoneTool.execute("call-1", { summary: "done" }); + } + }), + dispose: vi.fn(), + }, + }; + }) as any); + + const executor = new TaskExecutor(store, "/tmp/test"); + const watchdogSpy = vi.spyOn(executor as any, "scheduleCompletedTaskWatchdog"); + + await executor.execute(inProgressTask as any); + + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { + paused: false, + pausedByAgentId: null, + status: null, + }); + expect(watchdogSpy).toHaveBeenCalledWith("FN-001", "fn_task_done"); + expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); + expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo"); + expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-progress"); + expect( + store.logEntry.mock.calls.some( + ([id, action]: [string, string]) => + id === "FN-001" && action.includes("Completion handoff deferred — global pause active"), + ), + ).toBe(false); + // globalPause:true deferred behavior is intentionally covered by + // "parks todo tasks in in-progress when fn_task_done is called during global pause". + }); + }); + it("takes no action when globalPause remains false", async () => { const store = createMockStore(); const disposeFn = vi.fn(); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index b521286c3..3ee4c2ff2 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -4567,12 +4567,8 @@ export class TaskExecutor { } } const settings = await store.getSettings(); - const hardPauseActive = Boolean(task.paused || settings.globalPause); - if (hardPauseActive) { - await store.updateTask(taskId, { status: null }); - } else { - await store.updateTask(taskId, { paused: false, status: null }); - } + const hardPauseActive = Boolean(settings.globalPause); + await store.updateTask(taskId, { paused: false, pausedByAgentId: null, status: null }); await store.logEntry(taskId, "Task marked done by agent"); const latestTask = await store.getTask(taskId);