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
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user