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 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-23 18:52:19 -07:00
parent ddea2876c4
commit 370a7a645d
6 changed files with 113 additions and 47 deletions

View File

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

View File

@@ -215,7 +215,6 @@ function parsePlanningInputPayload(session: { inputPayload?: string | null }): {
type CompletePlanningResume = type CompletePlanningResume =
| { kind: "task_created"; taskId: string; summary: PlanningSummary } | { kind: "task_created"; taskId: string; summary: PlanningSummary }
| { kind: "create_retry"; summary: PlanningSummary }
| { kind: "plan_review"; summary: PlanningSummary } | { kind: "plan_review"; summary: PlanningSummary }
| { kind: "unrecoverable" }; | { kind: "unrecoverable" };
@@ -235,10 +234,15 @@ function resolveCompletePlanningResume(
const { validated, createdTaskId } = parsePlanningInputPayload(session); const { validated, createdTaskId } = parsePlanningInputPayload(session);
const terminal = session.status === "complete" || validated; const terminal = session.status === "complete" || validated;
if (terminal) { /*
if (createdTaskId) return { kind: "task_created", taskId: createdTaskId, summary }; FNXC:PlanningReopenAfterValidate 2026-07-23-23:30:
return { kind: "create_retry", summary }; 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 }; return { kind: "plan_review", summary };
} }
@@ -1196,13 +1200,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
resetPlanningAutoRetryBudget(); resetPlanningAutoRetryBudget();
if (resume.kind === "task_created") { if (resume.kind === "task_created") {
setView({ type: "task_created", taskId: resume.taskId }); 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 { } else {
setView({ setView({
type: "plan_review", type: "plan_review",
@@ -1642,13 +1639,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setRunningSummary(resume.summary); setRunningSummary(resume.summary);
if (resume.kind === "task_created") { if (resume.kind === "task_created") {
setView({ type: "task_created", taskId: resume.taskId }); 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 { } else {
setView({ setView({
type: "plan_review", type: "plan_review",
@@ -2086,13 +2076,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
clearPlanningDescription(projectId); clearPlanningDescription(projectId);
if (resume.kind === "task_created") { if (resume.kind === "task_created") {
setView({ type: "task_created", taskId: resume.taskId }); 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 { } else {
setView({ setView({
type: "plan_review", type: "plan_review",
@@ -4054,6 +4037,19 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
<div className="ai-error-panel" role="alert"> <div className="ai-error-panel" role="alert">
<div className="ai-error-message">{view.errorMessage}</div> <div className="ai-error-message">{view.errorMessage}</div>
<button type="button" className="btn btn-primary" onClick={() => void handleRetryCreateTask()}>{t("planning.retryCreate", "Retry create")}</button> <button type="button" className="btn btn-primary" onClick={() => void handleRetryCreateTask()}>{t("planning.retryCreate", "Retry create")}</button>
{/*
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.
*/}
<button
type="button"
className="btn"
onClick={() => setView({ type: "plan_review", session: view.session, summary: view.summary })}
>
{t("planning.backToPlan", "Back to plan")}
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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: FNXC:PlanningMode 2026-07-24-05:45:
status=complete is only written by validateSession. A missing inputPayload.validated flag 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({ mockFetchAiSession.mockResolvedValueOnce({
id: "session-complete-1", id: "session-complete-1",
@@ -255,10 +260,11 @@ describe("PlanningModeModal autosize", () => {
/> />
); );
expect(await screen.findByTestId("planning-create-retry")).toBeInTheDocument(); expect(await screen.findByTestId("planning-plan-review")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry create" })).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.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 () => { it("opens the linked task when a complete session already has a createdTaskId", async () => {

View File

@@ -1017,18 +1017,29 @@ describe("PlanningModeModal sequential flow", () => {
expect(screen.queryByRole("dialog", { name: "Refine plan and questions" })).toBeNull(); expect(screen.queryByRole("dialog", { name: "Refine plan and questions" })).toBeNull();
expect(screen.getByTestId("planning-plan-review")).toBeInTheDocument(); 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 }) }); mockFetchAiSession.mockResolvedValue({ ...base, status: "complete", currentQuestion: null, result: JSON.stringify(mockSummary), inputPayload: JSON.stringify({ validated: true }) });
renderSession(); renderSession();
expect(await screen.findByTestId("planning-create-retry")).toBeInTheDocument(); expect(await screen.findByTestId("planning-plan-review")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Proceed with plan" })).toBeNull(); 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: FNXC:PlanningMode 2026-07-24-05:45:
Auto/manual generation retry on a finished plan used to echo "already been validated". 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")); mockRetryPlanningSession.mockRejectedValue(new Error("Planning session has already been validated"));
mockFetchAiSession mockFetchAiSession
@@ -1048,7 +1059,7 @@ describe("PlanningModeModal sequential flow", () => {
inputPayload: JSON.stringify({ validated: true }), inputPayload: JSON.stringify({ validated: true }),
}); });
renderSession(); 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(screen.queryByText("Planning session has already been validated")).toBeNull();
expect(mockRetryPlanningSession).toHaveBeenCalled(); expect(mockRetryPlanningSession).toHaveBeenCalled();
}); });

View File

@@ -128,6 +128,25 @@ describe("planning question regeneration instead of no-active-question errors",
expect(lastPrompt).toContain("my answer"); 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 () => { it("contextual comments with no summary still apply via the rebuilt running summary", async () => {
const { sessionId } = await startSessionAwaitingInput("10.2.0.3"); const { sessionId } = await startSessionAwaitingInput("10.2.0.3");
const session = (await getSession(sessionId))!; const session = (await getSession(sessionId))!;

View File

@@ -3152,8 +3152,18 @@ export async function submitResponse(
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`); 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) { 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 // 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`); 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) { if (session.validated) {
throw new InvalidSessionStateError("Planning session has already been validated"); session.validated = false;
} }
if (store && !session.store) session.store = store; if (store && !session.store) session.store = store;
@@ -3544,17 +3555,33 @@ export async function rewindSession(
export function stopGeneration(sessionId: string): boolean { export function stopGeneration(sessionId: string): boolean {
const session = sessions.get(sessionId); const session = sessions.get(sessionId);
const activeGeneration = activeGenerations.get(sessionId); if (!session) {
if (!session || !activeGeneration) {
return false; return false;
} }
activeGeneration.abortReason = "user-stop"; /*
clearTimeout(activeGeneration.timer); FNXC:PlanningStopMultiSession 2026-07-23-23:50:
activeGeneration.abortTeardown(); Stop must work for every generation shape, keyed strictly to this session id so stopping one
activeGeneration.abortController.abort(); plan never touches other concurrently generating sessions. A just-started session whose
activeGenerations.delete(sessionId); 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 returnQuestion = session.generationReturnQuestion;
const stoppedPurpose = session.generationPurpose; const stoppedPurpose = session.generationPurpose;