From 370a7a645ddf5e0b0226d8f04c8fee894de1e468 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 23 Jul 2026 18:52:19 -0700 Subject: [PATCH] fix(dashboard): a validated plan stays readable, editable, and creatable A finished plan must never land on a do-nothing screen: - submitResponse/rewindSession REOPEN a validated session (clear the terminal marker; the turn's persistSession durably writes it) instead of rejecting with "already been validated". validateSession remains the only terminalizer. - A complete session with no created task resumes into the full plan review workspace (read plan, Refine/comments, Proceed) instead of the create-only retry card; task-linked sessions still resume to the task handoff, preserving the never-rotated one-task-per-session claim. - The live create-failure screen gains a Back to plan action. Co-Authored-By: Claude Fable 5 --- .../planning-validated-plan-never-dead-end.md | 7 +++ .../app/components/PlanningModeModal.tsx | 48 +++++++++---------- .../PlanningModeModal.autosize.test.tsx | 16 +++++-- .../PlanningModeModal.planning-flow.test.tsx | 23 ++++++--- .../planning-question-regeneration.test.ts | 19 ++++++++ packages/dashboard/src/planning.ts | 47 ++++++++++++++---- 6 files changed, 113 insertions(+), 47 deletions(-) create mode 100644 .changeset/planning-validated-plan-never-dead-end.md diff --git a/.changeset/planning-validated-plan-never-dead-end.md b/.changeset/planning-validated-plan-never-dead-end.md new file mode 100644 index 0000000000..7f138088c6 --- /dev/null +++ b/.changeset/planning-validated-plan-never-dead-end.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: A finished plan is never a dead end — read it, keep refining, and create the task at any time. +category: fix +dev: Validated planning sessions reopen on any new turn (submitResponse/rewind clear `validated`; validateSession stays the only terminalizer). Complete-without-task sessions resume into the full plan review workspace instead of the create-retry card, and the create-failure screen gains a Back to plan action. The one-task-per-session claim (`proposalClaimId`) is unchanged. diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 8ea9be9d94..cc5982ea06 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -215,7 +215,6 @@ function parsePlanningInputPayload(session: { inputPayload?: string | null }): { type CompletePlanningResume = | { kind: "task_created"; taskId: string; summary: PlanningSummary } - | { kind: "create_retry"; summary: PlanningSummary } | { kind: "plan_review"; summary: PlanningSummary } | { kind: "unrecoverable" }; @@ -235,10 +234,15 @@ function resolveCompletePlanningResume( 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 }; - } + /* + FNXC:PlanningReopenAfterValidate 2026-07-23-23:30: + A finished plan must never resume into a do-nothing screen. A validated session with no + created task lands on the full plan review workspace — read the plan, keep refining or + commenting (the server reopens a validated session on any new turn), and Proceed to create + the task at any time. Only a session whose task already exists resumes to the task handoff. + The create_retry view remains solely the transient failure screen of a live Proceed attempt. + */ + if (terminal && createdTaskId) return { kind: "task_created", taskId: createdTaskId, summary }; return { kind: "plan_review", summary }; } @@ -1196,13 +1200,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat 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", @@ -1642,13 +1639,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat 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: "plan_review", @@ -2086,13 +2076,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat 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", @@ -4054,6 +4037,19 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
{view.errorMessage}
+ {/* + FNXC:PlanningReopenAfterValidate 2026-07-23-23:30: + A failed create attempt must not trap the operator on an error card. Back to + plan returns to the full plan review workspace where the plan stays readable, + editable, and creatable. + */} +
diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx index c0dcc4924c..bda4243be8 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx @@ -216,11 +216,16 @@ describe("PlanningModeModal autosize", () => { }); }); - it("resumes a complete session without a durable validated payload marker as create-retry", async () => { + it("resumes a complete session without a created task into the full plan review workspace", 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. + must not strand reopen on "still being prepared". + + FNXC:PlanningReopenAfterValidate 2026-07-23-23:30: + A finished plan with no created task must resume into plan review — readable, still + editable (the server reopens validated sessions on any new turn), with Proceed available — + never a create-retry error card or any other do-nothing screen. */ mockFetchAiSession.mockResolvedValueOnce({ id: "session-complete-1", @@ -255,10 +260,11 @@ describe("PlanningModeModal autosize", () => { /> ); - expect(await screen.findByTestId("planning-create-retry")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Retry create" })).toBeInTheDocument(); + expect(await screen.findByTestId("planning-plan-review")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Proceed with plan" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Refine" })).toBeInTheDocument(); + expect(screen.queryByTestId("planning-create-retry")).toBeNull(); 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 () => { diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx index 2ec81efe4a..6f78aa14aa 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -1017,18 +1017,29 @@ describe("PlanningModeModal sequential flow", () => { expect(screen.queryByRole("dialog", { name: "Refine plan and questions" })).toBeNull(); expect(screen.getByTestId("planning-plan-review")).toBeInTheDocument(); }); - it("restores a validated unlinked session to create-only retry", async () => { + /* + FNXC:PlanningReopenAfterValidate 2026-07-23-23:30: + A validated session with no created task must resume into the full plan review workspace + (read, keep editing, Proceed at any time), never a create-only retry card. + */ + it("restores a validated unlinked session to the full plan review workspace", async () => { mockFetchAiSession.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.queryByRole("button", { name: "Proceed with plan" })).toBeNull(); + expect(await screen.findByTestId("planning-plan-review")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Proceed with plan" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Refine" })).toBeInTheDocument(); + expect(screen.queryByTestId("planning-create-retry")).toBeNull(); }); - it("routes generation retry away from already-validated sessions into create-retry", async () => { + it("routes generation retry away from already-validated sessions into plan review", 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. + Reject retry and re-fetch the complete row. + + FNXC:PlanningReopenAfterValidate 2026-07-23-23:30: + The refreshed complete row now lands on plan review so the plan stays editable and + creatable instead of a create-only retry card. */ mockRetryPlanningSession.mockRejectedValue(new Error("Planning session has already been validated")); mockFetchAiSession @@ -1048,7 +1059,7 @@ describe("PlanningModeModal sequential flow", () => { inputPayload: JSON.stringify({ validated: true }), }); renderSession(); - expect(await screen.findByTestId("planning-create-retry")).toBeInTheDocument(); + expect(await screen.findByTestId("planning-plan-review")).toBeInTheDocument(); expect(screen.queryByText("Planning session has already been validated")).toBeNull(); expect(mockRetryPlanningSession).toHaveBeenCalled(); }); diff --git a/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts b/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts index c67820f70b..d9cdcb035c 100644 --- a/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts +++ b/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts @@ -128,6 +128,25 @@ describe("planning question regeneration instead of no-active-question errors", expect(lastPrompt).toContain("my answer"); }); + /* + FNXC:PlanningReopenAfterValidate 2026-07-23-23:30: + Validation must not freeze the plan: a new turn (refine/comments/answers) on a validated + session reopens it and continues the interview instead of throwing "already been validated". + */ + it("reopens a validated session when a refine turn arrives", async () => { + const { sessionId } = await startSessionAwaitingInput("10.2.0.9"); + const session = (await getSession(sessionId))!; + session.validated = true; + session.currentQuestion = undefined; + + const result = await submitResponse(sessionId, { refine: true, focus: "add rollout plan" }, "/tmp/project", undefined, MOCK_TASK_STORE); + + expect(result.type).toBe("question"); + expect(session.validated).toBe(false); + expect(session.error).toBeUndefined(); + expect(session.currentQuestion).toBeDefined(); + }); + it("contextual comments with no summary still apply via the rebuilt running summary", async () => { const { sessionId } = await startSessionAwaitingInput("10.2.0.3"); const session = (await getSession(sessionId))!; diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index 74b2b76046..49ad65ee58 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -3152,8 +3152,18 @@ export async function submitResponse( throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`); } + /* + FNXC:PlanningReopenAfterValidate 2026-07-23-23:30: + A validated plan must never be a read-only dead end: the operator can keep refining, + commenting, or answering, and create the task whenever they choose. A new turn on a + validated session REOPENS it (clears the terminal marker; the turn's own + persistSession("generating") durably writes validated:false and moves the row out of + "complete"), rather than rejecting with "already been validated". validateSession remains + the only terminalizer, and a session whose task already exists keeps its one-task claim + (proposalClaimId is never rotated), so Proceed after re-editing returns the linked task. + */ if (session.validated) { - throw new InvalidSessionStateError("Planning session has already been validated"); + session.validated = false; } // Stash store/rootDir on the session so subsequent ensureSessionAgent calls @@ -3448,8 +3458,9 @@ export async function rewindSession( throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`); } + // FNXC:PlanningReopenAfterValidate 2026-07-23-23:30: editing an earlier answer reopens a validated plan — see submitResponse. if (session.validated) { - throw new InvalidSessionStateError("Planning session has already been validated"); + session.validated = false; } if (store && !session.store) session.store = store; @@ -3544,17 +3555,33 @@ export async function rewindSession( export function stopGeneration(sessionId: string): boolean { const session = sessions.get(sessionId); - const activeGeneration = activeGenerations.get(sessionId); - - if (!session || !activeGeneration) { + if (!session) { return false; } - activeGeneration.abortReason = "user-stop"; - clearTimeout(activeGeneration.timer); - activeGeneration.abortTeardown(); - activeGeneration.abortController.abort(); - activeGenerations.delete(sessionId); + /* + FNXC:PlanningStopMultiSession 2026-07-23-23:50: + Stop must work for every generation shape, keyed strictly to this session id so stopping one + plan never touches other concurrently generating sessions. A just-started session whose + initial turn is still PENDING (registered by start-streaming but not yet consumed by a + stream connect) has no activeGenerations record; without discarding that pending turn here, + Stop returned false and the "stopped" generation sprang back to life on the next stream + connect. Consuming (and dropping) the callback cancels the future turn. + */ + const discardedInitialTurn = planningStreamManager.consumeInitialTurn(sessionId) !== undefined; + const activeGeneration = activeGenerations.get(sessionId); + + if (!activeGeneration && !discardedInitialTurn) { + return false; + } + + if (activeGeneration) { + activeGeneration.abortReason = "user-stop"; + clearTimeout(activeGeneration.timer); + activeGeneration.abortTeardown(); + activeGeneration.abortController.abort(); + activeGenerations.delete(sessionId); + } const returnQuestion = session.generationReturnQuestion; const stoppedPurpose = session.generationPurpose;