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:
gsxdsm
2026-07-23 17:39:33 -07:00
parent 28e2cd587c
commit 73a57d9487
4 changed files with 280 additions and 79 deletions

View 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.

View File

@@ -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" });

View File

@@ -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();
});
});

View File

@@ -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();
});
});