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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7295-retry-refresh.md
Normal file
7
.changeset/fn-7295-retry-refresh.md
Normal file
@@ -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.
|
||||
@@ -49,6 +49,7 @@ async function flushPromises(): Promise<void> {
|
||||
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<Task[]>((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 = [
|
||||
|
||||
@@ -600,7 +600,43 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
}, [projectId]);
|
||||
|
||||
const retryTask = useCallback(async (id: string): Promise<Task> => {
|
||||
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<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 ? 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<Task> => {
|
||||
|
||||
Reference in New Issue
Block a user