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:
7
.changeset/fix-planning-stop-resume.md
Normal file
7
.changeset/fix-planning-stop-resume.md
Normal 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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user