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 =
|
||||
| { 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
|
||||
<div className="ai-error-panel" role="alert">
|
||||
<div className="ai-error-message">{view.errorMessage}</div>
|
||||
<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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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))!;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user