FN-8816: make planning storage failures non-fatal

Keep Planning Mode running when browser storage writes fail.

- Retry failed project-scoped planning persistence after targeted eviction.
- Cover storage failure recovery and planning draft hand-off behavior.
- Quarantine the recurring planning-flow flake and add a patch changeset.

Files changed:
 .changeset/fn-8816-planning-storage-recovery.md    |   7 +
 .../app/hooks/__tests__/modalPersistence.test.ts   | 159 ++++++++++++++++++++-
 packages/dashboard/app/hooks/modalPersistence.ts   |  22 ++-
 packages/dashboard/vitest.config.ts                |   5 +
 scripts/lib/test-quarantine.json                   |   5 +
 5 files changed, 195 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-8816
Fusion-Task-Lineage: 929c3d96-3a28-49fa-8018-710fc75e3fcc
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-06 08:43:50 -07:00
parent 3ed31e9907
commit 5532019fd3
5 changed files with 195 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep Planning Mode running when browser storage is unavailable.
category: fix
dev: Planning draft and active-session persistence now evicts only its scoped key and retries once.

View File

@@ -1,4 +1,13 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createElement } from "react";
import { fireEvent, render, screen, waitFor, cleanup } from "@testing-library/react";
import { PlanningModeModal, resetPlanningAutoRetryAttemptsForTests } from "../../components/PlanningModeModal";
import {
mockCreatePlanningDraft,
mockFetchAiSessions,
mockStartPlanningStreaming,
mockTasks,
} from "../../components/__tests__/PlanningModeModal.test-helpers";
import {
STORED_PLANNING_KEY,
STORED_PLANNING_ACTIVE_SESSION_KEY,
@@ -19,9 +28,38 @@ import {
} from "../modalPersistence";
import { scopedKey } from "../../utils/projectStorage";
const mockViewportMode = vi.hoisted(() => vi.fn(() => "desktop" as "desktop" | "tablet" | "mobile"));
vi.mock("../../hooks/useToast", () => ({ useOptionalToast: () => null, useToast: () => ({ addToast: vi.fn(), removeToast: vi.fn(), toasts: [] }) }));
vi.mock("../../hooks/useNavigationHistory", () => ({ useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }) }));
vi.mock("../../hooks/useViewportMode", () => ({ MOBILE_MEDIA_QUERY: "(max-width: 768px)", isFullScreenSheetViewport: () => false, isShortViewport: () => false, getViewportMode: () => mockViewportMode(), isMobileViewport: () => mockViewportMode() === "mobile", isTabletTouchViewport: (mode?: string) => mode === "tablet", useViewportMode: () => mockViewportMode() }));
vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: () => ({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false }) }));
vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: vi.fn().mockResolvedValue(true) }) }));
vi.mock("../../sse-bus", () => ({ subscribeSse: vi.fn(() => () => undefined) }));
vi.mock("../../api", () => {
const fn = vi.fn;
return {
fetchAiSession: fn(), fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
respondToPlanning: fn(), validatePlanningSession: fn(), createTaskFromPlanning: fn(),
fetchSettings: fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }), fetchGlobalSettings: fn().mockResolvedValue({}), fetchModels: fn().mockResolvedValue([]), fetchWorkflowSteps: fn().mockResolvedValue([]), fetchBoardWorkflows: fn().mockResolvedValue({ workflows: [] }),
startPlanning: fn(), startPlanningStreaming: (...args: unknown[]) => mockStartPlanningStreaming(...args), createPlanningDraft: (...args: unknown[]) => mockCreatePlanningDraft(...args), connectPlanningStream: fn(), rewindPlanningSession: fn(), retryPlanningSession: fn().mockResolvedValue({ success: true }), cancelPlanning: fn(), stopPlanningGeneration: fn(), updatePlanningSessionDraft: fn(), updatePlanningSessionTitle: fn(), startPlanningBreakdown: fn(), createTasksFromPlanning: fn(), parseConversationHistory: (raw: string) => JSON.parse(raw || "[]"), acquireSessionLock: fn(), releaseSessionLock: fn(), forceAcquireSessionLock: fn(), uploadAttachment: fn(), deleteAttachment: fn(), updateTask: fn(), pauseTask: fn(), unpauseTask: fn(), fetchTaskDetail: fn(), requestSpecRevision: fn(), approvePlan: fn(), rejectPlan: fn(), refineTask: fn(), deleteAiSession: fn(), refineText: fn(), getRefineErrorMessage: (error: Error) => error.message,
};
});
describe("modalPersistence", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockViewportMode.mockReturnValue("desktop");
mockFetchAiSessions.mockResolvedValue([]);
mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-1", title: "Resilient plan" });
mockStartPlanningStreaming.mockResolvedValue({ sessionId: "draft-1" });
});
afterEach(() => {
cleanup();
resetPlanningAutoRetryAttemptsForTests();
vi.restoreAllMocks();
});
describe("Storage keys are exported", () => {
@@ -95,6 +133,125 @@ describe("modalPersistence", () => {
});
});
describe("Planning write recovery", () => {
it("uses the first successful write without eviction", () => {
const setItem = vi.spyOn(localStorage, "setItem");
const removeItem = vi.spyOn(localStorage, "removeItem");
expect(() => savePlanningActiveSession("session-1", "project-a")).not.toThrow();
expect(setItem).toHaveBeenCalledTimes(1);
expect(removeItem).not.toHaveBeenCalled();
});
it("evicts only the failed description key and retries once", () => {
const planningKey = scopedKey(STORED_PLANNING_KEY, "project-a");
const otherProjectKey = scopedKey(STORED_PLANNING_KEY, "project-b");
localStorage.setItem(planningKey, "old description");
localStorage.setItem(otherProjectKey, "other project");
localStorage.setItem("unrelated-key", "preserved");
const originalSetItem = localStorage.setItem;
const setItem = vi.spyOn(localStorage, "setItem")
.mockImplementationOnce(() => { throw new DOMException("Quota exceeded"); })
.mockImplementation(function (key: string, value: string) {
originalSetItem.call(this, key, value);
});
const removeItem = vi.spyOn(localStorage, "removeItem");
expect(() => savePlanningDescription("new description", "project-a")).not.toThrow();
expect(setItem).toHaveBeenCalledTimes(2);
expect(removeItem).toHaveBeenCalledTimes(1);
expect(removeItem).toHaveBeenCalledWith(planningKey);
expect(localStorage.getItem(planningKey)).toBe("new description");
expect(localStorage.getItem(otherProjectKey)).toBe("other project");
expect(localStorage.getItem("unrelated-key")).toBe("preserved");
});
it.each([
["description", STORED_PLANNING_KEY, savePlanningDescription, "new description"],
["active session", STORED_PLANNING_ACTIVE_SESSION_KEY, savePlanningActiveSession, "session-2"],
] as const)("swallows persistent %s write failures after one retry", (_name, baseKey, save, value) => {
const planningKey = scopedKey(baseKey, "project-a");
localStorage.setItem(planningKey, "old value");
localStorage.setItem("unrelated-key", "preserved");
const setItem = vi.spyOn(localStorage, "setItem").mockImplementation(() => {
throw new DOMException("Quota exceeded");
});
const removeItem = vi.spyOn(localStorage, "removeItem");
expect(() => save(value, "project-a")).not.toThrow();
expect(setItem).toHaveBeenCalledTimes(2);
expect(removeItem).toHaveBeenCalledTimes(1);
expect(removeItem).toHaveBeenCalledWith(planningKey);
expect(localStorage.getItem("unrelated-key")).toBe("preserved");
});
it("swallows cleanup failure and respects unavailable storage methods", () => {
const planningKey = scopedKey(STORED_PLANNING_KEY, "project-a");
const setItem = vi.spyOn(localStorage, "setItem").mockImplementation(() => {
throw new DOMException("Quota exceeded");
});
const removeItem = vi.spyOn(localStorage, "removeItem").mockImplementation(() => {
throw new DOMException("Storage disabled");
});
expect(() => savePlanningDescription("new description", "project-a")).not.toThrow();
expect(setItem).toHaveBeenCalledTimes(2);
expect(removeItem).toHaveBeenCalledWith(planningKey);
vi.restoreAllMocks();
const descriptor = Object.getOwnPropertyDescriptor(window, "localStorage");
Object.defineProperty(window, "localStorage", { configurable: true, value: {} });
try {
expect(() => savePlanningDescription("ignored", "project-a")).not.toThrow();
} finally {
Object.defineProperty(window, "localStorage", descriptor!);
}
});
});
describe("PlanningModeModal storage failure regression", () => {
it("continues draft creation and streaming through persistent scoped storage failures", async () => {
const descriptionKey = scopedKey(STORED_PLANNING_KEY, "project-1");
const activeSessionKey = scopedKey(STORED_PLANNING_ACTIVE_SESSION_KEY, "project-1");
localStorage.setItem(descriptionKey, "old draft");
localStorage.setItem("kb:project-2:kb-planning-last-description", "other project");
localStorage.setItem("unrelated-key", "preserved");
render(createElement(PlanningModeModal, {
isOpen: true,
onClose: vi.fn(),
onTaskCreated: vi.fn(),
onTasksCreated: vi.fn(),
tasks: mockTasks,
projectId: "project-1",
}));
localStorage.setItem(activeSessionKey, "old session");
const setItem = vi.spyOn(localStorage, "setItem").mockImplementation(() => {
throw new DOMException("Quota exceeded");
});
const removeItem = vi.spyOn(localStorage, "removeItem");
fireEvent.change(screen.getByLabelText("What do you want to build?"), { target: { value: "Build resilient planning" } });
fireEvent.click(screen.getByRole("button", { name: "Start Planning" }));
await waitFor(() => expect(mockCreatePlanningDraft).toHaveBeenCalledWith("Build resilient planning", "project-1", undefined));
await waitFor(() => expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build resilient planning", "project-1", undefined, { clarificationEnabled: true }, "draft-1"));
expect(removeItem).toHaveBeenCalledWith(descriptionKey);
expect(removeItem).toHaveBeenCalledWith(activeSessionKey);
expect(setItem.mock.calls.filter(([key]) => key === descriptionKey)).toHaveLength(2);
// The durable draft hand-off and the selected-session effect each retry their same active key once.
expect(setItem.mock.calls.filter(([key]) => key === activeSessionKey)).toHaveLength(4);
expect(setItem).toHaveBeenCalledTimes(6);
expect(localStorage.getItem("kb:project-2:kb-planning-last-description")).toBe("other project");
expect(localStorage.getItem("unrelated-key")).toBe("preserved");
expect(screen.queryByText("Quota exceeded")).toBeNull();
});
});
describe("Subtask persistence", () => {
it("saves and retrieves subtask description", () => {
saveSubtaskDescription("Implement login feature");

View File

@@ -9,8 +9,26 @@ export const STORED_GITHUB_IMPORT_KEY = "kb-dashboard-github-import-state";
// Planning persistence
/*
FNXC:PlanningStorage 2026-08-06-14:56:
Planning storage is optional restoration state, never a prerequisite for durable draft creation or streaming. On a write failure, evict only this exact project-scoped key, retry once, then swallow cleanup or retry failures so planning continues from React and server state.
*/
function savePlanningItem(baseKey: string, value: string, projectId?: string): void {
try {
setScopedItem(baseKey, value, projectId);
} catch {
try {
removeScopedItem(baseKey, projectId);
} catch {}
try {
setScopedItem(baseKey, value, projectId);
} catch {}
}
}
export function savePlanningDescription(description: string, projectId?: string): void {
setScopedItem(STORED_PLANNING_KEY, description, projectId);
savePlanningItem(STORED_PLANNING_KEY, description, projectId);
}
export function getPlanningDescription(projectId?: string): string {
@@ -26,7 +44,7 @@ FNXC:PlanningMode 2026-07-20-12:00:
Embedded Planning unmounts whenever main-content navigation leaves its view. FN-8437 keeps the last active interview id project-scoped, matching Chat's active-session persistence, so a return during generation can rehydrate through the modal's single loadSession path.
*/
export function savePlanningActiveSession(sessionId: string, projectId?: string): void {
setScopedItem(STORED_PLANNING_ACTIVE_SESSION_KEY, sessionId, projectId);
savePlanningItem(STORED_PLANNING_ACTIVE_SESSION_KEY, sessionId, projectId);
}
export function getPlanningActiveSession(projectId?: string): string {

View File

@@ -358,6 +358,11 @@ const quarantinedDashboardTests: string[] = [
*/
"src/__tests__/register-model-routes-kimi-k3-supplemental.test.ts",
/*
FNXC:DashboardTestQuarantine 2026-08-06-15:08:
FN-8816 observed PlanningModeModal's direct task-handoff test fail a second time only under a loaded planning-flow file, after its first-sighting register entry. Quarantine the entire file with its ledger row rather than weaken user-flow assertions or add timing retries; delete it at the ratchet deadline unless a root-cause rescue restores stable coverage.
*/
"app/components/__tests__/PlanningModeModal.planning-flow.test.tsx",
/*
FNXC:DashboardTestQuarantine 2026-07-17-16:50:
FN-8245 re-admits all three UI files with their ledger rows removed in lockstep.
QuickEntryBox restores focus from its resolved submit path while isolated jsdom

View File

@@ -10,6 +10,11 @@
"file": "packages/engine/src/__tests__/project-engine.test.ts",
"reason": "FN-8811 observed B4/B5 workspace busy contention asserting a stale 60s cap while runtime schedules 120s, then the subprocess guard timed out on git remote; reproduced in isolation on 2026-08-06. Do not widen timing or weaken the assertion; restore only with a root-cause fix before the deletion deadline.",
"quarantinedAt": "2026-08-06"
},
{
"file": "packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx",
"reason": "FN-8816 observed the previously registered direct task-handoff failure a second time in a loaded planning-flow run while the exact test passed in isolation. Quarantined under the deletion ratchet rather than weakening assertions or adding retries; delete by 2026-08-20 unless a root-cause rescue restores stable coverage.",
"quarantinedAt": "2026-08-06"
}
]
}