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:
7
.changeset/planning-validated-plan-never-dead-end.md
Normal file
7
.changeset/planning-validated-plan-never-dead-end.md
Normal 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.
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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))!;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user