fix(planning): allow repeated task creation

Advance the durable creation epoch for each explicit Planning Mode action while preserving idempotency for transport retries. Recover soft-deleted task links with a fresh claim key and cover live, deleted, and retry paths.
This commit is contained in:
gsxdsm
2026-08-03 11:41:40 -07:00
parent 5d8d494230
commit 78543233aa
13 changed files with 378 additions and 55 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Allow Planning Mode to create another task from the same plan without requiring an edit.
category: fix
dev: Advances the durable planning creation epoch while preserving per-action idempotency.

View File

@@ -75,9 +75,22 @@ Seven tests failed in `plugin-runner.test.ts`, but only this one identity surviv
The failure is unrelated to the mobile question footer: it exercises the completed-plan Proceed handoff, while FN-8757 changes only the active-question footer. The file retains substantial coverage, so this first sighting is recorded rather than quarantined; a second sighting requires the normal file-level quarantine. The failure is unrelated to the mobile question footer: it exercises the completed-plan Proceed handoff, while FN-8757 changes only the active-question footer. The file retains substantial coverage, so this first sighting is recorded rather than quarantined; a second sighting requires the normal file-level quarantine.
## 5. Planning Mode mobile plan-tab selection
- **File:** `packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx`
- **Exact test:** `PlanningModeModal sequential flow > uses full-view Questions and Plan preview tabs on mobile`
- **Observed tree/SHA:** `main` at `4ff41a723c` with the Planning Mode task-creation fix uncommitted.
- **Observed frequency:** first observation in a targeted three-file dashboard run.
| run | result |
|---|---|
| targeted three-file dashboard run | **1 failed** / 198 passed; React reported an update outside `act(...)`, and the Plan tab still had `aria-selected="false"` immediately after `fireEvent.click` |
The failure exercises the pre-existing mobile tab transition, while the task-creation fix changes the completed-plan Proceed handoff. The file retains substantial coverage, so this first sighting is recorded rather than quarantined; a second sighting requires the normal file-level quarantine.
## Common shape and unverified suspicion ## Common shape and unverified suspicion
All three are PostgreSQL-backed or PostgreSQL-suite-adjacent, pass in isolation, and appear only under full-suite parallelism. This points at shared database state between test files rather than any of the three tests. It is **unverified and uninvestigated**, not a diagnosis; do not infer a root-cause fix from this record. The first three entries are PostgreSQL-backed or PostgreSQL-suite-adjacent, pass in isolation, and appear only under full-suite parallelism. This points at shared database state between those test files rather than any one test. It is **unverified and uninvestigated**, not a diagnosis; do not infer a root-cause fix from this record. The Planning Mode entries are separate frontend timing observations.
## Policy and escalation ## Policy and escalation

View File

@@ -17,6 +17,7 @@ import {
type SharedPgTaskStoreHarness, type SharedPgTaskStoreHarness,
} from "../../__test-utils__/pg-test-harness.js"; } from "../../__test-utils__/pg-test-harness.js";
import { import {
advancePlanningSessionTaskCreationEpoch,
claimPlanningSessionTaskCreation, claimPlanningSessionTaskCreation,
finalizePlanningSessionTaskCreation, finalizePlanningSessionTaskCreation,
getAiSession, getAiSession,
@@ -143,4 +144,56 @@ pgTest("planning session claim lifecycle (multi-task epochs)", () => {
const finalized = await finalizePlanningSessionTaskCreation(db, sessionId, "live-token", "FN-4", 2); const finalized = await finalizePlanningSessionTaskCreation(db, sessionId, "live-token", "FN-4", 2);
expect(payloadOf(finalized).createdTaskId).toBe("FN-4"); expect(payloadOf(finalized).createdTaskId).toBe("FN-4");
}); });
it("advances the epoch after the linked task is soft-deleted so recreation gets a fresh proposal key", async () => {
const db = h.layer().db;
const store = h.store();
const sessionId = "planning-soft-deleted-task";
const firstProposalKey = `planning-session:${sessionId}`;
const firstTask = await store.createTask({
title: "First planned task",
description: "The task that will be soft-deleted.",
proposalClaimId: firstProposalKey,
});
await upsertAiSession(db, planningRow(sessionId, {
createClaimStatus: "created",
createdTaskId: firstTask.id,
}));
await store.deleteTask(firstTask.id);
expect((await store.listTasks({ includeArchived: true })).some((task) => task.id === firstTask.id)).toBe(false);
await expect(store.createTask({
title: "Colliding replacement",
description: "The soft-deleted row must retain its unique proposal key.",
proposalClaimId: firstProposalKey,
})).rejects.toThrow();
const advanced = await advancePlanningSessionTaskCreationEpoch(
db,
sessionId,
firstTask.id,
0,
);
expect(payloadOf(advanced)).toMatchObject({
createClaimStatus: "none",
taskCreationEpoch: 1,
createdTaskIds: [firstTask.id],
});
expect(payloadOf(advanced).createdTaskId).toBeUndefined();
/*
FNXC:PlanningMultiTask 2026-08-03-18:32:
Retried requests carry the old task id and epoch. The epoch CAS must advance only once.
*/
expect(await advancePlanningSessionTaskCreationEpoch(db, sessionId, firstTask.id, 0)).toBeNull();
expect(payloadOf(await getAiSession(db, sessionId)).taskCreationEpoch).toBe(1);
const replacement = await store.createTask({
title: "Replacement planned task",
description: "A fresh task from the same planning session.",
proposalClaimId: `planning-session:${sessionId}#1`,
});
expect(replacement.id).not.toBe(firstTask.id);
expect(replacement.proposalClaimId).toBe(`planning-session:${sessionId}#1`);
});
}); });

View File

@@ -418,6 +418,49 @@ export async function releasePlanningSessionTaskCreation(
return rows[0] ? rowToSession(rows[0]) : null; return rows[0] ? rowToSession(rows[0]) : null;
} }
/**
* FNXC:PlanningMultiTask 2026-08-03-18:32:
* Move a planning session past the claim owned by its latest created task.
*
* The linked task id acts as the idempotency token for the operator's next create action.
* This compare-and-set advances exactly once and clears only linkage fields while preserving
* concurrent planning payload. It also handles soft-deleted tasks, whose rows intentionally
* retain their proposalClaimId and therefore require a fresh epoch.
*/
export async function advancePlanningSessionTaskCreationEpoch(
handle: QueryHandle,
sessionId: string,
previousTaskId: string,
expectedTaskCreationEpoch: number,
): Promise<AiSessionRow | null> {
const inputPayload = schema.project.aiSessions.inputPayload;
const nextEpoch = expectedTaskCreationEpoch + 1;
const rows = await handle.update(schema.project.aiSessions)
.set({
inputPayload: sql`(
(${inputPayload} - 'createdTaskId' - 'claimOwnerToken' - 'claimStartedAt')
|| jsonb_build_object(
'createClaimStatus', 'none',
'taskCreationEpoch', ${nextEpoch}::int,
'createdTaskIds', CASE
WHEN coalesce(${inputPayload}->'createdTaskIds', '[]'::jsonb) @> jsonb_build_array(${previousTaskId}::text)
THEN coalesce(${inputPayload}->'createdTaskIds', '[]'::jsonb)
ELSE coalesce(${inputPayload}->'createdTaskIds', '[]'::jsonb) || jsonb_build_array(${previousTaskId}::text)
END
)
)`,
updatedAt: new Date().toISOString(),
})
.where(and(
eq(schema.project.aiSessions.id, sessionId),
eq(schema.project.aiSessions.type, "planning"),
sql`${inputPayload}->>'createdTaskId' = ${previousTaskId}`,
...epochGuard(expectedTaskCreationEpoch),
))
.returning();
return rows[0] ? rowToSession(rows[0]) : null;
}
export async function updateAiSessionTitle( export async function updateAiSessionTitle(
handle: QueryHandle, handle: QueryHandle,
id: string, id: string,

View File

@@ -2590,6 +2590,7 @@ export {
finalizePlanningSessionTaskCreation, finalizePlanningSessionTaskCreation,
reconcilePlanningSessionTaskCreation, reconcilePlanningSessionTaskCreation,
releasePlanningSessionTaskCreation, releasePlanningSessionTaskCreation,
advancePlanningSessionTaskCreationEpoch,
listActiveAiSessions, listActiveAiSessions,
listAllAiSessions, listAllAiSessions,
listRecoverableAiSessions, listRecoverableAiSessions,

View File

@@ -289,6 +289,8 @@ export function createTaskFromPlanning(
baseBranch?: string; baseBranch?: string;
}; };
workflowId?: string | null; workflowId?: string | null;
/** FNXC:PlanningMultiTask 2026-08-03-18:32: Latest task visible when the operator intentionally requests another task from this plan. */
previousTaskId?: string;
}, },
): Promise<Task> { ): Promise<Task> {
return api<{ task: Task; alreadyCreated: boolean }>(withProjectId("/planning/create-task", projectId), { return api<{ task: Task; alreadyCreated: boolean }>(withProjectId("/planning/create-task", projectId), {
@@ -299,6 +301,7 @@ export function createTaskFromPlanning(
...(options?.baseBranch !== undefined ? { baseBranch: options.baseBranch } : {}), ...(options?.baseBranch !== undefined ? { baseBranch: options.baseBranch } : {}),
...(options?.branchSelection ? { branchSelection: options.branchSelection } : {}), ...(options?.branchSelection ? { branchSelection: options.branchSelection } : {}),
...(options?.workflowId !== undefined ? { workflowId: options.workflowId } : {}), ...(options?.workflowId !== undefined ? { workflowId: options.workflowId } : {}),
...(options?.previousTaskId ? { previousTaskId: options.previousTaskId } : {}),
}), }),
}).then((response) => response.task); }).then((response) => response.task);
} }
@@ -504,4 +507,3 @@ export function connectPlanningStream(
return connection; return connection;
} }

View File

@@ -283,8 +283,8 @@ function resolveCompletePlanningResume(
FNXC:PlanningMultiTask 2026-07-24-00:20: FNXC:PlanningMultiTask 2026-07-24-00:20:
Sessions whose task already exists resume to plan review too, carrying the linked task for Sessions whose task already exists resume to plan review too, carrying the linked task for
the banner. Proceed without editing idempotently returns that task; editing rotates the the banner. Each explicit Proceed advances the server-side creation epoch, while transport
server-side creation epoch so the next Proceed creates a fresh task from the evolved plan. retries for that action retain the old linked-task token and reconcile its canonical task.
*/ */
return { kind: "plan_review", summary, ...(createdTaskId ? { linkedTaskId: createdTaskId } : {}) }; return { kind: "plan_review", summary, ...(createdTaskId ? { linkedTaskId: createdTaskId } : {}) };
} }
@@ -500,7 +500,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [_activePlanPrompt, setActivePlanPrompt] = useState(""); const [_activePlanPrompt, setActivePlanPrompt] = useState("");
const [view, setView] = useState<ViewState>({ type: "initial" }); const [view, setView] = useState<ViewState>({ type: "initial" });
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// FNXC:PlanningMultiTask 2026-07-24-00:20: latest task created from this plan, shown as a plan-review banner; editing the plan rotates the server-side creation epoch so Proceed can create another. // FNXC:PlanningMultiTask 2026-08-03-18:32: Latest task created from this plan. Passing its id with the next explicit Proceed action
// advances the server-side creation epoch, whether or not the plan was edited.
const [linkedTaskId, setLinkedTaskId] = useState<string | null>(null); const [linkedTaskId, setLinkedTaskId] = useState<string | null>(null);
// FNXC:PlanningMultiTask 2026-07-24-01:40: the just-created Task object, so the banner's View task works immediately after creation without waiting for the tasks prop to refresh (review finding). // FNXC:PlanningMultiTask 2026-07-24-01:40: the just-created Task object, so the banner's View task works immediately after creation without waiting for the tasks prop to refresh (review finding).
const [linkedTask, setLinkedTask] = useState<Task | null>(null); const [linkedTask, setLinkedTask] = useState<Task | null>(null);
@@ -3211,6 +3212,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
try { try {
const task = await createTaskAfterActiveClaim(() => createTaskFromPlanning(sessionId, summary, projectId, { const task = await createTaskAfterActiveClaim(() => createTaskFromPlanning(sessionId, summary, projectId, {
...(workflowId !== undefined ? { workflowId } : {}), ...(workflowId !== undefined ? { workflowId } : {}),
...(linkedTaskId ? { previousTaskId: linkedTaskId } : {}),
})); }));
clearPlanningActiveSession(projectId); clearPlanningActiveSession(projectId);
setLinkedTaskId(task.id); setLinkedTaskId(task.id);
@@ -3222,7 +3224,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
} finally { } finally {
validateCreateInFlightRef.current = false; validateCreateInFlightRef.current = false;
} }
}, [projectId, t, workflowId, workspaceQuestion]); }, [linkedTaskId, projectId, t, workflowId, workspaceQuestion]);
const handleMobileKeyboardActionPointerDown = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => { const handleMobileKeyboardActionPointerDown = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
/* /*
@@ -3256,7 +3258,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
validateCreateInFlightRef.current = true; validateCreateInFlightRef.current = true;
setView({ type: "creating_task", session: view.session, summary: view.summary }); setView({ type: "creating_task", session: view.session, summary: view.summary });
try { try {
const task = await createTaskAfterActiveClaim(() => createTaskFromPlanning(view.session.sessionId, view.summary, projectId, { ...(workflowId !== undefined ? { workflowId } : {}) })); const task = await createTaskAfterActiveClaim(() => createTaskFromPlanning(view.session.sessionId, view.summary, projectId, {
...(workflowId !== undefined ? { workflowId } : {}),
...(linkedTaskId ? { previousTaskId: linkedTaskId } : {}),
}));
clearPlanningActiveSession(projectId); clearPlanningActiveSession(projectId);
setLinkedTaskId(task.id); setLinkedTaskId(task.id);
setLinkedTask(task); setLinkedTask(task);
@@ -3266,7 +3271,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
} finally { } finally {
validateCreateInFlightRef.current = false; validateCreateInFlightRef.current = false;
} }
}, [projectId, t, view, workflowId]); }, [linkedTaskId, projectId, t, view, workflowId]);
const handleCreateTask = useCallback(async () => { const handleCreateTask = useCallback(async () => {
if (view.type !== "summary") return; if (view.type !== "summary") return;
@@ -3290,6 +3295,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
Planning Mode saves must carry the workflow lane that opened the modal so created tasks do not land on the main board before appearing on the selected sub-board. Planning Mode saves must carry the workflow lane that opened the modal so created tasks do not land on the main board before appearing on the selected sub-board.
*/ */
...(workflowId !== undefined ? { workflowId } : {}), ...(workflowId !== undefined ? { workflowId } : {}),
...(linkedTaskId ? { previousTaskId: linkedTaskId } : {}),
}); });
onTaskCreated(task); onTaskCreated(task);
// Single-task creation should preserve completed planning history, so // Single-task creation should preserve completed planning history, so
@@ -3303,7 +3309,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
} finally { } finally {
setIsCreatingTask(false); setIsCreatingTask(false);
} }
}, [baseBranch, branchMode, branchName, editedSummary, view, projectId, workflowId, onTaskCreated, handleClose]); }, [baseBranch, branchMode, branchName, editedSummary, view, projectId, workflowId, linkedTaskId, onTaskCreated, handleClose]);
const handleStartBreakdown = useCallback(async () => { const handleStartBreakdown = useCallback(async () => {
if (view.type !== "summary") return; if (view.type !== "summary") return;
@@ -4175,15 +4181,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
{/* {/*
FNXC:PlanningMultiTask 2026-07-24-00:20: FNXC:PlanningMultiTask 2026-07-24-00:20:
A plan that already produced a task stays a live work surface. The banner links the A plan that already produced a task stays a live work surface. The banner links the
latest created task; continuing to edit rotates the server-side creation epoch, so latest created task; the next explicit Proceed action advances the server-side
Proceed creates a fresh task from the evolved plan (unedited Proceed replays return creation epoch so the plan can create another task without requiring an edit.
the same task).
*/} */}
{linkedTaskId && ( {linkedTaskId && (
<div className="planning-linked-task-note" data-testid="planning-linked-task-note" role="status"> <div className="planning-linked-task-note" data-testid="planning-linked-task-note" role="status">
<CheckCircle size={16} /> <CheckCircle size={16} />
<span> <span>
{t("planning.linkedTaskNote", "Task {{taskId}} was created from this plan. Keep refining to create another.", { taskId: linkedTaskId })} {t("planning.linkedTaskNote", "Task {{taskId}} was created from this plan. Proceed again to create another.", { taskId: linkedTaskId })}
</span> </span>
{/* FNXC:PlanningMultiTask 2026-07-24-01:40: resolve the just-created Task object first so View task is enabled immediately after creation, before the tasks prop refreshes (mirrors the task_created view's view.task ?? tasks.find pattern). */} {/* FNXC:PlanningMultiTask 2026-07-24-01:40: resolve the just-created Task object first so View task is enabled immediately after creation, before the tasks prop refreshes (mirrors the task_created view's view.task ?? tasks.find pattern). */}
<button <button
@@ -4228,8 +4233,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
{/* {/*
FNXC:PlanningMultiTask 2026-07-24-00:20: FNXC:PlanningMultiTask 2026-07-24-00:20:
Task creation is not the end of the plan. Continue planning returns to the plan Task creation is not the end of the plan. Continue planning returns to the plan
review workspace where the plan stays readable and editable; edits rotate the review workspace where the plan stays readable and editable; the next Proceed
creation epoch so Proceed can create another task from the evolved plan. action creates another task, even when the plan is unchanged.
*/} */}
{view.sessionId && runningSummary && ( {view.sessionId && runningSummary && (
<button <button

View File

@@ -792,6 +792,43 @@ describe("PlanningModeModal sequential flow", () => {
expect(screen.getByTestId("planning-linked-task-note")).toHaveTextContent(mockTasks[0].id); expect(screen.getByTestId("planning-linked-task-note")).toHaveTextContent(mockTasks[0].id);
expect(screen.getByRole("button", { name: "View task" })).toBeEnabled(); expect(screen.getByRole("button", { name: "View task" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Proceed with plan" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Proceed with plan" })).toBeInTheDocument();
mockCreateTaskFromPlanning.mockResolvedValueOnce({ id: "FN-002" });
fireEvent.click(screen.getByRole("button", { name: "Proceed with plan" }));
await waitFor(() => expect(mockCreateTaskFromPlanning).toHaveBeenCalledTimes(2));
expect(mockCreateTaskFromPlanning.mock.calls[1]?.[3]).toEqual(expect.objectContaining({
previousTaskId: mockTasks[0].id,
}));
expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-002");
});
/*
FNXC:PlanningMultiTask 2026-08-03-18:32:
A failed response may arrive after the server advanced the creation epoch. Manual Retry must
retain the same previous-task token so the server reconciles that action instead of advancing again.
*/
it("retries a failed explicit create with the same previous-task token", async () => {
mockFetchAiSession.mockResolvedValue({
...base,
status: "complete",
currentQuestion: null,
result: JSON.stringify(mockSummary),
inputPayload: JSON.stringify({ validated: true, createdTaskId: mockTasks[0].id }),
});
mockCreateTaskFromPlanning
.mockRejectedValueOnce(new Error("Response lost after create"))
.mockResolvedValueOnce({ id: "FN-RECONCILED" });
render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} onViewTask={vi.fn()} tasks={mockTasks} projectId="project-1" resumeSessionId="session-1" />);
fireEvent.click(await screen.findByRole("button", { name: "Proceed with plan" }));
expect(await screen.findByTestId("planning-create-retry")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry create" }));
await waitFor(() => expect(mockCreateTaskFromPlanning).toHaveBeenCalledTimes(2));
expect(mockCreateTaskFromPlanning.mock.calls[0]?.[3]).toEqual(expect.objectContaining({ previousTaskId: mockTasks[0].id }));
expect(mockCreateTaskFromPlanning.mock.calls[1]?.[3]).toEqual(expect.objectContaining({ previousTaskId: mockTasks[0].id }));
expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-RECONCILED");
}); });
it("clears the linked-task banner when switching to a session without a created task", async () => { it("clears the linked-task banner when switching to a session without a created task", async () => {

View File

@@ -334,8 +334,9 @@ describe("planning question regeneration instead of no-active-question errors",
expect(afterDeletion.alreadyCreated).toBe(false); expect(afterDeletion.alreadyCreated).toBe(false);
expect(afterDeletion.task.id).not.toBe(second.task.id); expect(afterDeletion.task.id).not.toBe(second.task.id);
expect(createTask).toHaveBeenCalledTimes(3); expect(createTask).toHaveBeenCalledTimes(3);
// The replacement task reuses the current epoch's claim key (its unique-index row died with the deleted task). // FNXC:PlanningMultiTask 2026-08-03-18:32: Production deletion is soft: the tombstone retains the epoch-1 unique claim key, so
expect(createTask.mock.calls[2][0].proposalClaimId).toBe(`planning-session:${sessionId}#1`); // deleted-link recovery must advance before inserting the replacement.
expect(createTask.mock.calls[2][0].proposalClaimId).toBe(`planning-session:${sessionId}#2`);
}); });
/* /*

View File

@@ -3041,6 +3041,97 @@ describe("Planning Mode Routes", () => {
expect(res.body.alreadyCreated).toBe(false); expect(res.body.alreadyCreated).toBe(false);
expect(res.body.task.id).toBe("FN-REBORN"); expect(res.body.task.id).toBe("FN-REBORN");
expect(store.createTask).toHaveBeenCalledTimes(1); expect(store.createTask).toHaveBeenCalledTimes(1);
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
proposalClaimId: `planning-session:${sessionId}#1`,
}));
expect(JSON.parse((await mockStore.get(sessionId))!.inputPayload)).toMatchObject({
createClaimStatus: "created",
createdTaskId: "FN-REBORN",
taskCreationEpoch: 1,
createdTaskIds: ["FN-GONE"],
});
});
it("creates another task from an unchanged plan when the request identifies the previous task", async () => {
const sessionId = "planning-create-another-unchanged";
const previousTaskId = "FN-PREVIOUS";
const mockStore = new MockAiSessionStore();
await mockStore.upsert(buildPlanningRow({
id: sessionId,
status: "complete",
inputPayload: JSON.stringify({
initialPlan: "Build a thing",
validated: true,
createdTaskId: previousTaskId,
createClaimStatus: "created",
}),
result: JSON.stringify({
title: "Reusable plan",
description: "Create more than one task without editing the plan",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Implementation"],
}),
}));
setAiSessionStore(mockStore as unknown as Parameters<typeof setAiSessionStore>[0]);
const nextTask = {
id: "FN-NEXT",
description: "Another task",
column: "triage",
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
proposalClaimId: `planning-session:${sessionId}#1`,
};
let created = false;
(store.listTasks as ReturnType<typeof vi.fn>).mockImplementation(async () => [
{ id: previousTaskId, proposalClaimId: `planning-session:${sessionId}` },
...(created ? [nextTask] : []),
]);
(store.createTask as ReturnType<typeof vi.fn>).mockImplementation(async () => {
created = true;
return nextTask;
});
const appWithAiSessionStore = express();
appWithAiSessionStore.use(express.json());
appWithAiSessionStore.use("/api", createApiRoutes(store, { aiSessionStore: mockStore as any }));
const res = await REQUEST(
appWithAiSessionStore,
"POST",
"/api/planning/create-task",
JSON.stringify({ sessionId, previousTaskId }),
{ "Content-Type": "application/json" },
);
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(res.body.task.id).toBe("FN-NEXT");
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
proposalClaimId: `planning-session:${sessionId}#1`,
}));
/*
FNXC:PlanningMultiTask 2026-08-03-18:32:
A transport retry repeats the old linked-task token. It must reconcile epoch 1's
canonical task instead of treating the retry as a third explicit create action.
*/
const replay = await REQUEST(
appWithAiSessionStore,
"POST",
"/api/planning/create-task",
JSON.stringify({ sessionId, previousTaskId }),
{ "Content-Type": "application/json" },
);
expect(replay.status).toBe(200);
expect(replay.body.alreadyCreated).toBe(true);
expect(replay.body.task.id).toBe("FN-NEXT");
expect(store.createTask).toHaveBeenCalledTimes(1);
expect(JSON.parse((await mockStore.get(sessionId))!.inputPayload)).toMatchObject({
createdTaskId: "FN-NEXT",
taskCreationEpoch: 1,
});
}); });
it("keeps failing closed when the linked task is still listed but unreadable", async () => { it("keeps failing closed when the linked task is still listed but unreadable", async () => {

View File

@@ -19,6 +19,7 @@ import {
finalizePlanningSessionTaskCreation, finalizePlanningSessionTaskCreation,
reconcilePlanningSessionTaskCreation, reconcilePlanningSessionTaskCreation,
releasePlanningSessionTaskCreation, releasePlanningSessionTaskCreation,
advancePlanningSessionTaskCreationEpoch,
listActiveAiSessions, listActiveAiSessions,
listAllAiSessions, listAllAiSessions,
listRecoverableAiSessions, listRecoverableAiSessions,
@@ -182,6 +183,12 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
return row; return row;
} }
async advancePlanningTaskCreationEpoch(sessionId: string, taskId: string, expectedTaskCreationEpoch: number): Promise<AiSessionRow | null> {
const row = await advancePlanningSessionTaskCreationEpoch(this.dbAsync, sessionId, taskId, expectedTaskCreationEpoch) as AiSessionRow | null;
if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt));
return row;
}
/** /**
* Update only the thinkingOutput field, debounced to reduce write frequency. * Update only the thinkingOutput field, debounced to reduce write frequency.
* Flushes immediately if `flush` is true (e.g. on status transition). * Flushes immediately if `flush` is true (e.g. on status transition).

View File

@@ -4046,9 +4046,10 @@ function restoreClaimSession(row: import("./ai-session-store.js").AiSessionRow):
/* /*
FNXC:PlanningMultiTask 2026-07-24-00:20: FNXC:PlanningMultiTask 2026-07-24-00:20:
One plan may produce multiple tasks, one per creation epoch. The task table's partial unique One plan may produce multiple tasks, one per creation epoch. The task table's partial unique
proposalClaimId index stays the multi-process crash authority WITHIN an epoch: replaying proposalClaimId index stays the multi-process crash authority WITHIN an epoch. A new explicit
Proceed without editing dedupes to the same task (alreadyCreated), while editing the plan create action carrying the latest task id advances the epoch; transport retries carrying the
after a task exists rotates to a new epoch/key so the next Proceed creates a fresh task. prior id stay on the already-advanced epoch and dedupe to its canonical task. Editing the plan
after a task exists also rotates to a new epoch/key.
Epoch 0 keeps the legacy un-suffixed key so pre-existing linked sessions stay reconciled. Epoch 0 keeps the legacy un-suffixed key so pre-existing linked sessions stay reconciled.
*/ */
export function planningProposalClaimId(sessionId: string, taskCreationEpoch?: number): string { export function planningProposalClaimId(sessionId: string, taskCreationEpoch?: number): string {
@@ -4116,10 +4117,10 @@ export async function createTaskFromPlanSession(
const summary = session.summary ?? buildRunningSummary(session.initialPlan, session.history); const summary = session.summary ?? buildRunningSummary(session.initialPlan, session.history);
if (!summary) throw new InvalidSessionStateError("Planning session has no plan to create a task from"); if (!summary) throw new InvalidSessionStateError("Planning session has no plan to create a task from");
const claimEpoch = session.taskCreationEpoch ?? 0; let claimEpoch = session.taskCreationEpoch ?? 0;
const proposalClaimId = planningProposalClaimId(sessionId, claimEpoch); const currentProposalClaimId = () => planningProposalClaimId(sessionId, claimEpoch);
const findCreatedTask = async (): Promise<Task | undefined> => const findCreatedTask = async (): Promise<Task | undefined> =>
(await store.listTasks({ includeArchived: true })).find((candidate) => candidate.proposalClaimId === proposalClaimId); (await store.listTasks({ includeArchived: true })).find((candidate) => candidate.proposalClaimId === currentProposalClaimId());
const markSessionComplete = async (): Promise<void> => { const markSessionComplete = async (): Promise<void> => {
const current = await getSession(sessionId); const current = await getSession(sessionId);
if (current && !current.validated) { if (current && !current.validated) {
@@ -4146,18 +4147,31 @@ export async function createTaskFromPlanSession(
const clearStaleLinkedTask = async (staleTaskId: string): Promise<boolean> => { const clearStaleLinkedTask = async (staleTaskId: string): Promise<boolean> => {
const allTasks = await store.listTasks({ includeArchived: true }).catch(() => null); const allTasks = await store.listTasks({ includeArchived: true }).catch(() => null);
if (allTasks === null || allTasks.some((candidate) => candidate.id === staleTaskId)) return false; if (allTasks === null || allTasks.some((candidate) => candidate.id === staleTaskId)) return false;
diagnostics.warn("Planning session linked task no longer exists; clearing stale linkage", { diagnostics.warn("Planning session linked task no longer exists; advancing creation epoch", {
sessionId, sessionId,
staleTaskId, staleTaskId,
operation: "create-task-session", operation: "create-task-session",
}); });
await updatePlanningCreateClaim(sessionId, { createClaimStatus: "none", createdTaskId: undefined, claimOwnerToken: undefined, claimStartedAt: undefined }).catch(() => undefined); const advanced = await advancePlanningTaskCreationEpoch(
if (session) { sessionId,
session.createdTaskId = undefined; staleTaskId,
session.createClaimStatus = "none"; claimEpoch,
session.claimOwnerToken = undefined; );
session.claimStartedAt = undefined; if (!advanced) {
/*
FNXC:PlanningMultiTask 2026-08-03-18:32:
Losing the epoch CAS means another process may already have advanced this session.
Refresh that state once so agent creation can reconcile or claim the winning epoch.
*/
const refreshed = await getDurablePlanningSession(sessionId).catch(() => undefined);
if (!refreshed
|| ((refreshed.taskCreationEpoch ?? 0) === claimEpoch && refreshed.createdTaskId === staleTaskId)) return false;
session = refreshed;
claimEpoch = refreshed.taskCreationEpoch ?? claimEpoch;
return true;
} }
session = advanced;
claimEpoch = advanced.taskCreationEpoch ?? claimEpoch + 1;
return true; return true;
}; };
@@ -4218,7 +4232,7 @@ export async function createTaskFromPlanSession(
priority: isTaskPriority(summary.priority) ? summary.priority : DEFAULT_TASK_PRIORITY, priority: isTaskPriority(summary.priority) ? summary.priority : DEFAULT_TASK_PRIORITY,
source: { sourceType: options?.sourceType ?? "cli" }, source: { sourceType: options?.sourceType ?? "cli" },
...(options?.baseBranch?.trim() ? { baseBranch: options.baseBranch.trim() } : {}), ...(options?.baseBranch?.trim() ? { baseBranch: options.baseBranch.trim() } : {}),
proposalClaimId, proposalClaimId: currentProposalClaimId(),
}); });
insertedTask = task; insertedTask = task;
// FNXC:PlanningMultiTask 2026-07-24-03:20: best-effort side effects must be LOUD on failure (review finding) — a task missing its plan document with no signal is undebuggable. // FNXC:PlanningMultiTask 2026-07-24-03:20: best-effort side effects must be LOUD on failure (review finding) — a task missing its plan document with no signal is undebuggable.
@@ -4236,7 +4250,7 @@ export async function createTaskFromPlanSession(
if (originalRequest) { if (originalRequest) {
await sideEffect("Planning create-task original description document write failed", () => store.upsertTaskDocument?.(task.id, { key: "original-description", content: originalRequest, author: "planning", metadata: { planningSessionId: sessionId, source: "planning-mode-initial-plan" } })); await sideEffect("Planning create-task original description document write failed", () => store.upsertTaskDocument?.(task.id, { key: "original-description", content: originalRequest, author: "planning", metadata: { planningSessionId: sessionId, source: "planning-mode-initial-plan" } }));
} }
await sideEffect("Planning create-task log entry failed", () => store.logEntry?.(task.id, "Created via Planning Mode", `Initial plan: ${(session.initialPlan ?? "").slice(0, 200)}`)); await sideEffect("Planning create-task log entry failed", () => store.logEntry?.(task.id, "Created via Planning Mode", `Initial plan: ${(session?.initialPlan ?? "").slice(0, 200)}`));
await finalizePlanningTaskCreation(sessionId, claimOwnerToken, task.id, claimEpoch); await finalizePlanningTaskCreation(sessionId, claimOwnerToken, task.id, claimEpoch);
await markSessionComplete(); await markSessionComplete();
return { task, alreadyCreated: false }; return { task, alreadyCreated: false };
@@ -4309,6 +4323,35 @@ export async function releasePlanningTaskCreation(sessionId: string, ownerToken:
return row ? restoreClaimSession(row) : undefined; return row ? restoreClaimSession(row) : undefined;
} }
/** FNXC:PlanningMultiTask 2026-08-03-18:32: Advance once when an explicit new create action identifies the session's latest task. */
export async function advancePlanningTaskCreationEpoch(
sessionId: string,
previousTaskId: string,
expectedTaskCreationEpoch: number,
): Promise<Session | undefined> {
if (_aiSessionStore && typeof (_aiSessionStore as unknown as { advancePlanningTaskCreationEpoch?: unknown }).advancePlanningTaskCreationEpoch === "function") {
const row = await _aiSessionStore.advancePlanningTaskCreationEpoch(
sessionId,
previousTaskId,
expectedTaskCreationEpoch,
);
return row ? restoreClaimSession(row) : undefined;
}
const session = await getSession(sessionId);
if (!session
|| session.createdTaskId !== previousTaskId
|| (session.taskCreationEpoch ?? 0) !== expectedTaskCreationEpoch) return undefined;
rotateTaskCreationEpochOnReopen(session);
await updatePlanningCreateClaim(sessionId, {
createClaimStatus: "none",
createdTaskId: undefined,
claimOwnerToken: undefined,
claimStartedAt: undefined,
});
return session;
}
/** /**
* Generate subtasks from a completed planning summary. * Generate subtasks from a completed planning summary.
* Uses the planning session's summary to create a SubtaskItem[] for multi-task creation. * Uses the planning session's summary to create a SubtaskItem[] for multi-task creation.

View File

@@ -1171,13 +1171,14 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
let claimedOwnerToken: string | undefined; let claimedOwnerToken: string | undefined;
let claimedSessionId: string | undefined; let claimedSessionId: string | undefined;
try { try {
const { sessionId, summary: summaryInput, branch, baseBranch, branchSelection, workflowId } = req.body as { const { sessionId, summary: summaryInput, branch, baseBranch, branchSelection, workflowId, previousTaskId } = req.body as {
sessionId?: unknown; sessionId?: unknown;
summary?: unknown; summary?: unknown;
branch?: unknown; branch?: unknown;
baseBranch?: unknown; baseBranch?: unknown;
branchSelection?: unknown; branchSelection?: unknown;
workflowId?: unknown; workflowId?: unknown;
previousTaskId?: unknown;
}; };
if (!sessionId || typeof sessionId !== "string") { if (!sessionId || typeof sessionId !== "string") {
@@ -1187,6 +1188,10 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
if (workflowId !== undefined && workflowId !== null && typeof workflowId !== "string") { if (workflowId !== undefined && workflowId !== null && typeof workflowId !== "string") {
throw badRequest("workflowId must be a string or null"); throw badRequest("workflowId must be a string or null");
} }
if (previousTaskId !== undefined && (typeof previousTaskId !== "string" || !previousTaskId.trim())) {
throw badRequest("previousTaskId must be a non-empty string");
}
const normalizedPreviousTaskId = typeof previousTaskId === "string" ? previousTaskId.trim() : undefined;
const summaryOverride = parsePlanningSummaryOverride(summaryInput); const summaryOverride = parsePlanningSummaryOverride(summaryInput);
@@ -1200,6 +1205,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
finalizePlanningTaskCreation, finalizePlanningTaskCreation,
reconcilePlanningTaskCreation, reconcilePlanningTaskCreation,
releasePlanningTaskCreation, releasePlanningTaskCreation,
advancePlanningTaskCreationEpoch,
validateSession, validateSession,
planningProposalClaimId, planningProposalClaimId,
formatPlanningTaskHandoff, formatPlanningTaskHandoff,
@@ -1301,6 +1307,23 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
session = await getSession(sessionId); session = await getSession(sessionId);
} }
/*
FNXC:PlanningMultiTask 2026-08-03-18:32:
An explicit create action from a plan that already produced a task starts a new creation
epoch even when the plan was not edited. The previous task id is the idempotency token:
the first request advances while retries carrying the same old id observe the already-
advanced/current epoch and reconcile its one canonical task.
*/
if (normalizedPreviousTaskId && session?.createdTaskId === normalizedPreviousTaskId) {
const priorEpoch = session.taskCreationEpoch ?? 0;
const advanced = await advancePlanningTaskCreationEpoch(
sessionId,
normalizedPreviousTaskId,
priorEpoch,
);
session = advanced ?? await getDurablePlanningSession(sessionId) ?? session;
}
/* /*
FNXC:PlanningMode 2026-07-20-15:45: FNXC:PlanningMode 2026-07-20-15:45:
FN-8442: the task table's partial unique proposalClaimId index, not this process's claim FN-8442: the task table's partial unique proposalClaimId index, not this process's claim
@@ -1308,22 +1331,21 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
durable cache reconciled from that key; a missing linked task fails closed rather than durable cache reconciled from that key; a missing linked task fails closed rather than
silently forking. silently forking.
FNXC:PlanningMultiTask 2026-07-24-00:20: The key is per creation epoch (`planning-session:{id}` for epoch 0, `…#N` afterward).
The key is now per creation EPOCH (`planning-session:{id}` for epoch 0, `…#N` after the An explicit action carrying the latest task id advances the epoch; retries carrying the
plan is edited past a created task), so one plan can produce multiple tasks while prior id observe the already-advanced epoch and dedupe to its canonical task.
Proceed replays inside an epoch still dedupe to that epoch's task.
*/ */
const claimEpoch = session?.taskCreationEpoch ?? 0; let claimEpoch = session?.taskCreationEpoch ?? 0;
const proposalClaimId = planningProposalClaimId(sessionId, claimEpoch); const currentProposalClaimId = () => planningProposalClaimId(sessionId, claimEpoch);
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 === currentProposalClaimId());
/* /*
FNXC:PlanningMode 2026-07-23-12:10 (updated FNXC:PlanningMultiTask 2026-07-24-01:40): FNXC:PlanningMode 2026-07-23-12:10 (updated FNXC:PlanningMultiTask 2026-07-24-01:40):
The claim model allows exactly one task per creation EPOCH — a session can produce The claim model allows exactly one task per creation EPOCH — a session can produce
multiple tasks across epochs (rotation happens when the plan is edited past a created multiple tasks across epochs (rotation happens on a new explicit create action or when
task). After each creation the session must stop advertising awaiting_input in the the plan is edited past a created task). After each creation the session must stop
session list/banner, so terminalize here through validateSession (the sole terminal advertising awaiting_input in the session list/banner, so terminalize here through
transition) on every path that ends with a created task, including alreadyCreated validateSession (the sole terminal transition) on every path that ends with a created task, including alreadyCreated
reconciliation; a later edit reopens it. Best-effort: a failure to terminalize must not reconciliation; a later edit reopens it. Best-effort: a failure to terminalize must not
fail the task creation itself. Deploy assumption: the dashboard serves a single code fail the task creation itself. Deploy assumption: the dashboard serves a single code
version per DB at a time — a pre-epoch binary handling a rotated session would derive version per DB at a time — a pre-epoch binary handling a rotated session would derive
@@ -1360,17 +1382,15 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
const allTasks = await scopedStore.listTasks({ includeArchived: true }).catch(() => null); const allTasks = await scopedStore.listTasks({ includeArchived: true }).catch(() => null);
const stillListed = allTasks === null || allTasks.some((task) => task.id === candidate.createdTaskId); const stillListed = allTasks === null || allTasks.some((task) => task.id === candidate.createdTaskId);
if (stillListed) throw conflict("PLANNING_CREATED_TASK_MISSING"); if (stillListed) throw conflict("PLANNING_CREATED_TASK_MISSING");
await runPlanningCreateSideEffect( const staleTaskId = candidate.createdTaskId;
"Planning create-task stale linkage clear failed", const advanced = await advancePlanningTaskCreationEpoch(
() => updatePlanningCreateClaim(sessionId, { createClaimStatus: "none", createdTaskId: undefined, claimOwnerToken: undefined, claimStartedAt: undefined }), sessionId,
{ sessionId, staleTaskId: candidate.createdTaskId }, staleTaskId,
claimEpoch,
); );
if (session) { if (!advanced) throw conflict("Planning task creation state changed; retry creation");
session.createdTaskId = undefined; session = advanced;
session.createClaimStatus = "none"; claimEpoch = advanced.taskCreationEpoch ?? claimEpoch + 1;
session.claimOwnerToken = undefined;
session.claimStartedAt = undefined;
}
return false; return false;
}; };
@@ -1453,7 +1473,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
Planning Mode creates tasks from the board context, so an active workflow lane must be materialized at create time when the client supplies it. Planning Mode creates tasks from the board context, so an active workflow lane must be materialized at create time when the client supplies it.
*/ */
...(workflowId !== undefined ? { workflowId: workflowId as string | null } : {}), ...(workflowId !== undefined ? { workflowId: workflowId as string | null } : {}),
proposalClaimId, proposalClaimId: currentProposalClaimId(),
}); });
// Update task with suggested size if provided. // Update task with suggested size if provided.