fix(FN-8764): clear stale planning state after refresh
Fusion-Task-Id: FN-8764
This commit is contained in:
7
.changeset/clear-stale-planning-badges.md
Normal file
7
.changeset/clear-stale-planning-badges.md
Normal file
@@ -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.
|
||||||
@@ -633,6 +633,43 @@ describe("AppModals", () => {
|
|||||||
expect(document.querySelector(".list-status-badge")).toHaveTextContent("Planning");
|
expect(document.querySelector(".list-status-badge")).toHaveTextContent("Planning");
|
||||||
expect(screen.getByTestId("task-detail-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", () => {
|
describe("ModelOnboardingModal wiring", () => {
|
||||||
|
|||||||
@@ -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", () => {
|
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 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" };
|
const executing = { ...todo, column: "in-progress", status: "executing", updatedAt: "2026-08-05T10:03:00.000Z", columnMovedAt: "2026-08-05T10:03:00.000Z" };
|
||||||
|
|||||||
@@ -313,8 +313,15 @@ export function mergeTaskSnapshot<T extends Task>(
|
|||||||
|| (options.fullSnapshot === true && columnMovedAtCompare === 0 && updatedAtCompare > 0)
|
|| (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.
|
// Older rows have no column-move clock. A newer task timestamp is still evidence for a real move.
|
||||||
|| (!current.columnMovedAt && !incoming.columnMovedAt && updatedAtCompare > 0));
|
|| (!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
|
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.
|
// 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;
|
||||||
@@ -325,13 +332,16 @@ export function mergeTaskSnapshot<T extends Task>(
|
|||||||
? carryAwaitingPlanning(current, incoming)
|
? carryAwaitingPlanning(current, incoming)
|
||||||
: current.awaitingPlanning;
|
: 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.
|
`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,
|
Preserve it while a stale payload is rejected so live Planning does not flash back to Queued. A
|
||||||
but clear it when a newer authoritative row arrives without the marker. Equal-clock sparse events
|
complete equal-clock fetch clears it only when that same lifecycle row proves the status changed;
|
||||||
may fill missing fields but cannot replace populated detail metadata; only complete fetches may do so.
|
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
|
? incoming.recentAgentActivityAt
|
||||||
: current.recentAgentActivityAt;
|
: current.recentAgentActivityAt;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user