From 8c76416960e14433d8a3705dbc8d2ec74bf79d2e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 7 Aug 2026 15:02:01 -0700 Subject: [PATCH] fix(FN-8801): synchronize dashboard pause state --- .changeset/fix-dashboard-unpause-sync.md | 7 ++ .../useTasks-hydration-freshness.test.ts | 58 +++++++++++++++ .../app/hooks/__tests__/useTasks.test.ts | 74 +++++++++++++++++-- packages/dashboard/app/hooks/useTasks.ts | 52 ++++++++++--- 4 files changed, 175 insertions(+), 16 deletions(-) create mode 100644 .changeset/fix-dashboard-unpause-sync.md diff --git a/.changeset/fix-dashboard-unpause-sync.md b/.changeset/fix-dashboard-unpause-sync.md new file mode 100644 index 0000000000..0e1eb3ab98 --- /dev/null +++ b/.changeset/fix-dashboard-unpause-sync.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep every open dashboard synchronized when a task is paused or unpaused. +category: fix +dev: Treat omitted fields in newer task snapshots as cleared pause lifecycle state. diff --git a/packages/dashboard/app/hooks/__tests__/useTasks-hydration-freshness.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks-hydration-freshness.test.ts index 7aac81a0da..f792cffaf1 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks-hydration-freshness.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks-hydration-freshness.test.ts @@ -205,6 +205,64 @@ describe("task snapshot lifecycle freshness", () => { }); }); + it("preserves pause lifecycle fields when a newer unrelated sparse snapshot omits them", () => { + const current = { + ...todo, + paused: true, + userPaused: true, + pausedByAgentId: "agent-1", + pausedReason: "operator", + status: "paused", + updatedAt: "2026-08-05T10:02:00.000Z", + } as Task; + const sparseEvent = { + id: current.id, + title: "New summary", + column: current.column, + updatedAt: "2026-08-05T10:03:00.000Z", + } as Task; + + expect(mergeTaskSnapshot(current, sparseEvent)).toMatchObject({ + paused: true, + userPaused: true, + pausedByAgentId: "agent-1", + pausedReason: "operator", + status: "paused", + title: "New summary", + }); + }); + + it("clears omitted pause lifecycle fields from an equal-clock complete snapshot", () => { + const current = { + ...todo, + paused: true, + userPaused: true, + pausedByAgentId: "agent-1", + pausedReason: "operator", + status: "paused", + updatedAt: "2026-08-05T10:02:00.000Z", + prompt: "# Full task detail", + } as Task; + const completeFetch = JSON.parse(JSON.stringify({ + ...current, + paused: undefined, + userPaused: undefined, + pausedByAgentId: undefined, + pausedReason: undefined, + status: undefined, + prompt: undefined, + })) as Task; + + expect(mergeTaskSnapshot(current, completeFetch, { fullSnapshot: true })).toMatchObject({ + paused: undefined, + userPaused: undefined, + pausedByAgentId: undefined, + pausedReason: undefined, + status: undefined, + prompt: "# Full task detail", + }); + }); + it("accepts equal-clock non-lifecycle fields only from a marked complete fetch", () => { const current = { ...todo, updatedAt: "2026-08-05T10:02:00.000Z", title: "Cached title" }; const completeFetch = { ...todo, updatedAt: current.updatedAt, title: "Fetched title" }; diff --git a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts index 1207c94380..b9323d67b8 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts @@ -1091,6 +1091,64 @@ describe("useTasks", () => { expect(result.current.tasks[0].column).toBe("in-progress"); }); + it("clears paused lifecycle state from a production-shaped unpause event", async () => { + const pausedTask = createMockTask({ + id: "FN-PAUSED", + column: "in-progress" as Column, + paused: true, + userPaused: true, + pausedByAgentId: "agent-1", + pausedReason: "operator", + status: "paused", + updatedAt: "2026-01-02T00:00:00Z", + }); + mockFetchTasks.mockResolvedValueOnce([pausedTask]); + + const { result } = renderHook(() => useTasks()); + + await waitFor(() => { + expect(result.current.tasks[0]?.paused).toBe(true); + }); + + // TaskStore represents cleared optional lifecycle fields as `undefined`. + // REST/SSE JSON serialization omits those keys, so this mirrors the wire + // payload observed by a passive dashboard after another client unpauses. + const unpausedWireTask = JSON.parse(JSON.stringify(createMockTask({ + ...pausedTask, + paused: undefined, + userPaused: undefined, + pausedByAgentId: undefined, + pausedReason: undefined, + status: undefined, + // Canonical SSE delivery order resolves lifecycle ambiguity when the store's + // millisecond clock ties the already-visible row. + updatedAt: pausedTask.updatedAt, + }))) as Task; + expect(unpausedWireTask).not.toHaveProperty("paused"); + expect(unpausedWireTask).not.toHaveProperty("status"); + + act(() => { + MockEventSource.instances[0]._emit("task:updated", { + ...unpausedWireTask, + updatedAt: "2026-01-01T23:59:00Z", + }); + }); + + expect(result.current.tasks[0]?.paused).toBe(true); + expect(result.current.tasks[0]?.status).toBe("paused"); + + act(() => { + MockEventSource.instances[0]._emit("task:updated", unpausedWireTask); + }); + + expect(result.current.tasks[0]).toEqual(expect.objectContaining({ id: "FN-PAUSED" })); + expect(result.current.tasks[0]?.paused).toBeUndefined(); + expect(result.current.tasks[0]?.userPaused).toBeUndefined(); + expect(result.current.tasks[0]?.pausedByAgentId).toBeUndefined(); + expect(result.current.tasks[0]?.pausedReason).toBeUndefined(); + expect(result.current.tasks[0]?.status).toBeUndefined(); + }); + it("preserves stable execution metadata during sparse same-column updates", async () => { const initialTask = createMockTask({ id: "FN-001", @@ -2026,10 +2084,10 @@ describe("useTasks", () => { 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, + paused: undefined, + userPaused: undefined, + pausedByAgentId: undefined, + pausedReason: undefined, updatedAt: "2026-07-12T00:00:00.000Z", }); mockFetchTasks.mockResolvedValueOnce([paused, keep]); @@ -2049,10 +2107,12 @@ describe("useTasks", () => { }); expect(mockUnpauseTask).toHaveBeenCalledWith("FN-PAUSE", "proj-1"); - expect(returned).toEqual(expect.objectContaining({ id: "FN-PAUSE", paused: false, userPaused: false })); + expect(returned).toEqual(expect.objectContaining({ id: "FN-PAUSE" })); + expect(returned).toHaveProperty("paused", undefined); + expect(returned).toHaveProperty("userPaused", undefined); 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(result.current.tasks.find((task) => task.id === "FN-PAUSE")?.paused).toBeUndefined(); + expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")?.userPaused).toBeUndefined(); expect(mockFetchTasks).toHaveBeenCalledTimes(1); expect(mockWriteCache).toHaveBeenCalledWith( `${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-1`, diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index baffa1fbe4..bd00593896 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -264,6 +264,8 @@ export interface TaskSnapshotMergeOptions { fullSnapshot?: boolean; /** A canonical task:moved SSE payload names its destination, even when its clock ties the visible row. */ authoritativeMove?: boolean; + /** A canonical task event owns pause/status fields even when JSON omission represents a cleared value. */ + authoritativeLifecycle?: boolean; } export function mergeTaskSnapshot( @@ -294,6 +296,25 @@ export function mergeTaskSnapshot( } } + /* + FNXC:DashboardPauseState 2026-08-07-14:48: + TaskStore clears optional pause lifecycle fields with `undefined`, so JSON omits them from REST and + `task:updated` payloads. A newer full task row therefore needs omission to mean "cleared" for these + fields; otherwise a passive dashboard retains an older `paused: true` forever. Keep this narrow to + pause-owned fields so genuinely sparse payloads still preserve unrelated detail metadata. + */ + const incomingOwnsLifecycleField = (field: "paused" | "userPaused" | "pausedByAgentId" | "pausedReason" | "status") => + options.authoritativeLifecycle === true + || options.fullSnapshot === true + || Object.prototype.hasOwnProperty.call(incoming, field); + const acceptsEqualClockLifecycle = options.authoritativeLifecycle === true && updatedAtCompare === 0; + if (acceptsIncomingSnapshot || acceptsEqualClockFields || acceptsEqualClockLifecycle) { + if (incomingOwnsLifecycleField("paused")) merged.paused = incoming.paused; + if (incomingOwnsLifecycleField("userPaused")) merged.userPaused = incoming.userPaused; + if (incomingOwnsLifecycleField("pausedByAgentId")) merged.pausedByAgentId = incoming.pausedByAgentId; + if (incomingOwnsLifecycleField("pausedReason")) merged.pausedReason = incoming.pausedReason; + } + const columnMovedAtCompare = compareTimestamps(incoming.columnMovedAt, current.columnMovedAt); /* @@ -317,11 +338,12 @@ export function mergeTaskSnapshot( // lifecycle row. Otherwise accepting its status while rejecting its column would tear the pair. const acceptsEqualClockStatus = acceptsEqualClockFields && (incoming.column === undefined || incoming.column === current.column); - const incomingUpdatesStatus = incoming.status !== undefined - && (current.status === undefined - || acceptsIncomingSnapshot + const incomingUpdatesStatus = incomingOwnsLifecycleField("status") + && (acceptsIncomingSnapshot || acceptsEqualClockStatus - || incomingMovesColumn); + || acceptsEqualClockLifecycle + || (incoming.status !== undefined + && (current.status === undefined || incomingMovesColumn))); // The lifecycle fields are evidence-owned rather than object-spread-owned. merged.column = incomingMovesColumn ? incoming.column : current.column; @@ -338,8 +360,7 @@ export function mergeTaskSnapshot( complete equal-clock fetch clears it only when that same lifecycle row proves the status changed; otherwise an agent-log that arrived while the fetch was in flight remains newer evidence. */ - const equalClockStatusChanged = acceptsEqualClockStatus - && incoming.status !== undefined + const equalClockStatusChanged = (acceptsEqualClockStatus || acceptsEqualClockLifecycle) && incoming.status !== current.status; merged.recentAgentActivityAt = acceptsIncomingSnapshot || equalClockStatusChanged ? incoming.recentAgentActivityAt @@ -1060,7 +1081,10 @@ export function useTasks(options?: UseTasksOptions) { return [...prev, movedTask]; } const current = prev[existingIndex]!; - const merged = mergeIncomingTask(current, movedTask, { authoritativeMove: true }); + const merged = mergeIncomingTask(current, movedTask, { + authoritativeMove: true, + authoritativeLifecycle: true, + }); if (merged === current) return prev; const next = [...prev]; next[existingIndex] = merged; @@ -1092,7 +1116,7 @@ export function useTasks(options?: UseTasksOptions) { return [...prev, incoming]; } const current = prev[existingIndex]!; - const merged = mergeIncomingTask(current, incoming); + const merged = mergeIncomingTask(current, incoming, { authoritativeLifecycle: true }); if (merged === current) return prev; const next = [...prev]; next[existingIndex] = merged; @@ -1219,7 +1243,17 @@ export function useTasks(options?: UseTasksOptions) { hosts cannot diverge after pause or unpause. */ const reconcileConfirmedTask = useCallback((confirmedTask: Task): Task => { - const confirmedRow = normalizeTask(confirmedTask); + const normalizedConfirmedRow = normalizeTask(confirmedTask); + // Preserve cleared lifecycle fields as own `undefined` properties so every downstream + // snapshot host can distinguish the confirmed deletion from an unrelated sparse update. + const confirmedRow: Task = { + ...normalizedConfirmedRow, + paused: normalizedConfirmedRow.paused, + userPaused: normalizedConfirmedRow.userPaused, + pausedByAgentId: normalizedConfirmedRow.pausedByAgentId, + pausedReason: normalizedConfirmedRow.pausedReason, + status: normalizedConfirmedRow.status, + }; const currentTask = tasksRef.current.find((task) => task.id === confirmedRow.id); // A live event that arrived while the mutation was pending may be newer than its response. // Start from the confirmed row so equal clocks retain the mutation, then admit only newer state.