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 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-23 15:45:39 -07:00
parent d3f15f92bf
commit 94644ef849
4 changed files with 103 additions and 0 deletions

View File

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

View File

@@ -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<T>() {

View File

@@ -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<typeof setAiSessionStore>[0]);
(store.createTask as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({});
(store.logEntry as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({
id: "FN-099",

View File

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