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;