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:
7
.changeset/fn-8816-planning-storage-recovery.md
Normal file
7
.changeset/fn-8816-planning-storage-recovery.md
Normal 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.
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user