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", () => {
|
||||
const current = { ...todo, updatedAt: "2026-08-05T10:02:00.000Z", title: "Cached 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");
|
||||
});
|
||||
|
||||
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`,
|
||||
|
||||
@@ -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<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);
|
||||
/*
|
||||
@@ -317,11 +338,12 @@ export function mergeTaskSnapshot<T extends Task>(
|
||||
// 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<T extends Task>(
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user