From 78543233aa46aae82dd0ca55638cc0e5adae7fe6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 3 Aug 2026 11:41:40 -0700 Subject: [PATCH] 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. --- .changeset/planning-reusable-task-creation.md | 7 ++ .../suite-only-flakes-observed-register.md | 15 ++- .../planning-session-claim.pg.test.ts | 53 +++++++++++ .../async-stores/async-ai-session-store.ts | 43 +++++++++ packages/core/src/index.ts | 1 + .../dashboard/app/api/planning/planning.ts | 4 +- .../app/components/PlanningModeModal.tsx | 31 ++++--- .../PlanningModeModal.planning-flow.test.tsx | 37 ++++++++ .../planning-question-regeneration.test.ts | 5 +- .../src/__tests__/routes-planning.test.ts | 91 +++++++++++++++++++ packages/dashboard/src/ai-session-store.ts | 7 ++ packages/dashboard/src/planning.ts | 73 ++++++++++++--- .../register-planning-subtask-routes.ts | 66 +++++++++----- 13 files changed, 378 insertions(+), 55 deletions(-) create mode 100644 .changeset/planning-reusable-task-creation.md diff --git a/.changeset/planning-reusable-task-creation.md b/.changeset/planning-reusable-task-creation.md new file mode 100644 index 0000000000..f4124b0fde --- /dev/null +++ b/.changeset/planning-reusable-task-creation.md @@ -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. diff --git a/docs/solutions/test-failures/suite-only-flakes-observed-register.md b/docs/solutions/test-failures/suite-only-flakes-observed-register.md index 867470daf3..0e19c67612 100644 --- a/docs/solutions/test-failures/suite-only-flakes-observed-register.md +++ b/docs/solutions/test-failures/suite-only-flakes-observed-register.md @@ -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 diff --git a/packages/core/src/__tests__/postgres/planning-session-claim.pg.test.ts b/packages/core/src/__tests__/postgres/planning-session-claim.pg.test.ts index a0d3f48c7e..ffffd730af 100644 --- a/packages/core/src/__tests__/postgres/planning-session-claim.pg.test.ts +++ b/packages/core/src/__tests__/postgres/planning-session-claim.pg.test.ts @@ -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`); + }); }); diff --git a/packages/core/src/async-stores/async-ai-session-store.ts b/packages/core/src/async-stores/async-ai-session-store.ts index e833a47522..0b58d27a25 100644 --- a/packages/core/src/async-stores/async-ai-session-store.ts +++ b/packages/core/src/async-stores/async-ai-session-store.ts @@ -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 { + 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, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2fd26d26d5..ed270c429e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2590,6 +2590,7 @@ export { finalizePlanningSessionTaskCreation, reconcilePlanningSessionTaskCreation, releasePlanningSessionTaskCreation, + advancePlanningSessionTaskCreationEpoch, listActiveAiSessions, listAllAiSessions, listRecoverableAiSessions, diff --git a/packages/dashboard/app/api/planning/planning.ts b/packages/dashboard/app/api/planning/planning.ts index 75fb2b0839..cd5a1fbe62 100644 --- a/packages/dashboard/app/api/planning/planning.ts +++ b/packages/dashboard/app/api/planning/planning.ts @@ -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 { 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; } - diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 3180235b7f..f26ef90fb8 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -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({ type: "initial" }); const [error, setError] = useState(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(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(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) => { /* @@ -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 && (
- {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 })} {/* 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). */}