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:
@@ -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.
|
||||
@@ -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>() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user