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

View File

@@ -17,6 +17,7 @@ import {
type SharedPgTaskStoreHarness,
} from "../../__test-utils__/pg-test-harness.js";
import {
advancePlanningSessionTaskCreationEpoch,
claimPlanningSessionTaskCreation,
finalizePlanningSessionTaskCreation,
getAiSession,
@@ -143,4 +144,56 @@ pgTest("planning session claim lifecycle (multi-task epochs)", () => {
const finalized = await finalizePlanningSessionTaskCreation(db, sessionId, "live-token", "FN-4", 2);
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;
}
/**
* 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(
handle: QueryHandle,
id: string,

View File

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

View File

@@ -289,6 +289,8 @@ export function createTaskFromPlanning(
baseBranch?: string;
};
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> {
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?.branchSelection ? { branchSelection: options.branchSelection } : {}),
...(options?.workflowId !== undefined ? { workflowId: options.workflowId } : {}),
...(options?.previousTaskId ? { previousTaskId: options.previousTaskId } : {}),
}),
}).then((response) => response.task);
}
@@ -504,4 +507,3 @@ export function connectPlanningStream(
return connection;
}

View File

@@ -283,8 +283,8 @@ function resolveCompletePlanningResume(
FNXC:PlanningMultiTask 2026-07-24-00:20:
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
server-side creation epoch so the next Proceed creates a fresh task from the evolved plan.
the banner. Each explicit Proceed advances the server-side creation epoch, while transport
retries for that action retain the old linked-task token and reconcile its canonical task.
*/
return { kind: "plan_review", summary, ...(createdTaskId ? { linkedTaskId: createdTaskId } : {}) };
}
@@ -500,7 +500,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [_activePlanPrompt, setActivePlanPrompt] = useState("");
const [view, setView] = useState<ViewState>({ type: "initial" });
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);
// 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);
@@ -3211,6 +3212,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
try {
const task = await createTaskAfterActiveClaim(() => createTaskFromPlanning(sessionId, summary, projectId, {
...(workflowId !== undefined ? { workflowId } : {}),
...(linkedTaskId ? { previousTaskId: linkedTaskId } : {}),
}));
clearPlanningActiveSession(projectId);
setLinkedTaskId(task.id);
@@ -3222,7 +3224,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
} finally {
validateCreateInFlightRef.current = false;
}
}, [projectId, t, workflowId, workspaceQuestion]);
}, [linkedTaskId, projectId, t, workflowId, workspaceQuestion]);
const handleMobileKeyboardActionPointerDown = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
/*
@@ -3256,7 +3258,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
validateCreateInFlightRef.current = true;
setView({ type: "creating_task", session: view.session, summary: view.summary });
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);
setLinkedTaskId(task.id);
setLinkedTask(task);
@@ -3266,7 +3271,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
} finally {
validateCreateInFlightRef.current = false;
}
}, [projectId, t, view, workflowId]);
}, [linkedTaskId, projectId, t, view, workflowId]);
const handleCreateTask = useCallback(async () => {
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.
*/
...(workflowId !== undefined ? { workflowId } : {}),
...(linkedTaskId ? { previousTaskId: linkedTaskId } : {}),
});
onTaskCreated(task);
// Single-task creation should preserve completed planning history, so
@@ -3303,7 +3309,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
} finally {
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 () => {
if (view.type !== "summary") return;
@@ -4175,15 +4181,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
{/*
FNXC:PlanningMultiTask 2026-07-24-00:20:
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
Proceed creates a fresh task from the evolved plan (unedited Proceed replays return
the same task).
latest created task; the next explicit Proceed action advances the server-side
creation epoch so the plan can create another task without requiring an edit.
*/}
{linkedTaskId && (
<div className="planning-linked-task-note" data-testid="planning-linked-task-note" role="status">
<CheckCircle size={16} />
<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>
{/* 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
@@ -4228,8 +4233,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
{/*
FNXC:PlanningMultiTask 2026-07-24-00:20:
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
creation epoch so Proceed can create another task from the evolved plan.
review workspace where the plan stays readable and editable; the next Proceed
action creates another task, even when the plan is unchanged.
*/}
{view.sessionId && runningSummary && (
<button

View File

@@ -792,6 +792,43 @@ describe("PlanningModeModal sequential flow", () => {
expect(screen.getByTestId("planning-linked-task-note")).toHaveTextContent(mockTasks[0].id);
expect(screen.getByRole("button", { name: "View task" })).toBeEnabled();
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 () => {

View File

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

View File

@@ -19,6 +19,7 @@ import {
finalizePlanningSessionTaskCreation,
reconcilePlanningSessionTaskCreation,
releasePlanningSessionTaskCreation,
advancePlanningSessionTaskCreationEpoch,
listActiveAiSessions,
listAllAiSessions,
listRecoverableAiSessions,
@@ -182,6 +183,12 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
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.
* 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:
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
Proceed without editing dedupes to the same task (alreadyCreated), while editing the plan
after a task exists rotates to a new epoch/key so the next Proceed creates a fresh task.
proposalClaimId index stays the multi-process crash authority WITHIN an epoch. A new explicit
create action carrying the latest task id advances the epoch; transport retries carrying the
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.
*/
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);
if (!summary) throw new InvalidSessionStateError("Planning session has no plan to create a task from");
const claimEpoch = session.taskCreationEpoch ?? 0;
const proposalClaimId = planningProposalClaimId(sessionId, claimEpoch);
let claimEpoch = session.taskCreationEpoch ?? 0;
const currentProposalClaimId = () => planningProposalClaimId(sessionId, claimEpoch);
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 current = await getSession(sessionId);
if (current && !current.validated) {
@@ -4146,18 +4147,31 @@ export async function createTaskFromPlanSession(
const clearStaleLinkedTask = async (staleTaskId: string): Promise<boolean> => {
const allTasks = await store.listTasks({ includeArchived: true }).catch(() => null);
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,
staleTaskId,
operation: "create-task-session",
});
await updatePlanningCreateClaim(sessionId, { createClaimStatus: "none", createdTaskId: undefined, claimOwnerToken: undefined, claimStartedAt: undefined }).catch(() => undefined);
if (session) {
session.createdTaskId = undefined;
session.createClaimStatus = "none";
session.claimOwnerToken = undefined;
session.claimStartedAt = undefined;
const advanced = await advancePlanningTaskCreationEpoch(
sessionId,
staleTaskId,
claimEpoch,
);
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;
};
@@ -4218,7 +4232,7 @@ export async function createTaskFromPlanSession(
priority: isTaskPriority(summary.priority) ? summary.priority : DEFAULT_TASK_PRIORITY,
source: { sourceType: options?.sourceType ?? "cli" },
...(options?.baseBranch?.trim() ? { baseBranch: options.baseBranch.trim() } : {}),
proposalClaimId,
proposalClaimId: currentProposalClaimId(),
});
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.
@@ -4236,7 +4250,7 @@ export async function createTaskFromPlanSession(
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 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 markSessionComplete();
return { task, alreadyCreated: false };
@@ -4309,6 +4323,35 @@ export async function releasePlanningTaskCreation(sessionId: string, ownerToken:
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.
* 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 claimedSessionId: string | undefined;
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;
summary?: unknown;
branch?: unknown;
baseBranch?: unknown;
branchSelection?: unknown;
workflowId?: unknown;
previousTaskId?: unknown;
};
if (!sessionId || typeof sessionId !== "string") {
@@ -1187,6 +1188,10 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
if (workflowId !== undefined && workflowId !== null && typeof workflowId !== "string") {
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);
@@ -1200,6 +1205,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
finalizePlanningTaskCreation,
reconcilePlanningTaskCreation,
releasePlanningTaskCreation,
advancePlanningTaskCreationEpoch,
validateSession,
planningProposalClaimId,
formatPlanningTaskHandoff,
@@ -1301,6 +1307,23 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
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:
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
silently forking.
FNXC:PlanningMultiTask 2026-07-24-00:20:
The key is now per creation EPOCH (`planning-session:{id}` for epoch 0, `…#N` after the
plan is edited past a created task), so one plan can produce multiple tasks while
Proceed replays inside an epoch still dedupe to that epoch's task.
The key is per creation epoch (`planning-session:{id}` for epoch 0, `…#N` afterward).
An explicit action carrying the latest task id advances the epoch; retries carrying the
prior id observe the already-advanced epoch and dedupe to its canonical task.
*/
const claimEpoch = session?.taskCreationEpoch ?? 0;
const proposalClaimId = planningProposalClaimId(sessionId, claimEpoch);
let claimEpoch = session?.taskCreationEpoch ?? 0;
const currentProposalClaimId = () => planningProposalClaimId(sessionId, claimEpoch);
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):
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
task). After each creation the session must stop advertising awaiting_input in the
session list/banner, so terminalize here through validateSession (the sole terminal
transition) on every path that ends with a created task, including alreadyCreated
multiple tasks across epochs (rotation happens on a new explicit create action or when
the plan is edited past a created task). After each creation the session must stop
advertising awaiting_input in the session list/banner, so terminalize here through
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
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
@@ -1360,17 +1382,15 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
const allTasks = await scopedStore.listTasks({ includeArchived: true }).catch(() => null);
const stillListed = allTasks === null || allTasks.some((task) => task.id === candidate.createdTaskId);
if (stillListed) throw conflict("PLANNING_CREATED_TASK_MISSING");
await runPlanningCreateSideEffect(
"Planning create-task stale linkage clear failed",
() => updatePlanningCreateClaim(sessionId, { createClaimStatus: "none", createdTaskId: undefined, claimOwnerToken: undefined, claimStartedAt: undefined }),
{ sessionId, staleTaskId: candidate.createdTaskId },
const staleTaskId = candidate.createdTaskId;
const advanced = await advancePlanningTaskCreationEpoch(
sessionId,
staleTaskId,
claimEpoch,
);
if (session) {
session.createdTaskId = undefined;
session.createClaimStatus = "none";
session.claimOwnerToken = undefined;
session.claimStartedAt = undefined;
}
if (!advanced) throw conflict("Planning task creation state changed; retry creation");
session = advanced;
claimEpoch = advanced.taskCreationEpoch ?? claimEpoch + 1;
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.
*/
...(workflowId !== undefined ? { workflowId: workflowId as string | null } : {}),
proposalClaimId,
proposalClaimId: currentProposalClaimId(),
});
// Update task with suggested size if provided.