fix(planning): resume initial generation after stop

Reset the initial-generation guard, ignore stale stream callbacks, and preserve session-specific elapsed time across refreshes.
This commit is contained in:
gsxdsm
2026-07-21 00:45:34 -07:00
parent b1fa2c77d8
commit 36583bc1c7
4 changed files with 82 additions and 14 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Resume initial planning cleanly after stopping generation and preserve session timers across refreshes.
category: fix
dev: Ignores stale stopped streams and prevents draft updates from overwriting active generation metadata.

View File

@@ -410,7 +410,16 @@ export async function updateDraft(
const result = await handle
.update(schema.project.aiSessions)
.set({ inputPayload: payloadValue as Record<string, unknown>, updatedAt: now })
.where(and(eq(schema.project.aiSessions.id, id), eq(schema.project.aiSessions.type, "planning")))
/*
FNXC:PlanningMode 2026-07-21-00:42:
A debounced editor write can arrive after Start Planning changes the row to generating.
Restrict the mutation atomically so stale draft text cannot erase the generation timestamp.
*/
.where(and(
eq(schema.project.aiSessions.id, id),
eq(schema.project.aiSessions.type, "planning"),
eq(schema.project.aiSessions.status, "draft"),
))
.returning({ id: schema.project.aiSessions.id });
return result.length > 0;
}

View File

@@ -440,6 +440,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [newSessionFocusSignal, setNewSessionFocusSignal] = useState(0);
const modalRef = useRef<HTMLDivElement>(null);
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
const streamConnectionEpochRef = useRef(0);
const currentSessionIdRef = useRef<string | null>(null);
const viewRef = useRef<ViewState>({ type: "initial" });
/*
@@ -1053,13 +1054,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const connectToPlanningStream = useCallback(
(sessionId: string) => {
const streamEpoch = ++streamConnectionEpochRef.current;
streamConnectionRef.current?.close();
// Guard handlers against late events from a connection the user has
// already navigated away from (e.g. clicked "New Session" while the
// previous SSE flushed a buffered question). currentSessionIdRef is
// cleared by resetDetailState and reassigned by handleStartPlanning /
// loadSession before each connectToPlanningStream call.
const isStaleEvent = () => currentSessionIdRef.current !== sessionId;
const isStaleEvent = () => currentSessionIdRef.current !== sessionId
|| streamConnectionEpochRef.current !== streamEpoch;
const connection = connectPlanningStream(sessionId, projectId, {
onThinking: (data) => {
@@ -1149,6 +1152,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setStreamingOutput("");
},
onError: (message) => {
if (isStaleEvent()) return;
const errorMessage = message || t("planning.sessionFailed", "Session failed while contacting the AI.");
// A single transient stream error (e.g. tab was backgrounded long
@@ -1159,6 +1163,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
(async () => {
try {
const session = await fetchAiSession(sessionId);
if (isStaleEvent()) return;
if (
session &&
(session.status === "generating" || session.status === "awaiting_input")
@@ -1169,6 +1174,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
} catch {
// fall through to error view below
}
if (isStaleEvent()) return;
/*
FNXC:PlanningRetry 2026-07-15-00:00:
@@ -1203,6 +1209,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
})();
},
onComplete: () => {
if (isStaleEvent()) return;
setIsRetrying(false);
resetPlanningAutoRetryBudget();
setIsRefiningSummary(false);
@@ -1361,6 +1368,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
if (!startedPlan || startPlanningInFlightRef.current) return;
startPlanningInFlightRef.current = true;
if (draftDebounceRef.current) {
clearTimeout(draftDebounceRef.current);
draftDebounceRef.current = null;
}
setActivePlanPrompt(startedPlan);
setError(null);
setStreamingOutput("");
@@ -1383,10 +1395,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
let draftSessionId = draftSessionIdRef.current;
if (!draftSessionId) {
if (draftDebounceRef.current) {
clearTimeout(draftDebounceRef.current);
draftDebounceRef.current = null;
}
const draftPromise = draftCreatePromiseRef.current ?? createPlanningDraft(startedPlan, projectId, modelOverride);
draftCreatePromiseRef.current = draftPromise;
draftCreateInFlightRef.current = true;
@@ -1551,9 +1559,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
// An unavailable payload cannot provide a safe copy target.
}
setActivePlanPrompt(typeof inputPayload?.initialPlan === "string" ? inputPayload.initialPlan : "");
const persistedGenerationStartedAt = typeof inputPayload?.generationStartedAt === "string"
? Date.parse(inputPayload.generationStartedAt)
: Number.NaN;
const generationStartedAtSource = typeof inputPayload?.generationStartedAt === "string"
? inputPayload.generationStartedAt
: session.updatedAt;
const persistedGenerationStartedAt = Date.parse(generationStartedAtSource);
setGenerationStartTime(
session.status === "generating" && Number.isFinite(persistedGenerationStartedAt)
? persistedGenerationStartedAt
@@ -2268,7 +2277,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setGenerationStartTime(Date.now());
setView({ type: "loading" });
setStreamingOutput(""); // Clear old thinking output when entering loading state
currentSessionIdRef.current = sessionId;
liveGenerationSessionIdRef.current = sessionId;
if (!streamConnectionRef.current?.isConnected()) {
connectToPlanningStream(sessionId);
}
try {
// Submit response. SSE remains the primary live path, while the HTTP payload closes
@@ -2349,7 +2362,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setView({ type: "question", session: { ...session, summary: runningSummaryRef.current } });
}
},
[conversationHistory, editingQuestionId, projectId, resetPlanningAutoRetryBudget, view]
[connectToPlanningStream, conversationHistory, editingQuestionId, projectId, resetPlanningAutoRetryBudget, view]
);
const handleStopGeneration = useCallback(async () => {
@@ -2361,14 +2374,19 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const summary = runningSummaryRef.current;
const history = conversationHistoryRef.current;
currentSessionIdRef.current = null;
liveGenerationSessionIdRef.current = null;
streamConnectionEpochRef.current += 1;
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
try {
await stopPlanningGeneration(sessionId, projectId);
} catch {
// best-effort; server-side timeout/stop event may have already fired
}
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
startPlanningInFlightRef.current = false;
setIsRetrying(false);
setIsAutoRetrying(false);
setIsRefiningSummary(false);

View File

@@ -363,12 +363,15 @@ describe("PlanningModeModal sequential flow", () => {
mockFetchAiSession.mockImplementation(async (sessionId: string) => ({
...base,
id: sessionId,
updatedAt: new Date(now - (sessionId === "session-1" ? 25_000 : 7_000)).toISOString(),
status: "generating",
currentQuestion: null,
result: JSON.stringify(summaryWithRefinements),
inputPayload: JSON.stringify({
generationPurpose: "plan_update",
generationStartedAt: new Date(now - (sessionId === "session-1" ? 25_000 : 7_000)).toISOString(),
...(sessionId === "session-1"
? { generationStartedAt: new Date(now - 25_000).toISOString() }
: {}),
}),
}));
const props = { isOpen: true, onClose: vi.fn(), onTaskCreated: vi.fn(), onTasksCreated: vi.fn(), tasks: mockTasks, projectId: "project-1" };
@@ -397,8 +400,39 @@ describe("PlanningModeModal sequential flow", () => {
await waitFor(() => expect(mockStopPlanningGeneration).toHaveBeenCalledWith("session-1", "project-1"));
expect(await screen.findByText("What should change?")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Type your answer here...")).toHaveValue("Preserve drafts");
await waitFor(() => expect(screen.getByPlaceholderText("Type your answer here...")).toHaveValue("Preserve drafts"));
expect(screen.queryByText(/Generation stopped by user/i)).toBeNull();
const stoppedStreamHandlers = mockConnectPlanningStream.mock.calls[0]?.[2];
mockRespondToPlanning.mockResolvedValue({
currentQuestion: { id: "q-next", type: "text", question: "What comes next?" },
summary: summaryWithRefinements,
});
fireEvent.click(screen.getByRole("button", { name: "Next" }));
await waitFor(() => expect(mockRespondToPlanning).toHaveBeenCalledWith(
"session-1",
{ "q-prior": "Preserve drafts" },
"project-1",
));
expect(await screen.findByText("What comes next?")).toBeInTheDocument();
expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2);
stoppedStreamHandlers?.onError?.("Stream error");
await Promise.resolve();
expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2);
expect(screen.queryByText("Stream error")).toBeNull();
});
it("can restart initial planning after stopping its first generation", async () => {
render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" />);
fireEvent.change(screen.getByLabelText("What do you want to build?"), { target: { value: "Build secure accounts" } });
fireEvent.click(screen.getByRole("button", { name: "Start Planning" }));
await waitFor(() => expect(mockStartPlanningStreaming).toHaveBeenCalledTimes(1));
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
expect(await screen.findByLabelText("What do you want to build?")).toHaveValue("Build secure accounts");
fireEvent.click(screen.getByRole("button", { name: "Start Planning" }));
await waitFor(() => expect(mockStartPlanningStreaming).toHaveBeenCalledTimes(2));
});
it("renders exactly one write-your-own choice for normalized select questions", async () => {
mockFetchAiSession.mockResolvedValue({