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:
7
.changeset/planning-reusable-task-creation.md
Normal file
7
.changeset/planning-reusable-task-creation.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2590,6 +2590,7 @@ export {
|
||||
finalizePlanningSessionTaskCreation,
|
||||
reconcilePlanningSessionTaskCreation,
|
||||
releasePlanningSessionTaskCreation,
|
||||
advancePlanningSessionTaskCreationEpoch,
|
||||
listActiveAiSessions,
|
||||
listAllAiSessions,
|
||||
listRecoverableAiSessions,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user