fix(FN-8801): synchronize dashboard pause state
This commit is contained in:
7
.changeset/fix-dashboard-unpause-sync.md
Normal file
7
.changeset/fix-dashboard-unpause-sync.md
Normal file
@@ -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.
|
||||||
@@ -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", () => {
|
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 current = { ...todo, updatedAt: "2026-08-05T10:02:00.000Z", title: "Cached title" };
|
||||||
const completeFetch = { ...todo, updatedAt: current.updatedAt, title: "Fetched title" };
|
const completeFetch = { ...todo, updatedAt: current.updatedAt, title: "Fetched title" };
|
||||||
|
|||||||
@@ -1091,6 +1091,64 @@ describe("useTasks", () => {
|
|||||||
expect(result.current.tasks[0].column).toBe("in-progress");
|
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 () => {
|
it("preserves stable execution metadata during sparse same-column updates", async () => {
|
||||||
const initialTask = createMockTask({
|
const initialTask = createMockTask({
|
||||||
id: "FN-001",
|
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 keep = createMockTask({ id: "FN-KEEP", column: "in-progress" as Column, paused: false, userPaused: false });
|
||||||
const unpaused = createMockTask({
|
const unpaused = createMockTask({
|
||||||
...paused,
|
...paused,
|
||||||
paused: false,
|
paused: undefined,
|
||||||
userPaused: false,
|
userPaused: undefined,
|
||||||
pausedByAgentId: null,
|
pausedByAgentId: undefined,
|
||||||
pausedReason: null,
|
pausedReason: undefined,
|
||||||
updatedAt: "2026-07-12T00:00:00.000Z",
|
updatedAt: "2026-07-12T00:00:00.000Z",
|
||||||
});
|
});
|
||||||
mockFetchTasks.mockResolvedValueOnce([paused, keep]);
|
mockFetchTasks.mockResolvedValueOnce([paused, keep]);
|
||||||
@@ -2049,10 +2107,12 @@ describe("useTasks", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(mockUnpauseTask).toHaveBeenCalledWith("FN-PAUSE", "proj-1");
|
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")).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")?.paused).toBeUndefined();
|
||||||
expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")?.userPaused).toBe(false);
|
expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")?.userPaused).toBeUndefined();
|
||||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||||
expect(mockWriteCache).toHaveBeenCalledWith(
|
expect(mockWriteCache).toHaveBeenCalledWith(
|
||||||
`${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-1`,
|
`${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-1`,
|
||||||
|
|||||||
@@ -264,6 +264,8 @@ export interface TaskSnapshotMergeOptions {
|
|||||||
fullSnapshot?: boolean;
|
fullSnapshot?: boolean;
|
||||||
/** A canonical task:moved SSE payload names its destination, even when its clock ties the visible row. */
|
/** A canonical task:moved SSE payload names its destination, even when its clock ties the visible row. */
|
||||||
authoritativeMove?: boolean;
|
authoritativeMove?: boolean;
|
||||||
|
/** A canonical task event owns pause/status fields even when JSON omission represents a cleared value. */
|
||||||
|
authoritativeLifecycle?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeTaskSnapshot<T extends Task>(
|
export function mergeTaskSnapshot<T extends Task>(
|
||||||
@@ -294,6 +296,25 @@ export function mergeTaskSnapshot<T extends Task>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
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);
|
const columnMovedAtCompare = compareTimestamps(incoming.columnMovedAt, current.columnMovedAt);
|
||||||
/*
|
/*
|
||||||
@@ -317,11 +338,12 @@ export function mergeTaskSnapshot<T extends Task>(
|
|||||||
// lifecycle row. Otherwise accepting its status while rejecting its column would tear the pair.
|
// lifecycle row. Otherwise accepting its status while rejecting its column would tear the pair.
|
||||||
const acceptsEqualClockStatus = acceptsEqualClockFields
|
const acceptsEqualClockStatus = acceptsEqualClockFields
|
||||||
&& (incoming.column === undefined || incoming.column === current.column);
|
&& (incoming.column === undefined || incoming.column === current.column);
|
||||||
const incomingUpdatesStatus = incoming.status !== undefined
|
const incomingUpdatesStatus = incomingOwnsLifecycleField("status")
|
||||||
&& (current.status === undefined
|
&& (acceptsIncomingSnapshot
|
||||||
|| acceptsIncomingSnapshot
|
|
||||||
|| acceptsEqualClockStatus
|
|| acceptsEqualClockStatus
|
||||||
|| incomingMovesColumn);
|
|| acceptsEqualClockLifecycle
|
||||||
|
|| (incoming.status !== undefined
|
||||||
|
&& (current.status === undefined || incomingMovesColumn)));
|
||||||
|
|
||||||
// The lifecycle fields are evidence-owned rather than object-spread-owned.
|
// The lifecycle fields are evidence-owned rather than object-spread-owned.
|
||||||
merged.column = incomingMovesColumn ? incoming.column : current.column;
|
merged.column = incomingMovesColumn ? incoming.column : current.column;
|
||||||
@@ -338,8 +360,7 @@ export function mergeTaskSnapshot<T extends Task>(
|
|||||||
complete equal-clock fetch clears it only when that same lifecycle row proves the status changed;
|
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.
|
otherwise an agent-log that arrived while the fetch was in flight remains newer evidence.
|
||||||
*/
|
*/
|
||||||
const equalClockStatusChanged = acceptsEqualClockStatus
|
const equalClockStatusChanged = (acceptsEqualClockStatus || acceptsEqualClockLifecycle)
|
||||||
&& incoming.status !== undefined
|
|
||||||
&& incoming.status !== current.status;
|
&& incoming.status !== current.status;
|
||||||
merged.recentAgentActivityAt = acceptsIncomingSnapshot || equalClockStatusChanged
|
merged.recentAgentActivityAt = acceptsIncomingSnapshot || equalClockStatusChanged
|
||||||
? incoming.recentAgentActivityAt
|
? incoming.recentAgentActivityAt
|
||||||
@@ -1060,7 +1081,10 @@ export function useTasks(options?: UseTasksOptions) {
|
|||||||
return [...prev, movedTask];
|
return [...prev, movedTask];
|
||||||
}
|
}
|
||||||
const current = prev[existingIndex]!;
|
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;
|
if (merged === current) return prev;
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
next[existingIndex] = merged;
|
next[existingIndex] = merged;
|
||||||
@@ -1092,7 +1116,7 @@ export function useTasks(options?: UseTasksOptions) {
|
|||||||
return [...prev, incoming];
|
return [...prev, incoming];
|
||||||
}
|
}
|
||||||
const current = prev[existingIndex]!;
|
const current = prev[existingIndex]!;
|
||||||
const merged = mergeIncomingTask(current, incoming);
|
const merged = mergeIncomingTask(current, incoming, { authoritativeLifecycle: true });
|
||||||
if (merged === current) return prev;
|
if (merged === current) return prev;
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
next[existingIndex] = merged;
|
next[existingIndex] = merged;
|
||||||
@@ -1219,7 +1243,17 @@ export function useTasks(options?: UseTasksOptions) {
|
|||||||
hosts cannot diverge after pause or unpause.
|
hosts cannot diverge after pause or unpause.
|
||||||
*/
|
*/
|
||||||
const reconcileConfirmedTask = useCallback((confirmedTask: Task): Task => {
|
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);
|
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.
|
// 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.
|
// Start from the confirmed row so equal clocks retain the mutation, then admit only newer state.
|
||||||
|
|||||||
Reference in New Issue
Block a user