FN-8536: fix planning retry ownership after failure

Release a settled retry token so a later planning error can start the next bounded attempt.

- Clear only the matching retry owner before scheduling its successor.
- Cover distinct stream errors after retry settlement.
- Add a patch changeset for the recovery fix.

Files changed:
 .changeset/fn-8536-planning-retry.md                           |  7 +++++++
 packages/dashboard/app/components/PlanningModeModal.tsx        | 10 ++++++++++
 .../__tests__/PlanningModeModal.planning-flow.test.tsx         |  5 +++++
 3 files changed, 22 insertions(+)

Fusion-Task-Id: FN-8536

Fusion-Task-Lineage: cccb0793-390e-4bdb-b459-939760e644bb

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-23 10:25:16 -07:00
parent 2df0c7e90b
commit 492d375e8f
3 changed files with 22 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep Planning Mode recovery retries safely bounded after failed attempts.
category: fix
dev: Releases the settled automatic retry owner before scheduling its token-guarded successor.

View File

@@ -1421,6 +1421,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
viewRef.current = { type: "loading" }; viewRef.current = { type: "loading" };
setView({ type: "loading" }); setView({ type: "loading" });
setIsAutoRetrying(true); setIsAutoRetrying(true);
/*
FNXC:PlanningRetry 2026-07-23-09:45:
A rejected automatic attempt has settled before its bounded successor is queued, so
release only its matching token first. The successor can then acquire ownership, while
duplicate SSE/poll reports still coalesce only during a genuinely pending invocation;
a stale callback cannot clear a newer session's owner.
*/
if (options.retryToken && planningAutoRetryOwnerRef.current?.token === options.retryToken) {
planningAutoRetryOwnerRef.current = null;
}
queueMicrotask(() => { queueMicrotask(() => {
if (currentSessionIdRef.current === retryTarget.sessionId) { if (currentSessionIdRef.current === retryTarget.sessionId) {
void startPlanningAutoRetryRef.current(retryTarget.sessionId); void startPlanningAutoRetryRef.current(retryTarget.sessionId);

View File

@@ -166,6 +166,11 @@ describe("PlanningModeModal sequential flow", () => {
resolveRetry({ success: true }); resolveRetry({ success: true });
await Promise.resolve(); await Promise.resolve();
}); });
// Settlement releases the first owner. A later, distinct error may acquire the next
// bounded attempt; only the duplicate report while the promise was pending is coalesced.
mockConnectPlanningStream.mock.calls.at(-1)?.[2]?.onError?.("Later stream error");
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledTimes(2));
}); });
it("ignores a stale errored load after a newer session is selected", async () => { it("ignores a stale errored load after a newer session is selected", async () => {