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 { import {
STORED_PLANNING_KEY, STORED_PLANNING_KEY,
STORED_PLANNING_ACTIVE_SESSION_KEY, STORED_PLANNING_ACTIVE_SESSION_KEY,
@@ -19,9 +28,38 @@ import {
} from "../modalPersistence"; } from "../modalPersistence";
import { scopedKey } from "../../utils/projectStorage"; 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", () => { describe("modalPersistence", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks();
localStorage.clear(); 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", () => { 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", () => { describe("Subtask persistence", () => {
it("saves and retrieves subtask description", () => { it("saves and retrieves subtask description", () => {
saveSubtaskDescription("Implement login feature"); saveSubtaskDescription("Implement login feature");

View File

@@ -9,8 +9,26 @@ export const STORED_GITHUB_IMPORT_KEY = "kb-dashboard-github-import-state";
// Planning persistence // 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 { 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 { 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. 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 { 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 { 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", "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: FNXC:DashboardTestQuarantine 2026-07-17-16:50:
FN-8245 re-admits all three UI files with their ledger rows removed in lockstep. 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 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", "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.", "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" "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"
} }
] ]
} }