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
This commit is contained in:
Fusion
2026-05-12 13:37:34 -07:00
committed by gsxdsm
parent 8653a368fe
commit d5d125c23e
2 changed files with 144 additions and 8 deletions

View File

@@ -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();

View File

@@ -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);