From 94644ef84999d92381d2ae1a84367d69ed04b335 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 23 Jul 2026 15:45:39 -0700 Subject: [PATCH] fix(planning): mark session complete after Proceed-with-plan task creation Proceed with plan called /api/planning/create-task without the legacy /validate step, so the persisted AI session stayed awaiting_input and the session list/needs-input banner kept advertising a finished session. The create-task route now terminalizes the session via validateSession on every path that ends with a created task, including alreadyCreated reconciliation. Co-Authored-By: Claude Fable 5 --- ...ning-session-complete-after-task-create.md | 7 ++ .../routes-planning-tracking.test.ts | 2 + .../src/__tests__/routes-planning.test.ts | 70 +++++++++++++++++++ .../register-planning-subtask-routes.ts | 24 +++++++ 4 files changed, 103 insertions(+) create mode 100644 .changeset/planning-session-complete-after-task-create.md diff --git a/.changeset/planning-session-complete-after-task-create.md b/.changeset/planning-session-complete-after-task-create.md new file mode 100644 index 0000000000..082e2e0f0c --- /dev/null +++ b/.changeset/planning-session-complete-after-task-create.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Planning sessions now show Complete instead of Needs input after their task is created. +category: fix +dev: POST /api/planning/create-task terminalizes the session via validateSession on every created/alreadyCreated path. diff --git a/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts b/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts index 578d4c43bc..8b1567f468 100644 --- a/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts @@ -41,6 +41,8 @@ vi.mock("../planning.js", () => ({ finalizePlanningTaskCreation: vi.fn(async () => undefined), reconcilePlanningTaskCreation: vi.fn(async () => undefined), releasePlanningTaskCreation: vi.fn(async () => undefined), + // FNXC:PlanningMode 2026-07-23-12:10: create-task terminalizes the session after creation. + validateSession: vi.fn(async () => undefined), })); function deferred() { diff --git a/packages/dashboard/src/__tests__/routes-planning.test.ts b/packages/dashboard/src/__tests__/routes-planning.test.ts index 185f38faba..e36d66823b 100644 --- a/packages/dashboard/src/__tests__/routes-planning.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning.test.ts @@ -2633,6 +2633,76 @@ describe("Planning Mode Routes", () => { expect(store.createTask).toHaveBeenCalled(); }); + it("terminalizes a not-yet-validated session when Proceed with plan creates its task", async () => { + /* + FNXC:PlanningMode 2026-07-23-12:10: + Proceed with plan calls create-task directly, without the legacy /validate step. The + persisted session must still leave awaiting_input on task creation; otherwise the + session list and the needs-input banner keep advertising a finished session. + */ + const mockStore = new MockAiSessionStore(); + setAiSessionStore(mockStore as unknown as Parameters[0]); + + (store.createTask as ReturnType).mockResolvedValue({ + id: "FN-777", + description: "Proceed-with-plan task", + column: "triage", + dependencies: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + (store.updateTask as ReturnType).mockResolvedValue({}); + (store.logEntry as ReturnType).mockResolvedValue(undefined); + + const startRes = await REQUEST( + buildApp(), + "POST", + "/api/planning/start", + JSON.stringify({ initialPlan: "Build a user auth system" }), + { "Content-Type": "application/json" } + ); + const sessionId = startRes.body.sessionId; + await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" }); + + // Precondition: mid-interview session, never validated. + expect((await mockStore.get(sessionId))?.status).toBe("awaiting_input"); + + const summary = { + title: "Auth running plan", + description: "Running plan accepted via Proceed with plan", + suggestedSize: "M", + suggestedDependencies: [], + keyDeliverables: ["Implementation"], + }; + const res = await REQUEST( + buildApp(), + "POST", + "/api/planning/create-task", + JSON.stringify({ sessionId, summary }), + { "Content-Type": "application/json" } + ); + expect(res.status).toBe(201); + + // Invariant: a session whose one task exists is terminal for every store reader + // (sidebar session-list label, needs-input banner count, recoverable-session sweep). + expect((await mockStore.get(sessionId))?.status).toBe("complete"); + + // The alreadyCreated reconciliation replay must keep the terminal status. + (store.listTasks as ReturnType).mockResolvedValue([ + { id: "FN-777", proposalClaimId: `planning-session:${sessionId}` }, + ]); + const replay = await REQUEST( + buildApp(), + "POST", + "/api/planning/create-task", + JSON.stringify({ sessionId, summary }), + { "Content-Type": "application/json" } + ); + expect(replay.status).toBe(200); + expect(replay.body.alreadyCreated).toBe(true); + expect((await mockStore.get(sessionId))?.status).toBe("complete"); + }); + it("uses summary override when provided", async () => { (store.createTask as ReturnType).mockResolvedValue({ id: "FN-099", diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index 4fe9c3997c..6240f1b543 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -1187,6 +1187,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann finalizePlanningTaskCreation, reconcilePlanningTaskCreation, releasePlanningTaskCreation, + validateSession, } = await import("../planning.js"); let session = await getSession(sessionId); @@ -1257,10 +1258,29 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann const proposalClaimId = `planning-session:${sessionId}`; const findCreatedTask = async () => (await scopedStore.listTasks({ includeArchived: true })).find((candidate) => candidate.proposalClaimId === proposalClaimId); + /* + FNXC:PlanningMode 2026-07-23-12:10: + A planning session whose task exists is done: the claim model allows exactly one task per + session, so after creation the session must stop advertising awaiting_input in the session + list/banner. The Proceed-with-plan flow calls this route without the legacy /validate step, + so terminalize here through validateSession (the sole terminal transition) on every path + that ends with a created task, including alreadyCreated reconciliation. Best-effort: a + failure to terminalize must not fail the task creation itself. + */ + const markSessionComplete = () => + runPlanningCreateSideEffect( + "Planning create-task session completion failed", + async () => { + const current = await getSession(sessionId); + if (current && !current.validated) await validateSession(sessionId); + }, + { sessionId }, + ); const returnLinkedTask = async (candidate = session) => { if (!candidate?.createdTaskId) return false; const linkedTask = await scopedStore.getTask(candidate.createdTaskId).catch(() => null); if (!linkedTask) throw conflict("PLANNING_CREATED_TASK_MISSING"); + await markSessionComplete(); res.status(200).json({ task: linkedTask, alreadyCreated: true }); return true; }; @@ -1269,6 +1289,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann const existingTask = await findCreatedTask(); if (existingTask) { await reconcilePlanningTaskCreation(sessionId, existingTask.id); + await markSessionComplete(); res.status(200).json({ task: existingTask, alreadyCreated: true }); return; } @@ -1295,6 +1316,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann const recoveredTask = await findCreatedTask(); if (recoveredTask) { await reconcilePlanningTaskCreation(sessionId, recoveredTask.id); + await markSessionComplete(); res.status(200).json({ task: recoveredTask, alreadyCreated: true }); return; } @@ -1382,6 +1404,8 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann await updatePlanningCreateClaim(sessionId, { createClaimStatus: "created", createdTaskId: task.id, claimOwnerToken: undefined, claimStartedAt: undefined }); } + await markSessionComplete(); + res.status(201).json({ task, alreadyCreated: false }); } catch (err: unknown) { // A failed insert may release only this request's owner token. A successful insert whose