diff --git a/.changeset/clear-stale-planning-badges.md b/.changeset/clear-stale-planning-badges.md new file mode 100644 index 0000000000..7bc3ba59b9 --- /dev/null +++ b/.changeset/clear-stale-planning-badges.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Clear stale Planning badges when refreshed task state shows execution has advanced. +category: fix +dev: Equal-clock complete snapshots clear stale lifecycle status without erasing newer planner activity. diff --git a/packages/dashboard/app/components/__tests__/AppModals.test.tsx b/packages/dashboard/app/components/__tests__/AppModals.test.tsx index 81445c98bf..1f3ff4b672 100644 --- a/packages/dashboard/app/components/__tests__/AppModals.test.tsx +++ b/packages/dashboard/app/components/__tests__/AppModals.test.tsx @@ -633,6 +633,43 @@ describe("AppModals", () => { expect(document.querySelector(".list-status-badge")).toHaveTextContent("Planning"); expect(screen.getByTestId("task-detail-status-badge")).toHaveTextContent("Planning"); }); + + const inProgressTask = { + ...parkedTask, + column: "in-progress" as const, + status: "planning", + updatedAt: "2026-08-05T10:03:00.000Z", + columnMovedAt: "2026-08-05T10:03:00.000Z", + }; + act(() => { + boardEvents?.["task:moved"]?.({ data: JSON.stringify({ + task: inProgressTask, + from: "triage", + to: "in-progress", + }) }); + }); + + await waitFor(() => { + expect(document.querySelector('.card[data-id="FN-8798"] .card-status-badge')).toHaveTextContent("Planning"); + expect(document.querySelector(".list-status-badge")).toHaveTextContent("Planning"); + expect(screen.getByTestId("task-detail-status-badge")).toHaveTextContent("Planning"); + }); + + vi.mocked(taskApi.fetchTasks).mockResolvedValueOnce([{ + ...inProgressTask, + status: null, + }] as any); + act(() => window.dispatchEvent(new Event("focus"))); + + await waitFor(() => expect(taskApi.fetchTasks).toHaveBeenCalledTimes(2)); + await waitFor(() => { + const card = document.querySelector('.card[data-id="FN-8798"]'); + expect(card).toBeInTheDocument(); + expect(card?.querySelector(".card-status-badge")).not.toBeInTheDocument(); + expect(document.querySelector(".list-status-badge")).not.toHaveTextContent("Planning"); + expect(document.querySelector("#task-detail-modal-title")).toHaveTextContent("FN-8798"); + expect(screen.queryByTestId("task-detail-status-badge")).not.toBeInTheDocument(); + }); }); describe("ModelOnboardingModal wiring", () => { 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 124b70b057..7aac81a0da 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks-hydration-freshness.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks-hydration-freshness.test.ts @@ -215,6 +215,71 @@ describe("task snapshot lifecycle freshness", () => { }); }); + it("clears a stale planning status from an equal-clock complete refresh", () => { + const current = { + ...todo, + column: "in-progress", + status: "planning", + updatedAt: "2026-08-05T10:02:00.000Z", + recentAgentActivityAt: "2026-08-05T10:02:00.000Z", + } as Task; + const completeFetch = { + ...todo, + column: "in-progress", + status: null, + updatedAt: current.updatedAt, + } as Task; + + expect(mergeTaskSnapshot(current, completeFetch, { fullSnapshot: true })).toMatchObject({ + column: "in-progress", + status: null, + recentAgentActivityAt: undefined, + }); + }); + + it("preserves newer planner activity when an equal-clock refresh has unchanged status", () => { + const current = { + ...todo, + column: "triage", + status: "needs-replan", + updatedAt: "2026-08-05T10:02:00.000Z", + recentAgentActivityAt: "2026-08-05T10:03:00.000Z", + } as Task; + const completeFetch = { + ...todo, + column: "triage", + status: "needs-replan", + updatedAt: current.updatedAt, + } as Task; + + expect(mergeTaskSnapshot(current, completeFetch, { fullSnapshot: true })).toMatchObject({ + status: "needs-replan", + recentAgentActivityAt: current.recentAgentActivityAt, + }); + }); + + it("keeps column and status from the same lifecycle row", () => { + const current = { + ...todo, + column: "in-progress", + status: "planning", + updatedAt: "2026-08-05T10:02:00.000Z", + columnMovedAt: "2026-08-05T10:02:00.000Z", + } as Task; + const staleCompleteFetch = { + ...todo, + column: "triage", + status: null, + updatedAt: current.updatedAt, + columnMovedAt: current.columnMovedAt, + } as Task; + + expect(mergeTaskSnapshot(current, staleCompleteFetch, { fullSnapshot: true })).toMatchObject({ + column: "in-progress", + status: "planning", + }); + }); + it("accepts a genuinely newer column transition", () => { const queued = { ...todo, column: "todo", status: "queued-overlap", updatedAt: "2026-08-05T10:02:00.000Z", columnMovedAt: "2026-08-05T10:02:00.000Z" }; const executing = { ...todo, column: "in-progress", status: "executing", updatedAt: "2026-08-05T10:03:00.000Z", columnMovedAt: "2026-08-05T10:03:00.000Z" }; diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index cd8a71a093..baffa1fbe4 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -313,8 +313,15 @@ export function mergeTaskSnapshot( || (options.fullSnapshot === true && columnMovedAtCompare === 0 && updatedAtCompare > 0) // Older rows have no column-move clock. A newer task timestamp is still evidence for a real move. || (!current.columnMovedAt && !incoming.columnMovedAt && updatedAtCompare > 0)); + // An equal-clock fetch may resolve a stale client-only status only when it describes the same + // 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 || updatedAtCompare > 0 || incomingMovesColumn); + && (current.status === undefined + || acceptsIncomingSnapshot + || acceptsEqualClockStatus + || incomingMovesColumn); // The lifecycle fields are evidence-owned rather than object-spread-owned. merged.column = incomingMovesColumn ? incoming.column : current.column; @@ -325,13 +332,16 @@ export function mergeTaskSnapshot( ? carryAwaitingPlanning(current, incoming) : current.awaitingPlanning; /* - FNXC:TaskStatusConsistency 2026-08-05-04:05: + FNXC:TaskStatusConsistency 2026-08-07-06:10: `recentAgentActivityAt` is a client-only bridge from an agent-log event to the next task snapshot. - Preserve it while a stale/equal payload is rejected so live Planning does not flash back to Queued, - but clear it when a newer authoritative row arrives without the marker. Equal-clock sparse events - may fill missing fields but cannot replace populated detail metadata; only complete fetches may do so. + Preserve it while a stale payload is rejected so live Planning does not flash back to Queued. A + 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. */ - merged.recentAgentActivityAt = acceptsIncomingSnapshot + const equalClockStatusChanged = acceptsEqualClockStatus + && incoming.status !== undefined + && incoming.status !== current.status; + merged.recentAgentActivityAt = acceptsIncomingSnapshot || equalClockStatusChanged ? incoming.recentAgentActivityAt : current.recentAgentActivityAt;