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 const result = await handle
.update(schema.project.aiSessions) .update(schema.project.aiSessions)
.set({ inputPayload: payloadValue as Record<string, unknown>, updatedAt: now }) .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 }); .returning({ id: schema.project.aiSessions.id });
return result.length > 0; return result.length > 0;
} }

View File

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

View File

@@ -363,12 +363,15 @@ describe("PlanningModeModal sequential flow", () => {
mockFetchAiSession.mockImplementation(async (sessionId: string) => ({ mockFetchAiSession.mockImplementation(async (sessionId: string) => ({
...base, ...base,
id: sessionId, id: sessionId,
updatedAt: new Date(now - (sessionId === "session-1" ? 25_000 : 7_000)).toISOString(),
status: "generating", status: "generating",
currentQuestion: null, currentQuestion: null,
result: JSON.stringify(summaryWithRefinements), result: JSON.stringify(summaryWithRefinements),
inputPayload: JSON.stringify({ inputPayload: JSON.stringify({
generationPurpose: "plan_update", 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" }; 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")); await waitFor(() => expect(mockStopPlanningGeneration).toHaveBeenCalledWith("session-1", "project-1"));
expect(await screen.findByText("What should change?")).toBeInTheDocument(); 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(); 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 () => { it("renders exactly one write-your-own choice for normalized select questions", async () => {
mockFetchAiSession.mockResolvedValue({ mockFetchAiSession.mockResolvedValue({