fix(dashboard): recover finished planning sessions instead of retry dead-ends
Reopening a complete plan no longer shows "still being prepared" when the validated payload marker is missing. Generation Retry that hits "already been validated" refreshes into create-retry or plan review.
This commit is contained in:
7
.changeset/fix-planning-complete-reopen.md
Normal file
7
.changeset/fix-planning-complete-reopen.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix Planning reopen after a finished session so Retry no longer dead-ends.
|
||||
category: fix
|
||||
dev: Treat status=complete as terminal; recover create-retry/task-created/plan-review on load and when generation retry reports already-validated.
|
||||
@@ -182,24 +182,66 @@ type PlanningGenerationActivity = "initial_plan" | "plan_update" | "question";
|
||||
/**
|
||||
* FNXC:PlanningMode 2026-07-20-00:00:
|
||||
* A persisted planning `result` is an evolving running plan, not proof that the interview ended.
|
||||
* Only the explicit Validate action writes this durable marker, so reload and poll paths must use
|
||||
* it before exposing terminal summary/create-task UI.
|
||||
* Only the explicit Validate action writes the durable terminal markers, so reload and poll paths
|
||||
* must use them before exposing create-task UI.
|
||||
*
|
||||
* FNXC:PlanningMode 2026-07-24-05:45:
|
||||
* `status === "complete"` is written only by validateSession, so it is authoritative terminal
|
||||
* evidence even when a stale/malformed inputPayload omits `validated: true` (that mismatch was
|
||||
* stranding reopen on "still being prepared", then Retry hit "already been validated"). Prefer
|
||||
* status, then the payload marker, and always recover complete rows to task-created / create-retry
|
||||
* / plan-review rather than a generation-retry dead end.
|
||||
*/
|
||||
function isValidatedPlanningSession(session: { inputPayload?: string | null }): boolean {
|
||||
function parsePlanningInputPayload(session: { inputPayload?: string | null }): {
|
||||
validated: boolean;
|
||||
createdTaskId?: string;
|
||||
} {
|
||||
try {
|
||||
const payload: unknown = JSON.parse(session.inputPayload ?? "");
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-20-01:15:
|
||||
Terminal Planning UI is an explicit user-validation privilege, not a legacy-session inference.
|
||||
Missing or malformed persistence may contain a running plan, so only a durable `validated: true`
|
||||
marker can reveal SummaryView and task-creation actions after reload, polling, or SSE updates.
|
||||
*/
|
||||
return typeof payload === "object" && payload !== null && (payload as { validated?: unknown }).validated === true;
|
||||
const payload = JSON.parse(session.inputPayload ?? "{}") as {
|
||||
validated?: unknown;
|
||||
createdTaskId?: unknown;
|
||||
};
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return { validated: false };
|
||||
}
|
||||
return {
|
||||
validated: payload.validated === true,
|
||||
createdTaskId: typeof payload.createdTaskId === "string" ? payload.createdTaskId : undefined,
|
||||
};
|
||||
} catch {
|
||||
return false;
|
||||
return { validated: false };
|
||||
}
|
||||
}
|
||||
|
||||
type CompletePlanningResume =
|
||||
| { kind: "task_created"; taskId: string; summary: PlanningSummary }
|
||||
| { kind: "create_retry"; summary: PlanningSummary }
|
||||
| { kind: "plan_review"; summary: PlanningSummary }
|
||||
| { kind: "unrecoverable" };
|
||||
|
||||
function resolveCompletePlanningResume(
|
||||
session: { status?: string; result?: string | null; inputPayload?: string | null },
|
||||
summaryFallback?: PlanningSummary | null,
|
||||
): CompletePlanningResume {
|
||||
let summary: PlanningSummary | null = summaryFallback ?? null;
|
||||
if (!summary && session.result) {
|
||||
try {
|
||||
summary = normalizePlanningSummary(JSON.parse(session.result) as PlanningSummary);
|
||||
} catch {
|
||||
summary = null;
|
||||
}
|
||||
}
|
||||
if (!summary) return { kind: "unrecoverable" };
|
||||
|
||||
const { validated, createdTaskId } = parsePlanningInputPayload(session);
|
||||
const terminal = session.status === "complete" || validated;
|
||||
if (terminal) {
|
||||
if (createdTaskId) return { kind: "task_created", taskId: createdTaskId, summary };
|
||||
return { kind: "create_retry", summary };
|
||||
}
|
||||
return { kind: "plan_review", summary };
|
||||
}
|
||||
|
||||
function getExamplePlans(t: TFunction<"app">): string[] {
|
||||
return [
|
||||
t("planning.examplePlan1", "Build a user authentication system with login and signup"),
|
||||
@@ -993,22 +1035,32 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
});
|
||||
setStreamingOutput("");
|
||||
} else if (session.status === "complete" && session.result) {
|
||||
const summary = normalizePlanningSummary(JSON.parse(session.result) as PlanningSummary);
|
||||
setRunningSummary(summary);
|
||||
if (isValidatedPlanningSession(session)) {
|
||||
resetPlanningAutoRetryBudget();
|
||||
const inputPayload = JSON.parse(session.inputPayload ?? "{}") as { createdTaskId?: unknown };
|
||||
if (typeof inputPayload.createdTaskId === "string") {
|
||||
setView({ type: "task_created", taskId: inputPayload.createdTaskId });
|
||||
} else {
|
||||
setView({ type: "create_retry", session: { sessionId, currentQuestion: null, summary }, summary, errorMessage: t("planning.retryCreate", "Retry create") });
|
||||
}
|
||||
} else {
|
||||
const resume = resolveCompletePlanningResume(session);
|
||||
if (resume.kind === "unrecoverable") {
|
||||
setView({
|
||||
type: "error",
|
||||
session: { sessionId, currentQuestion: null, summary },
|
||||
errorMessage: t("planning.awaitingValidationState", "This plan is still being prepared. Retry to continue the interview."),
|
||||
session: { sessionId, currentQuestion: null, summary: null },
|
||||
errorMessage: t("planning.sessionUnrecoverableState", "This session could not be restored. Retry to continue the interview."),
|
||||
});
|
||||
} else {
|
||||
setRunningSummary(resume.summary);
|
||||
resetPlanningAutoRetryBudget();
|
||||
if (resume.kind === "task_created") {
|
||||
setView({ type: "task_created", taskId: resume.taskId });
|
||||
} else if (resume.kind === "create_retry") {
|
||||
setView({
|
||||
type: "create_retry",
|
||||
session: { sessionId, currentQuestion: null, summary: resume.summary },
|
||||
summary: resume.summary,
|
||||
errorMessage: t("planning.retryCreate", "Retry create"),
|
||||
});
|
||||
} else {
|
||||
setView({
|
||||
type: "plan_review",
|
||||
session: { sessionId, currentQuestion: null, summary: resume.summary },
|
||||
summary: resume.summary,
|
||||
});
|
||||
}
|
||||
}
|
||||
setStreamingOutput("");
|
||||
} else if (session.status === "error") {
|
||||
@@ -1359,10 +1411,21 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
let retryError: unknown = err;
|
||||
const retryErrorMessage = getErrorMessage(err) || "";
|
||||
|
||||
// FNXC:PlanningTurnAdmission 2026-07-22-21:00: a retry rejected because another turn
|
||||
// already holds the session's turn slot means work is in progress — rejoin it via the
|
||||
// same session-refresh path instead of surfacing a terminal error.
|
||||
if (retryErrorMessage.includes("not in an error state") || retryErrorMessage.includes("already in progress")) {
|
||||
/*
|
||||
FNXC:PlanningTurnAdmission 2026-07-22-21:00:
|
||||
A retry rejected because another turn already holds the session's turn slot means work is
|
||||
in progress — rejoin it via the same session-refresh path instead of a terminal error.
|
||||
|
||||
FNXC:PlanningMode 2026-07-24-05:45:
|
||||
"already been validated" is the same class of mismatch: generation retry is the wrong tool
|
||||
for a finished plan. Refresh the durable row and route to create-retry / task-created /
|
||||
plan-review instead of echoing the server exception.
|
||||
*/
|
||||
if (
|
||||
retryErrorMessage.includes("not in an error state")
|
||||
|| retryErrorMessage.includes("already in progress")
|
||||
|| retryErrorMessage.includes("already been validated")
|
||||
) {
|
||||
try {
|
||||
const session = await fetchAiSession(retryTarget.sessionId);
|
||||
if (!session) {
|
||||
@@ -1379,49 +1442,69 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
connectToPlanningStream(session.id);
|
||||
setView({ type: "loading" });
|
||||
} else if (session.status === "awaiting_input") {
|
||||
if (!session.currentQuestion) {
|
||||
throw new Error("Planning session is awaiting input but has no current question.");
|
||||
}
|
||||
resetPlanningAutoRetryBudget();
|
||||
const question = normalizeQuestionOptions(JSON.parse(session.currentQuestion) as PlanningQuestion);
|
||||
clearPlanningDescription(projectId);
|
||||
setView({
|
||||
type: "question",
|
||||
session: { sessionId: session.id, currentQuestion: question, summary: null },
|
||||
});
|
||||
if (session.thinkingOutput) {
|
||||
const trimmed = session.thinkingOutput.trim();
|
||||
if (trimmed) {
|
||||
setConversationHistory((prev) => {
|
||||
const lastEntry = prev[prev.length - 1];
|
||||
if (lastEntry?.thinkingOutput === trimmed) return prev;
|
||||
return [...prev, { thinkingOutput: trimmed }];
|
||||
});
|
||||
const summary = session.result
|
||||
? normalizePlanningSummary(JSON.parse(session.result) as PlanningSummary)
|
||||
: retryTarget.summary;
|
||||
if (summary) setRunningSummary(summary);
|
||||
if (session.currentQuestion) {
|
||||
const question = normalizeQuestionOptions(JSON.parse(session.currentQuestion) as PlanningQuestion);
|
||||
setWorkspaceQuestion(question);
|
||||
setView({
|
||||
type: "question",
|
||||
session: { sessionId: session.id, currentQuestion: question, summary },
|
||||
});
|
||||
if (session.thinkingOutput) {
|
||||
const trimmed = session.thinkingOutput.trim();
|
||||
if (trimmed) {
|
||||
setConversationHistory((prev) => {
|
||||
const lastEntry = prev[prev.length - 1];
|
||||
if (lastEntry?.thinkingOutput === trimmed) return prev;
|
||||
return [...prev, { thinkingOutput: trimmed }];
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!streamConnectionRef.current?.isConnected()) {
|
||||
connectToPlanningStream(session.id);
|
||||
if (!streamConnectionRef.current?.isConnected()) {
|
||||
connectToPlanningStream(session.id);
|
||||
}
|
||||
} else if (summary) {
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-24-05:45:
|
||||
Plan-review rows are awaiting_input with a running plan and no current question.
|
||||
Treating that as fatal forced Retry into an unrecoverable error after stop/proceed.
|
||||
*/
|
||||
setWorkspaceQuestion(null);
|
||||
setView({
|
||||
type: "plan_review",
|
||||
session: { sessionId: session.id, currentQuestion: null, summary },
|
||||
summary,
|
||||
});
|
||||
} else {
|
||||
throw new Error("Planning session is awaiting input but has no current question or plan.");
|
||||
}
|
||||
} else if (session.status === "complete") {
|
||||
if (!session.result) {
|
||||
const resume = resolveCompletePlanningResume(session, retryTarget.summary);
|
||||
if (resume.kind === "unrecoverable") {
|
||||
throw new Error("Planning session is complete but has no result.");
|
||||
}
|
||||
const summary = normalizePlanningSummary(JSON.parse(session.result) as PlanningSummary);
|
||||
setRunningSummary(summary);
|
||||
if (isValidatedPlanningSession(session)) {
|
||||
resetPlanningAutoRetryBudget();
|
||||
clearPlanningDescription(projectId);
|
||||
const inputPayload = JSON.parse(session.inputPayload ?? "{}") as { createdTaskId?: unknown };
|
||||
if (typeof inputPayload.createdTaskId === "string") {
|
||||
setView({ type: "task_created", taskId: inputPayload.createdTaskId });
|
||||
} else {
|
||||
setView({ type: "create_retry", session: { sessionId: session.id, currentQuestion: null, summary }, summary, errorMessage: t("planning.retryCreate", "Retry create") });
|
||||
}
|
||||
resetPlanningAutoRetryBudget();
|
||||
clearPlanningDescription(projectId);
|
||||
setRunningSummary(resume.summary);
|
||||
if (resume.kind === "task_created") {
|
||||
setView({ type: "task_created", taskId: resume.taskId });
|
||||
} else if (resume.kind === "create_retry") {
|
||||
setView({
|
||||
type: "create_retry",
|
||||
session: { sessionId: session.id, currentQuestion: null, summary: resume.summary },
|
||||
summary: resume.summary,
|
||||
errorMessage: t("planning.retryCreate", "Retry create"),
|
||||
});
|
||||
} else {
|
||||
setView({
|
||||
type: "error",
|
||||
session: { sessionId: session.id, currentQuestion: null, summary },
|
||||
errorMessage: t("planning.awaitingValidationState", "This plan is still being prepared. Retry to continue the interview."),
|
||||
type: "plan_review",
|
||||
session: { sessionId: session.id, currentQuestion: null, summary: resume.summary },
|
||||
summary: resume.summary,
|
||||
});
|
||||
}
|
||||
} else if (session.status === "error") {
|
||||
@@ -1841,23 +1924,33 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
}
|
||||
}
|
||||
} else if (session.status === "complete" && session.result) {
|
||||
const summary = persistedRunningSummary ?? normalizePlanningSummary(JSON.parse(session.result));
|
||||
setRunningSummary(summary);
|
||||
if (isValidatedPlanningSession(session)) {
|
||||
const createdTaskId = inputPayload && typeof inputPayload.createdTaskId === "string" ? inputPayload.createdTaskId : undefined;
|
||||
resetPlanningAutoRetryBudget();
|
||||
clearPlanningDescription(projectId);
|
||||
if (createdTaskId) {
|
||||
setView({ type: "task_created", taskId: createdTaskId });
|
||||
} else {
|
||||
setView({ type: "create_retry", session: { sessionId, currentQuestion: null, summary }, summary, errorMessage: t("planning.retryCreate", "Retry create") });
|
||||
}
|
||||
} else {
|
||||
const resume = resolveCompletePlanningResume(session, persistedRunningSummary);
|
||||
if (resume.kind === "unrecoverable") {
|
||||
setView({
|
||||
type: "error",
|
||||
session: { sessionId, currentQuestion: null, summary },
|
||||
errorMessage: t("planning.awaitingValidationState", "This plan is still being prepared. Retry to continue the interview."),
|
||||
session: { sessionId, currentQuestion: null, summary: persistedRunningSummary },
|
||||
errorMessage: t("planning.sessionUnrecoverableState", "This session could not be restored. Retry to continue the interview."),
|
||||
});
|
||||
} else {
|
||||
setRunningSummary(resume.summary);
|
||||
resetPlanningAutoRetryBudget();
|
||||
clearPlanningDescription(projectId);
|
||||
if (resume.kind === "task_created") {
|
||||
setView({ type: "task_created", taskId: resume.taskId });
|
||||
} else if (resume.kind === "create_retry") {
|
||||
setView({
|
||||
type: "create_retry",
|
||||
session: { sessionId, currentQuestion: null, summary: resume.summary },
|
||||
summary: resume.summary,
|
||||
errorMessage: t("planning.retryCreate", "Retry create"),
|
||||
});
|
||||
} else {
|
||||
setView({
|
||||
type: "plan_review",
|
||||
session: { sessionId, currentQuestion: null, summary: resume.summary },
|
||||
summary: resume.summary,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (session.status === "generating") {
|
||||
setView({ type: "loading" });
|
||||
|
||||
@@ -216,7 +216,12 @@ describe("PlanningModeModal autosize", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose the removed final review for an unvalidated completed session", async () => {
|
||||
it("resumes a complete session without a durable validated payload marker as create-retry", async () => {
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-24-05:45:
|
||||
status=complete is only written by validateSession. A missing inputPayload.validated flag
|
||||
must not strand reopen on "still being prepared" — route to create-retry so Proceed can finish.
|
||||
*/
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "session-complete-1",
|
||||
type: "planning",
|
||||
@@ -250,7 +255,51 @@ describe("PlanningModeModal autosize", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("This plan is still being prepared");
|
||||
expect(await screen.findByTestId("planning-create-retry")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Retry create" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("This plan is still being prepared")).toBeNull();
|
||||
expect(screen.queryByTestId("planning-description-markdown-toggle")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the linked task when a complete session already has a createdTaskId", async () => {
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "session-complete-linked",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Linked planning output",
|
||||
inputPayload: JSON.stringify({
|
||||
initialPlan: "Build resilient planning resume",
|
||||
validated: true,
|
||||
createdTaskId: "FN-9001",
|
||||
}),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify({
|
||||
title: "Linked planning output",
|
||||
description: "Recovered summary",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Done"],
|
||||
}),
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onTaskCreated={vi.fn()}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
resumeSessionId="session-complete-linked"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await screen.findByText("FN-9001")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("planning-create-retry")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -987,4 +987,56 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
expect(await screen.findByTestId("planning-create-retry")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Proceed with plan" })).toBeNull();
|
||||
});
|
||||
|
||||
it("routes generation retry away from already-validated sessions into create-retry", async () => {
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-24-05:45:
|
||||
Auto/manual generation retry on a finished plan used to echo "already been validated".
|
||||
Reject retry, re-fetch the complete row, and land on create-retry.
|
||||
*/
|
||||
mockRetryPlanningSession.mockRejectedValue(new Error("Planning session has already been validated"));
|
||||
mockFetchAiSession
|
||||
.mockResolvedValueOnce({
|
||||
...base,
|
||||
status: "error",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(mockSummary),
|
||||
error: "stream failed",
|
||||
inputPayload: JSON.stringify({ validated: true }),
|
||||
})
|
||||
.mockResolvedValue({
|
||||
...base,
|
||||
status: "complete",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(mockSummary),
|
||||
inputPayload: JSON.stringify({ validated: true }),
|
||||
});
|
||||
renderSession();
|
||||
expect(await screen.findByTestId("planning-create-retry")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Planning session has already been validated")).toBeNull();
|
||||
expect(mockRetryPlanningSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores plan review when awaiting_input has a plan but no current question after retry refresh", async () => {
|
||||
mockRetryPlanningSession.mockRejectedValue(new Error("Planning session session-1 is not in an error state"));
|
||||
mockFetchAiSession
|
||||
.mockResolvedValueOnce({
|
||||
...base,
|
||||
status: "error",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(mockSummary),
|
||||
error: "stream failed",
|
||||
inputPayload: "{}",
|
||||
})
|
||||
.mockResolvedValue({
|
||||
...base,
|
||||
status: "awaiting_input",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(mockSummary),
|
||||
inputPayload: "{}",
|
||||
});
|
||||
renderSession();
|
||||
expect(await screen.findByTestId("planning-plan-review")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Proceed with plan" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user