fix(FN-8801): synchronize dashboard pause state

This commit is contained in:
gsxdsm
2026-08-07 15:02:01 -07:00
parent 55e8484418
commit 8c76416960
4 changed files with 175 additions and 16 deletions

View 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.

View File

@@ -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" };

View File

@@ -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`,

View File

@@ -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.