FN-7861: make pause/unpause task state update the board immediately

Patch useTasks pauseTask/unpauseTask to update local hook state and the project SWR task cache immediately on API success, instead of waiting for SSE/poll to reconcile paused state.

- pauseTask/unpauseTask now bump fetchVersionRef, patch the in-memory tasks list, and patch/clear the project SWR cache the same way retryTask/bypassReview already do
- guards against stale in-flight fetches clobbering the just-applied paused/unpaused state and against missing-id cache entries
- adds regression tests covering immediate local+cache reflection for pause and unpause, stale in-flight fetch ordering, and missing-id stability
- adds a patch changeset documenting the user-facing fix

Files changed:
 .changeset/fn-7861-immediate-pause-state.md        |   7 +
 .../dashboard/app/hooks/__tests__/useTasks.test.ts | 151 +++++++++++++++++++++
 packages/dashboard/app/hooks/useTasks.ts           |  66 ++++++++-
 3 files changed, 222 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-7861

Fusion-Task-Lineage: fefbaf4f-8eb7-44a7-a1e2-ac8471a726bd

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 11:23:23 -07:00
parent 9bb74595c2
commit 20c6db9534
3 changed files with 222 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Unpausing (and pausing) a task now updates the board immediately.
category: fix
dev: useTasks pauseTask/unpauseTask patch shared task state + SWR cache on API success (FN-7861), mirroring retryTask/bypassReview; no longer waits for SSE/poll.

View File

@@ -34,6 +34,8 @@ vi.mock("../../api", async (importOriginal) => {
mergeTask: vi.fn(),
retryTask: vi.fn(),
bypassReview: vi.fn(),
pauseTask: vi.fn(),
unpauseTask: vi.fn(),
duplicateTask: vi.fn(),
updateTask: vi.fn(),
archiveTask: vi.fn(),
@@ -53,6 +55,8 @@ const mockCreateTask = vi.mocked(api.createTask);
const mockDeleteTask = vi.mocked(api.deleteTask);
const mockRetryTask = vi.mocked(api.retryTask);
const mockBypassReview = vi.mocked(api.bypassReview);
const mockPauseTask = vi.mocked(api.pauseTask);
const mockUnpauseTask = vi.mocked(api.unpauseTask);
const mockDuplicateTask = vi.mocked(api.duplicateTask);
const mockUpdateTask = vi.mocked(api.updateTask);
const mockArchiveAllDone = vi.mocked(api.archiveAllDone);
@@ -104,6 +108,8 @@ beforeEach(() => {
mockFetchArchivedTasks.mockReset().mockResolvedValue({ tasks: [], total: 0, hasMore: false });
mockDeleteTask.mockReset();
mockRetryTask.mockReset();
mockPauseTask.mockReset();
mockUnpauseTask.mockReset();
mockReadCache.mockReset();
mockWriteCache.mockReset();
mockClearCache.mockReset();
@@ -1830,6 +1836,151 @@ describe("useTasks", () => {
});
});
describe("pauseTask and unpauseTask", () => {
it("FN-7861 immediately reflects unpaused state locally and in the project SWR cache without SSE", async () => {
const paused = createMockTask({
id: "FN-PAUSE",
column: "todo" as Column,
paused: true,
userPaused: true,
pausedByAgentId: null,
pausedReason: "operator",
});
const keep = createMockTask({ id: "FN-KEEP", column: "in-progress" as Column, paused: false, userPaused: false });
const unpaused = createMockTask({
...paused,
paused: false,
userPaused: false,
pausedByAgentId: null,
pausedReason: null,
updatedAt: "2026-07-12T00:00:00.000Z",
});
mockFetchTasks.mockResolvedValueOnce([paused, keep]);
mockUnpauseTask.mockResolvedValueOnce(unpaused);
const { result } = renderHook(() => useTasks({ projectId: "proj-1" }));
await waitFor(() => expect(result.current.tasks).toHaveLength(2));
mockReadCache.mockClear();
mockWriteCache.mockClear();
mockClearCache.mockClear();
mockReadCache.mockReturnValueOnce([paused, keep]);
let returned: Task | undefined;
await act(async () => {
returned = await result.current.unpauseTask("FN-PAUSE");
});
expect(mockUnpauseTask).toHaveBeenCalledWith("FN-PAUSE", "proj-1");
expect(returned).toEqual(expect.objectContaining({ id: "FN-PAUSE", paused: false, userPaused: false }));
expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")).toEqual(unpaused);
expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")?.paused).toBe(false);
expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")?.userPaused).toBe(false);
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
expect(mockWriteCache).toHaveBeenCalledWith(
`${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-1`,
[unpaused, keep],
{ maxBytes: 500_000 },
);
expect(mockClearCache).not.toHaveBeenCalled();
});
it("FN-7861 immediately reflects paused state locally and in the project SWR cache without SSE", async () => {
const unpaused = createMockTask({
id: "FN-PAUSE",
column: "todo" as Column,
paused: false,
userPaused: false,
pausedByAgentId: null,
pausedReason: null,
});
const keep = createMockTask({ id: "FN-KEEP", column: "in-progress" as Column, paused: false, userPaused: false });
const paused = createMockTask({
...unpaused,
paused: true,
userPaused: true,
pausedByAgentId: null,
pausedReason: "operator",
updatedAt: "2026-07-12T00:01:00.000Z",
});
mockFetchTasks.mockResolvedValueOnce([unpaused, keep]);
mockPauseTask.mockResolvedValueOnce(paused);
const { result } = renderHook(() => useTasks({ projectId: "proj-1" }));
await waitFor(() => expect(result.current.tasks).toHaveLength(2));
mockReadCache.mockClear();
mockWriteCache.mockClear();
mockClearCache.mockClear();
mockReadCache.mockReturnValueOnce([unpaused, keep]);
let returned: Task | undefined;
await act(async () => {
returned = await result.current.pauseTask("FN-PAUSE");
});
expect(mockPauseTask).toHaveBeenCalledWith("FN-PAUSE", "proj-1");
expect(returned).toEqual(expect.objectContaining({ id: "FN-PAUSE", paused: true, userPaused: true }));
expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")).toEqual(paused);
expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")?.paused).toBe(true);
expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")?.userPaused).toBe(true);
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
expect(mockWriteCache).toHaveBeenCalledWith(
`${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-1`,
[paused, keep],
{ maxBytes: 500_000 },
);
expect(mockClearCache).not.toHaveBeenCalled();
});
it("does not let an older in-flight fetch restore stale paused state after unpause", async () => {
const paused = createMockTask({ id: "FN-PAUSE", column: "todo" as Column, paused: true, userPaused: true });
const keep = createMockTask({ id: "FN-KEEP", column: "in-progress" as Column });
const unpaused = createMockTask({ ...paused, paused: false, userPaused: false, updatedAt: "2026-07-12T00:02:00.000Z" });
let resolveRefresh!: (tasks: Task[]) => void;
mockReadCache.mockReturnValue([paused, keep]);
mockFetchTasks.mockImplementationOnce(() => new Promise<Task[]>((resolve) => {
resolveRefresh = resolve;
}));
mockUnpauseTask.mockResolvedValueOnce(unpaused);
const { result } = renderHook(() => useTasks({ projectId: "proj-1" }));
expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-PAUSE", "FN-KEEP"]);
await waitFor(() => expect(mockFetchTasks).toHaveBeenCalledTimes(1));
await act(async () => {
await result.current.unpauseTask("FN-PAUSE");
});
expect(result.current.tasks).toEqual([unpaused, keep]);
await act(async () => {
resolveRefresh([paused, keep]);
await flushPromises();
});
expect(result.current.tasks).toEqual([unpaused, keep]);
});
it("leaves missing-id task collections stable after pause success", async () => {
const keep = createMockTask({ id: "FN-KEEP", column: "in-progress" as Column, paused: false, userPaused: false });
const pausedMissing = createMockTask({ id: "FN-MISSING", column: "todo" as Column, paused: true, userPaused: true });
mockFetchTasks.mockResolvedValueOnce([keep]);
mockPauseTask.mockResolvedValueOnce(pausedMissing);
const { result } = renderHook(() => useTasks());
await waitFor(() => expect(result.current.tasks).toEqual([keep]));
await act(async () => {
await result.current.pauseTask("FN-MISSING");
});
expect(result.current.tasks).toEqual([keep]);
});
});
describe("retryTask", () => {
it("FN-7295 immediately replaces every matching local retry task without SSE or refresh", async () => {
const failedOne = createMockTask({

View File

@@ -744,12 +744,74 @@ export function useTasks(options?: UseTasksOptions) {
return normalizeTask(await api.moveTask(id, column, projectId, optionsOrPosition));
}, [projectId]);
/*
FNXC:DashboardPauseState 2026-07-12-00:00:
FN-7861 makes pause and unpause user-visible state boundaries. After the API confirms either transition, patch shared hook state and the project SWR task cache immediately, mirroring retryTask/bypassReview, so Board/List/right-dock task renderers do not wait for SSE or polling to clear stale paused rendering.
*/
const pauseTask = useCallback(async (id: string): Promise<Task> => {
return normalizeTask(await api.pauseTask(id, projectId));
const updatedTask = normalizeTask(await api.pauseTask(id, projectId));
fetchVersionRef.current++;
const projectUpdatedTasks = (currentTasks: Task[]) => currentTasks.map((task) => (task.id === id ? updatedTask : task));
if (projectId) {
const cacheKey = `${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`;
const cachedTasks = readCache<unknown>(cacheKey, { maxAgeMs: SWR_TASKS_MAX_AGE_MS });
if (Array.isArray(cachedTasks)) {
const cacheContainsOnlyTaskRows = cachedTasks.every((task) => Boolean(task && typeof task === "object" && typeof (task as Task).id === "string"));
if (cacheContainsOnlyTaskRows) {
const nextCachedTasks = cachedTasks.map((task) => ((task as Task).id === id ? updatedTask : normalizeTask(task as Task)));
writeCache(cacheKey, nextCachedTasks.length > 500 ? nextCachedTasks.slice(0, 500) : nextCachedTasks, { maxBytes: 500_000 });
} else {
clearCache(cacheKey);
}
} else if (cachedTasks === null) {
const nextCurrentTasks = projectUpdatedTasks(tasksRef.current);
writeCache(cacheKey, nextCurrentTasks.length > 500 ? nextCurrentTasks.slice(0, 500) : nextCurrentTasks, { maxBytes: 500_000 });
} else {
clearCache(cacheKey);
}
}
setTasks((prev) => {
const next = projectUpdatedTasks(prev);
tasksRef.current = next;
return next;
});
return updatedTask;
}, [projectId]);
const unpauseTask = useCallback(async (id: string): Promise<Task> => {
return normalizeTask(await api.unpauseTask(id, projectId));
const updatedTask = normalizeTask(await api.unpauseTask(id, projectId));
fetchVersionRef.current++;
const projectUpdatedTasks = (currentTasks: Task[]) => currentTasks.map((task) => (task.id === id ? updatedTask : task));
if (projectId) {
const cacheKey = `${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`;
const cachedTasks = readCache<unknown>(cacheKey, { maxAgeMs: SWR_TASKS_MAX_AGE_MS });
if (Array.isArray(cachedTasks)) {
const cacheContainsOnlyTaskRows = cachedTasks.every((task) => Boolean(task && typeof task === "object" && typeof (task as Task).id === "string"));
if (cacheContainsOnlyTaskRows) {
const nextCachedTasks = cachedTasks.map((task) => ((task as Task).id === id ? updatedTask : normalizeTask(task as Task)));
writeCache(cacheKey, nextCachedTasks.length > 500 ? nextCachedTasks.slice(0, 500) : nextCachedTasks, { maxBytes: 500_000 });
} else {
clearCache(cacheKey);
}
} else if (cachedTasks === null) {
const nextCurrentTasks = projectUpdatedTasks(tasksRef.current);
writeCache(cacheKey, nextCurrentTasks.length > 500 ? nextCurrentTasks.slice(0, 500) : nextCurrentTasks, { maxBytes: 500_000 });
} else {
clearCache(cacheKey);
}
}
setTasks((prev) => {
const next = projectUpdatedTasks(prev);
tasksRef.current = next;
return next;
});
return updatedTask;
}, [projectId]);
const deleteTask = useCallback(async (