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),
|
finalizePlanningTaskCreation: vi.fn(async () => undefined),
|
||||||
reconcilePlanningTaskCreation: vi.fn(async () => undefined),
|
reconcilePlanningTaskCreation: vi.fn(async () => undefined),
|
||||||
releasePlanningTaskCreation: 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>() {
|
function deferred<T>() {
|
||||||
|
|||||||
@@ -2633,6 +2633,76 @@ describe("Planning Mode Routes", () => {
|
|||||||
expect(store.createTask).toHaveBeenCalled();
|
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 () => {
|
it("uses summary override when provided", async () => {
|
||||||
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
id: "FN-099",
|
id: "FN-099",
|
||||||
|
|||||||
@@ -1187,6 +1187,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
finalizePlanningTaskCreation,
|
finalizePlanningTaskCreation,
|
||||||
reconcilePlanningTaskCreation,
|
reconcilePlanningTaskCreation,
|
||||||
releasePlanningTaskCreation,
|
releasePlanningTaskCreation,
|
||||||
|
validateSession,
|
||||||
} = await import("../planning.js");
|
} = await import("../planning.js");
|
||||||
|
|
||||||
let session = await getSession(sessionId);
|
let session = await getSession(sessionId);
|
||||||
@@ -1257,10 +1258,29 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
const proposalClaimId = `planning-session:${sessionId}`;
|
const proposalClaimId = `planning-session:${sessionId}`;
|
||||||
const findCreatedTask = async () =>
|
const findCreatedTask = async () =>
|
||||||
(await scopedStore.listTasks({ includeArchived: true })).find((candidate) => candidate.proposalClaimId === proposalClaimId);
|
(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) => {
|
const returnLinkedTask = async (candidate = session) => {
|
||||||
if (!candidate?.createdTaskId) return false;
|
if (!candidate?.createdTaskId) return false;
|
||||||
const linkedTask = await scopedStore.getTask(candidate.createdTaskId).catch(() => null);
|
const linkedTask = await scopedStore.getTask(candidate.createdTaskId).catch(() => null);
|
||||||
if (!linkedTask) throw conflict("PLANNING_CREATED_TASK_MISSING");
|
if (!linkedTask) throw conflict("PLANNING_CREATED_TASK_MISSING");
|
||||||
|
await markSessionComplete();
|
||||||
res.status(200).json({ task: linkedTask, alreadyCreated: true });
|
res.status(200).json({ task: linkedTask, alreadyCreated: true });
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -1269,6 +1289,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
const existingTask = await findCreatedTask();
|
const existingTask = await findCreatedTask();
|
||||||
if (existingTask) {
|
if (existingTask) {
|
||||||
await reconcilePlanningTaskCreation(sessionId, existingTask.id);
|
await reconcilePlanningTaskCreation(sessionId, existingTask.id);
|
||||||
|
await markSessionComplete();
|
||||||
res.status(200).json({ task: existingTask, alreadyCreated: true });
|
res.status(200).json({ task: existingTask, alreadyCreated: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1295,6 +1316,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
const recoveredTask = await findCreatedTask();
|
const recoveredTask = await findCreatedTask();
|
||||||
if (recoveredTask) {
|
if (recoveredTask) {
|
||||||
await reconcilePlanningTaskCreation(sessionId, recoveredTask.id);
|
await reconcilePlanningTaskCreation(sessionId, recoveredTask.id);
|
||||||
|
await markSessionComplete();
|
||||||
res.status(200).json({ task: recoveredTask, alreadyCreated: true });
|
res.status(200).json({ task: recoveredTask, alreadyCreated: true });
|
||||||
return;
|
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 updatePlanningCreateClaim(sessionId, { createClaimStatus: "created", createdTaskId: task.id, claimOwnerToken: undefined, claimStartedAt: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await markSessionComplete();
|
||||||
|
|
||||||
res.status(201).json({ task, alreadyCreated: false });
|
res.status(201).json({ task, alreadyCreated: false });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// A failed insert may release only this request's owner token. A successful insert whose
|
// A failed insert may release only this request's owner token. A successful insert whose
|
||||||
|
|||||||
Reference in New Issue
Block a user