fix(FN-8764): clear stale planning state after refresh

Fusion-Task-Id: FN-8764
This commit is contained in:
gsxdsm
2026-08-06 23:49:37 -07:00
parent 5e718544ac
commit 8eb050ed49
4 changed files with 125 additions and 6 deletions

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

View File

@@ -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", () => {

View File

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

View File

@@ -313,8 +313,15 @@ export function mergeTaskSnapshot<T extends Task>(
|| (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<T extends Task>(
? 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;