From c5dd8c50b7890ff781f3e11d23a4e46991eb8cac Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 30 Jun 2026 13:25:35 -0700 Subject: [PATCH] FN-7295: refresh retry state immediately Refresh task retry state immediately after the retry API confirms success. - Replace matching local task rows with the normalized retried task before waiting for SSE or polling. - Update or clear the project task SWR cache to prevent stale retry snapshots from reappearing. - Add hook coverage for duplicate task rows, missing rows, cache handling, stale in-flight fetches, and rejected retries. - Record a patch changeset for the published CLI package. Files changed: .changeset/fn-7295-retry-refresh.md | 7 + .../dashboard/app/hooks/__tests__/useTasks.test.ts | 216 +++++++++++++++++++++ packages/dashboard/app/hooks/useTasks.ts | 38 +++- 3 files changed, 260 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-7295 Fusion-Task-Lineage: db205b19-ad8f-4312-a364-c7bd7758df41 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7295-retry-refresh.md | 7 + .../app/hooks/__tests__/useTasks.test.ts | 216 ++++++++++++++++++ packages/dashboard/app/hooks/useTasks.ts | 38 ++- 3 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 .changeset/fn-7295-retry-refresh.md diff --git a/.changeset/fn-7295-retry-refresh.md b/.changeset/fn-7295-retry-refresh.md new file mode 100644 index 0000000000..e581cb522b --- /dev/null +++ b/.changeset/fn-7295-retry-refresh.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Refresh dashboard task state immediately after Retry succeeds. +category: fix +dev: useTasks now replaces matching retry rows, updates project SWR task cache, and invalidates older fetches. diff --git a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts index 51651ff74f..7cf372f803 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts @@ -49,6 +49,7 @@ async function flushPromises(): Promise { const mockFetchTasks = vi.mocked(api.fetchTasks); const mockCreateTask = vi.mocked(api.createTask); const mockDeleteTask = vi.mocked(api.deleteTask); +const mockRetryTask = vi.mocked(api.retryTask); const mockDuplicateTask = vi.mocked(api.duplicateTask); const mockUpdateTask = vi.mocked(api.updateTask); const mockArchiveAllDone = vi.mocked(api.archiveAllDone); @@ -98,6 +99,7 @@ beforeEach(() => { (globalThis as any).EventSource = MockEventSource; mockFetchTasks.mockReset().mockResolvedValue([]); mockDeleteTask.mockReset(); + mockRetryTask.mockReset(); mockReadCache.mockReset(); mockWriteCache.mockReset(); mockClearCache.mockReset(); @@ -1598,6 +1600,220 @@ describe("useTasks", () => { }); }); + describe("retryTask", () => { + it("FN-7295 immediately replaces every matching local retry task without SSE or refresh", async () => { + const failedOne = createMockTask({ + id: "FN-RETRY", + title: "Failed duplicate one", + column: "in-progress" as Column, + status: "failed", + error: "Executor crashed", + worktree: "/tmp/stale-worktree", + branch: "fusion/FN-RETRY-stale", + currentStep: 2, + }); + const keep = createMockTask({ id: "FN-KEEP", title: "Keep", column: "todo" as Column }); + const failedTwo = createMockTask({ + id: "FN-RETRY", + title: "Failed duplicate two", + column: "in-review" as Column, + status: "stuck-killed", + error: "Merge stalled", + worktree: "/tmp/stale-review", + branch: "fusion/FN-RETRY-review", + currentStep: 3, + }); + const retried = createMockTask({ + id: "FN-RETRY", + title: "Retried from server", + column: "todo" as Column, + status: null, + error: null, + worktree: null, + branch: null, + currentStep: 0, + updatedAt: "2026-06-30T12:00:00.000Z", + }); + mockFetchTasks.mockResolvedValueOnce([failedOne, keep, failedTwo]); + mockRetryTask.mockResolvedValueOnce(retried); + + const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); + + await waitFor(() => expect(result.current.tasks).toHaveLength(3)); + + let returned: Task | undefined; + await act(async () => { + returned = await result.current.retryTask("FN-RETRY"); + }); + + expect(mockRetryTask).toHaveBeenCalledWith("FN-RETRY", "proj-1"); + expect(returned).toEqual(expect.objectContaining({ id: "FN-RETRY", column: "todo", status: null, error: null })); + expect(result.current.tasks).toEqual([retried, keep, retried]); + expect(result.current.tasks.filter((task) => task.id === "FN-RETRY")).toHaveLength(2); + expect(result.current.tasks.filter((task) => task.id === "FN-RETRY").every((task) => task.status === null && task.error === null)).toBe(true); + expect(mockFetchTasks).toHaveBeenCalledTimes(1); + }); + + it("leaves empty and missing-id task collections stable after retry success", async () => { + const retried = createMockTask({ id: "FN-MISSING", column: "todo" as Column, status: null, error: null }); + mockFetchTasks.mockResolvedValueOnce([]); + mockRetryTask.mockResolvedValueOnce(retried); + + const emptyHook = renderHook(() => useTasks()); + + await waitFor(() => expect(emptyHook.result.current.tasks).toEqual([])); + + await act(async () => { + await emptyHook.result.current.retryTask("FN-MISSING"); + }); + + expect(emptyHook.result.current.tasks).toEqual([]); + emptyHook.unmount(); + + const keep = createMockTask({ id: "FN-KEEP", column: "in-progress" as Column }); + mockFetchTasks.mockResolvedValueOnce([keep]); + mockRetryTask.mockResolvedValueOnce(retried); + + const missingHook = renderHook(() => useTasks()); + + await waitFor(() => expect(missingHook.result.current.tasks.map((task) => task.id)).toEqual(["FN-KEEP"])); + + await act(async () => { + await missingHook.result.current.retryTask("FN-MISSING"); + }); + + expect(missingHook.result.current.tasks).toEqual([keep]); + missingHook.unmount(); + }); + + it("updates project SWR task cache after retry success for array and absent payloads", async () => { + const failed = createMockTask({ id: "FN-RETRY", column: "in-progress" as Column, status: "failed", error: "boom" }); + const keep = createMockTask({ id: "FN-KEEP", column: "todo" as Column }); + const retried = createMockTask({ id: "FN-RETRY", column: "todo" as Column, status: null, error: null }); + mockFetchTasks.mockResolvedValueOnce([failed, keep]); + mockRetryTask.mockResolvedValueOnce(retried); + + const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); + + await waitFor(() => expect(result.current.tasks).toHaveLength(2)); + mockReadCache.mockClear(); + mockWriteCache.mockClear(); + mockClearCache.mockClear(); + mockReadCache.mockReturnValueOnce([failed, keep, failed]); + + await act(async () => { + await result.current.retryTask("FN-RETRY"); + }); + + expect(mockReadCache).toHaveBeenCalledWith( + `${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-1`, + { maxAgeMs: swrCache.SWR_TASKS_MAX_AGE_MS }, + ); + expect(mockWriteCache).toHaveBeenCalledWith( + `${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-1`, + [retried, keep, retried], + { maxBytes: 500_000 }, + ); + expect(mockClearCache).not.toHaveBeenCalled(); + + mockReadCache.mockClear(); + mockWriteCache.mockClear(); + mockClearCache.mockClear(); + mockReadCache.mockReturnValueOnce(null); + const retriedAgain = createMockTask({ id: "FN-RETRY", column: "todo" as Column, status: null, error: null, updatedAt: "2026-06-30T12:01:00.000Z" }); + mockRetryTask.mockResolvedValueOnce(retriedAgain); + + await act(async () => { + await result.current.retryTask("FN-RETRY"); + }); + + expect(mockWriteCache).toHaveBeenCalledWith( + `${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-1`, + [retriedAgain, keep], + { maxBytes: 500_000 }, + ); + expect(mockClearCache).not.toHaveBeenCalled(); + }); + + it("clears malformed project SWR task cache payloads after retry success", async () => { + const failed = createMockTask({ id: "FN-RETRY", column: "in-progress" as Column, status: "failed", error: "boom" }); + const retried = createMockTask({ id: "FN-RETRY", column: "todo" as Column, status: null, error: null }); + mockFetchTasks.mockResolvedValueOnce([failed]); + mockRetryTask.mockResolvedValueOnce(retried); + + const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); + + await waitFor(() => expect(result.current.tasks).toHaveLength(1)); + mockReadCache.mockClear(); + mockWriteCache.mockClear(); + mockClearCache.mockClear(); + mockReadCache.mockReturnValueOnce({ data: [failed] }); + + await act(async () => { + await result.current.retryTask("FN-RETRY"); + }); + + expect(result.current.tasks).toEqual([retried]); + expect(mockWriteCache).not.toHaveBeenCalled(); + expect(mockClearCache).toHaveBeenCalledWith(`${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-1`); + }); + + it("does not let an older in-flight fetch restore stale failed retry state", async () => { + const failed = createMockTask({ id: "FN-RETRY", column: "in-progress" as Column, status: "failed", error: "boom" }); + const keep = createMockTask({ id: "FN-KEEP", column: "todo" as Column }); + const retried = createMockTask({ id: "FN-RETRY", column: "todo" as Column, status: null, error: null }); + let resolveRefresh!: (tasks: Task[]) => void; + mockReadCache.mockReturnValue([failed, keep]); + mockFetchTasks.mockImplementationOnce(() => new Promise((resolve) => { + resolveRefresh = resolve; + })); + mockRetryTask.mockResolvedValueOnce(retried); + + const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); + + expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-RETRY", "FN-KEEP"]); + await waitFor(() => expect(mockFetchTasks).toHaveBeenCalledTimes(1)); + + await act(async () => { + await result.current.retryTask("FN-RETRY"); + }); + + expect(result.current.tasks).toEqual([retried, keep]); + + await act(async () => { + resolveRefresh([failed, keep]); + await flushPromises(); + }); + + expect(result.current.tasks).toEqual([retried, keep]); + }); + + it("keeps local state and cache untouched when retry rejects", async () => { + const failed = createMockTask({ id: "FN-RETRY", column: "in-progress" as Column, status: "failed", error: "boom" }); + const keep = createMockTask({ id: "FN-KEEP", column: "todo" as Column }); + mockFetchTasks.mockResolvedValueOnce([failed, keep]); + mockRetryTask.mockRejectedValueOnce(new Error("retry rejected")); + + const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); + + await waitFor(() => expect(result.current.tasks).toHaveLength(2)); + mockReadCache.mockClear(); + mockWriteCache.mockClear(); + mockClearCache.mockClear(); + + await expect( + act(async () => { + await result.current.retryTask("FN-RETRY"); + }), + ).rejects.toThrow("retry rejected"); + + expect(result.current.tasks).toEqual([failed, keep]); + expect(mockReadCache).not.toHaveBeenCalled(); + expect(mockWriteCache).not.toHaveBeenCalled(); + expect(mockClearCache).not.toHaveBeenCalled(); + }); + }); + describe("SSE event: task:deleted", () => { it("removes task from the list", async () => { const tasks = [ diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index 6ace983215..dcbc405dad 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -600,7 +600,43 @@ export function useTasks(options?: UseTasksOptions) { }, [projectId]); const retryTask = useCallback(async (id: string): Promise => { - return normalizeTask(await api.retryTask(id, projectId)); + const retriedTask = normalizeTask(await api.retryTask(id, projectId)); + /* + FNXC:DashboardTaskRetry 2026-06-30-12:57: + Manual retry success is a user-visible state boundary. Replace matching rows in shared hook state and the project SWR cache as soon as the retry API returns so Board/List/detail/right-dock retry affordances do not depend on later SSE, polling, remount, or route re-entry to clear stale failed/stuck state. + + FNXC:DashboardTaskRetry 2026-06-30-12:58: + Retry success also invalidates refreshes that began before the API returned; a late pre-retry fetch snapshot must not rehydrate the failed card after the operator has already received server confirmation for the retry. + */ + fetchVersionRef.current++; + + const projectUpdatedTasks = (currentTasks: Task[]) => currentTasks.map((task) => (task.id === id ? retriedTask : task)); + + if (projectId) { + const cacheKey = `${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`; + const cachedTasks = readCache(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 ? retriedTask : 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 retriedTask; }, [projectId]); const resetTask = useCallback(async (id: string): Promise => {