diff --git a/.changeset/planning-multi-task-per-plan.md b/.changeset/planning-multi-task-per-plan.md new file mode 100644 index 0000000000..4793b27d85 --- /dev/null +++ b/.changeset/planning-multi-task-per-plan.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: One plan can now create multiple tasks — keep refining after a task is created and Proceed again. +category: feature +dev: Task-creation claims are epoch-scoped (`planning-session:{id}` → `…#N` via `planningProposalClaimId`); editing a plan past a created task rotates the epoch after turn admission. Complete sessions resume to an editable plan review with a linked-task banner; claim-lifecycle writes are surgical jsonb merges with an epoch-guarded reconcile; create-task 409s while a turn is generating. 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 new file mode 100644 index 0000000000..a7ca32691b --- /dev/null +++ b/packages/core/src/__tests__/postgres/planning-session-claim.pg.test.ts @@ -0,0 +1,120 @@ +/* +FNXC:PlanningMultiTask 2026-07-24-01:40: +PostgreSQL integration coverage for the planning create-claim lifecycle after the +multi-task-per-plan change. Pins two invariants against the real jsonb SQL: +1. Claim-lifecycle writes are SURGICAL — claim/finalize/reconcile/release only touch the four + claim keys, so concurrently-written epoch fields (taskCreationEpoch, createdTaskIds) always + survive (review finding: the previous whole-payload read-modify-write could silently revert + a concurrent epoch rotation). +2. reconcile's expectedTaskCreationEpoch guard is a no-op when the row's epoch has advanced, + so an archived task is never re-linked onto a newer epoch. +*/ + +import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest"; +import { + createSharedPgTaskStoreTestHarness, + pgDescribe, + type SharedPgTaskStoreHarness, +} from "../../__test-utils__/pg-test-harness.js"; +import { + claimPlanningSessionTaskCreation, + finalizePlanningSessionTaskCreation, + getAiSession, + reconcilePlanningSessionTaskCreation, + releasePlanningSessionTaskCreation, + upsertAiSession, + type AiSessionRow, +} from "../../async-ai-session-store.js"; + +const pgTest = pgDescribe; + +function planningRow(id: string, inputPayload: Record): AiSessionRow { + const now = new Date().toISOString(); + return { + id, + type: "planning", + status: "complete", + title: "Multi-task plan", + inputPayload: JSON.stringify({ initialPlan: "Build the thing", ...inputPayload }), + conversationHistory: "[]", + currentQuestion: null, + result: null, + thinkingOutput: "", + error: null, + projectId: null, + createdAt: now, + updatedAt: now, + } as AiSessionRow; +} + +function payloadOf(row: AiSessionRow | null): Record { + return JSON.parse((row?.inputPayload as string) ?? "{}") as Record; +} + +pgTest("planning session claim lifecycle (multi-task epochs)", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_planning_claim", + }); + + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); + + it("claim/finalize/release only touch claim keys and preserve concurrent epoch fields", async () => { + const db = h.layer().db; + const sessionId = "planning-claim-surgical"; + await upsertAiSession(db, planningRow(sessionId, { + taskCreationEpoch: 1, + createdTaskIds: ["FN-1"], + })); + + const token = "owner-token-1"; + const claimed = await claimPlanningSessionTaskCreation(db, sessionId, token, new Date().toISOString()); + expect(claimed).not.toBeNull(); + const afterClaim = payloadOf(claimed); + expect(afterClaim.createClaimStatus).toBe("creating"); + expect(afterClaim.taskCreationEpoch).toBe(1); + expect(afterClaim.createdTaskIds).toEqual(["FN-1"]); + expect(afterClaim.createdTaskId).toBeUndefined(); + expect(afterClaim.initialPlan).toBe("Build the thing"); + + // Second claim while creating must lose the CAS. + expect(await claimPlanningSessionTaskCreation(db, sessionId, "other-token", new Date().toISOString())).toBeNull(); + + const finalized = await finalizePlanningSessionTaskCreation(db, sessionId, token, "FN-2"); + const afterFinalize = payloadOf(finalized); + expect(afterFinalize.createClaimStatus).toBe("created"); + expect(afterFinalize.createdTaskId).toBe("FN-2"); + expect(afterFinalize.claimOwnerToken).toBeUndefined(); + expect(afterFinalize.taskCreationEpoch).toBe(1); + expect(afterFinalize.createdTaskIds).toEqual(["FN-1"]); + + // Wrong-token release is a no-op; the row keeps its finalized linkage. + expect(await releasePlanningSessionTaskCreation(db, sessionId, "other-token")).toBeNull(); + expect(payloadOf(await getAiSession(db, sessionId)).createClaimStatus).toBe("created"); + }); + + it("reconcile with a stale expected epoch is a no-op instead of re-linking an archived task", async () => { + const db = h.layer().db; + const sessionId = "planning-claim-epoch-guard"; + await upsertAiSession(db, planningRow(sessionId, { + taskCreationEpoch: 2, + createdTaskIds: ["FN-1", "FN-2"], + })); + + // Caller derived its claim key under epoch 1; the plan has since rotated to epoch 2. + expect(await reconcilePlanningSessionTaskCreation(db, sessionId, "FN-STALE", 1)).toBeNull(); + const untouched = payloadOf(await getAiSession(db, sessionId)); + expect(untouched.createdTaskId).toBeUndefined(); + expect(untouched.taskCreationEpoch).toBe(2); + + // Matching epoch reconciles normally and preserves the epoch fields. + const reconciled = await reconcilePlanningSessionTaskCreation(db, sessionId, "FN-3", 2); + const afterReconcile = payloadOf(reconciled); + expect(afterReconcile.createClaimStatus).toBe("created"); + expect(afterReconcile.createdTaskId).toBe("FN-3"); + expect(afterReconcile.taskCreationEpoch).toBe(2); + expect(afterReconcile.createdTaskIds).toEqual(["FN-1", "FN-2"]); + }); +}); diff --git a/packages/core/src/async-ai-session-store.ts b/packages/core/src/async-ai-session-store.ts index 371806c795..27193e5a14 100644 --- a/packages/core/src/async-ai-session-store.ts +++ b/packages/core/src/async-ai-session-store.ts @@ -297,18 +297,30 @@ FN-8442 requires a database compare-and-set before Planning Mode creates a task. never-rotated proposalClaimId prevents duplicate task rows, while this conditional ai_sessions transition assigns exactly one live creator across dashboard processes. */ +/* +FNXC:PlanningMultiTask 2026-07-24-01:40: +Claim-lifecycle writes are SURGICAL jsonb merges (`input_payload || patch - removedKeys`), +never read-modify-write of the whole payload. Review finding: the previous stale-spread +(`{...input, ...}`) could silently revert a concurrent epoch rotation (taskCreationEpoch / +createdTaskIds written by an edit turn between this function's read and its UPDATE), which +either re-linked an archived task to the new epoch or dropped the rotation entirely. The +merge form only touches the four claim keys, so concurrent non-claim payload fields always +survive. The WHERE guards still evaluate against the CURRENT row at update time. +*/ +const CLAIM_KEYS_PATCH = (patch: Record, removeKeys: string[]) => + sql`(${schema.project.aiSessions.inputPayload} || ${JSON.stringify(patch)}::jsonb)${sql.raw(removeKeys.map((key) => ` - '${key.replace(/'/g, "''")}'`).join(""))}`; + export async function claimPlanningSessionTaskCreation( handle: QueryHandle, sessionId: string, claimOwnerToken: string, claimStartedAt: string, ): Promise { - const existing = await getAiSession(handle, sessionId); - if (!existing || existing.type !== "planning") return null; - const input = safeJsonParse(existing.inputPayload, {}) as Record; - const inputPayload = { ...input, createClaimStatus: "creating", claimOwnerToken, claimStartedAt, createdTaskId: undefined }; const rows = await handle.update(schema.project.aiSessions) - .set({ inputPayload, updatedAt: claimStartedAt }) + .set({ + inputPayload: CLAIM_KEYS_PATCH({ createClaimStatus: "creating", claimOwnerToken, claimStartedAt }, ["createdTaskId"]), + updatedAt: claimStartedAt, + }) .where(and( eq(schema.project.aiSessions.id, sessionId), eq(schema.project.aiSessions.type, "planning"), @@ -325,30 +337,47 @@ export async function finalizePlanningSessionTaskCreation( claimOwnerToken: string, createdTaskId: string, ): Promise { - const existing = await getAiSession(handle, sessionId); - if (!existing || existing.type !== "planning") return null; - const input = safeJsonParse(existing.inputPayload, {}) as Record; - const inputPayload = { ...input, createClaimStatus: "created", createdTaskId, claimOwnerToken: undefined, claimStartedAt: undefined }; const rows = await handle.update(schema.project.aiSessions) - .set({ inputPayload, updatedAt: new Date().toISOString() }) + .set({ + inputPayload: CLAIM_KEYS_PATCH({ createClaimStatus: "created", createdTaskId }, ["claimOwnerToken", "claimStartedAt"]), + updatedAt: new Date().toISOString(), + }) .where(and(eq(schema.project.aiSessions.id, sessionId), sql`${schema.project.aiSessions.inputPayload}->>'claimOwnerToken' = ${claimOwnerToken}`)) .returning(); return rows[0] ? rowToSession(rows[0]) : null; } -/** Reconcile a task created before a process could finalize its session linkage. */ +/* +Reconcile a task created before a process could finalize its session linkage. + +FNXC:PlanningMultiTask 2026-07-24-01:40: +`expectedTaskCreationEpoch` guards reconcile against racing an edit-turn rotation: the caller +derived the task's claim key under a specific epoch, and stamping that task's id onto a row +whose epoch has since advanced would link an archived task to the NEW epoch. When provided, +the conditional update only fires while the row's epoch still matches; a lost race is a +harmless no-op (the task itself was already returned to the caller). +*/ export async function reconcilePlanningSessionTaskCreation( handle: QueryHandle, sessionId: string, createdTaskId: string, + expectedTaskCreationEpoch?: number, ): Promise { - const existing = await getAiSession(handle, sessionId); - if (!existing || existing.type !== "planning") return null; - const input = safeJsonParse(existing.inputPayload, {}) as Record; - const inputPayload = { ...input, createClaimStatus: "created", createdTaskId, claimOwnerToken: undefined, claimStartedAt: undefined }; + const conditions = [ + eq(schema.project.aiSessions.id, sessionId), + eq(schema.project.aiSessions.type, "planning"), + ]; + if (expectedTaskCreationEpoch !== undefined) { + conditions.push( + sql`coalesce((${schema.project.aiSessions.inputPayload}->>'taskCreationEpoch')::int, 0) = ${expectedTaskCreationEpoch}`, + ); + } const rows = await handle.update(schema.project.aiSessions) - .set({ inputPayload, updatedAt: new Date().toISOString() }) - .where(and(eq(schema.project.aiSessions.id, sessionId), eq(schema.project.aiSessions.type, "planning"))) + .set({ + inputPayload: CLAIM_KEYS_PATCH({ createClaimStatus: "created", createdTaskId }, ["claimOwnerToken", "claimStartedAt"]), + updatedAt: new Date().toISOString(), + }) + .where(and(...conditions)) .returning(); return rows[0] ? rowToSession(rows[0]) : null; } @@ -359,12 +388,11 @@ export async function releasePlanningSessionTaskCreation( sessionId: string, claimOwnerToken: string, ): Promise { - const existing = await getAiSession(handle, sessionId); - if (!existing || existing.type !== "planning") return null; - const input = safeJsonParse(existing.inputPayload, {}) as Record; - const inputPayload = { ...input, createClaimStatus: "none", claimOwnerToken: undefined, claimStartedAt: undefined }; const rows = await handle.update(schema.project.aiSessions) - .set({ inputPayload, updatedAt: new Date().toISOString() }) + .set({ + inputPayload: CLAIM_KEYS_PATCH({ createClaimStatus: "none" }, ["claimOwnerToken", "claimStartedAt"]), + updatedAt: new Date().toISOString(), + }) .where(and(eq(schema.project.aiSessions.id, sessionId), sql`${schema.project.aiSessions.inputPayload}->>'claimOwnerToken' = ${claimOwnerToken}`)) .returning(); return rows[0] ? rowToSession(rows[0]) : null; diff --git a/packages/dashboard/app/components/PlanningModeModal.css b/packages/dashboard/app/components/PlanningModeModal.css index 7008f57e29..0d605519cd 100644 --- a/packages/dashboard/app/components/PlanningModeModal.css +++ b/packages/dashboard/app/components/PlanningModeModal.css @@ -1132,6 +1132,30 @@ That gutter read as dead space around the framed cards; panes now sit flush edge font-size: var(--font-size-sm); } +/* +FNXC:PlanningMultiTask 2026-07-24-00:20: +Plan-review banner linking the latest task created from this plan; the plan remains a live, +editable work surface after creation. +*/ +.planning-linked-task-note { + display: flex; + align-items: center; + /* FNXC:PlanningMultiTask 2026-07-24-01:40: wrap on narrow/mobile viewports so the View task action never clips (review finding). */ + flex-wrap: wrap; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--success, var(--accent)) 8%, var(--card)); + color: var(--text); + font-size: var(--font-size-sm); +} + +.planning-linked-task-note span { + flex: 1; + min-width: 0; +} + .planning-question { display: flex; flex-direction: column; diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index cc5982ea06..ec729eda27 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -163,7 +163,7 @@ type ViewState = | { type: "plan_review"; session: PlanningSession; summary: PlanningSummary } | { type: "creating_task"; session: PlanningSession; summary: PlanningSummary } | { type: "create_retry"; session: PlanningSession; summary: PlanningSummary; errorMessage: string } - | { type: "task_created"; taskId: string; task?: Task } + | { type: "task_created"; taskId: string; task?: Task; sessionId?: string } | { type: "error"; session: PlanningSession; errorMessage: string } | { type: "breakdown"; sessionId: string; originalSubtasks: SubtaskItem[]; subtasks: SubtaskItem[]; dirty: boolean } | { type: "loading" } @@ -200,13 +200,25 @@ function parsePlanningInputPayload(session: { inputPayload?: string | null }): { const payload = JSON.parse(session.inputPayload ?? "{}") as { validated?: unknown; createdTaskId?: unknown; + createdTaskIds?: unknown; }; if (typeof payload !== "object" || payload === null) { return { validated: false }; } + /* + FNXC:PlanningMultiTask 2026-07-24-01:40: + Epoch rotation clears createdTaskId (it archives into createdTaskIds), so a resumed + mid-epoch session would lose its banner without this fallback to the newest archived + task id (review finding). + */ + const archivedIds = Array.isArray(payload.createdTaskIds) + ? payload.createdTaskIds.filter((id): id is string => typeof id === "string") + : []; return { validated: payload.validated === true, - createdTaskId: typeof payload.createdTaskId === "string" ? payload.createdTaskId : undefined, + createdTaskId: typeof payload.createdTaskId === "string" + ? payload.createdTaskId + : archivedIds.length > 0 ? archivedIds[archivedIds.length - 1] : undefined, }; } catch { return { validated: false }; @@ -214,8 +226,7 @@ function parsePlanningInputPayload(session: { inputPayload?: string | null }): { } type CompletePlanningResume = - | { kind: "task_created"; taskId: string; summary: PlanningSummary } - | { kind: "plan_review"; summary: PlanningSummary } + | { kind: "plan_review"; summary: PlanningSummary; linkedTaskId?: string } | { kind: "unrecoverable" }; function resolveCompletePlanningResume( @@ -232,18 +243,20 @@ function resolveCompletePlanningResume( } if (!summary) return { kind: "unrecoverable" }; - const { validated, createdTaskId } = parsePlanningInputPayload(session); - const terminal = session.status === "complete" || validated; + const { createdTaskId } = parsePlanningInputPayload(session); /* FNXC:PlanningReopenAfterValidate 2026-07-23-23:30: - A finished plan must never resume into a do-nothing screen. A validated session with no - created task lands on the full plan review workspace — read the plan, keep refining or - commenting (the server reopens a validated session on any new turn), and Proceed to create - the task at any time. Only a session whose task already exists resumes to the task handoff. - The create_retry view remains solely the transient failure screen of a live Proceed attempt. + A finished plan must never resume into a do-nothing screen: every recoverable complete + session lands on the full plan review workspace — read the plan, keep refining or + commenting (the server reopens a validated session on any new turn), and Proceed at any + time. The create_retry view remains solely the transient failure screen of a live Proceed. + + 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. */ - if (terminal && createdTaskId) return { kind: "task_created", taskId: createdTaskId, summary }; - return { kind: "plan_review", summary }; + return { kind: "plan_review", summary, ...(createdTaskId ? { linkedTaskId: createdTaskId } : {}) }; } function getExamplePlans(t: TFunction<"app">): string[] { @@ -457,6 +470,10 @@ 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. + 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); const [, setResponseHistory] = useState([]); const [conversationHistory, setConversationHistory] = useState([]); const conversationHistoryRef = useRef([]); @@ -1120,6 +1137,26 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat return () => clearInterval(timer); }, [generationStartTime, view.type]); + /* + FNXC:PlanningMultiTask 2026-07-24-01:40: + Single application point for a complete-session resume (review finding: this exact + setRunningSummary + setLinkedTaskId + setView block was duplicated verbatim at the poll, + retry-refresh, and loadSession call sites, and grew a new line in all three copies). + */ + const applyCompletePlanningResume = useCallback( + (sessionId: string, resume: { summary: PlanningSummary; linkedTaskId?: string }) => { + setRunningSummary(resume.summary); + setLinkedTaskId(resume.linkedTaskId ?? null); + setLinkedTask(null); + setView({ + type: "plan_review", + session: { sessionId, currentQuestion: null, summary: resume.summary }, + summary: resume.summary, + }); + }, + [], + ); + // Fallback for missed SSE 'question'/'summary' events: when the loading // state lingers, periodically refetch the session and transition the view // if the server has already moved past generating. Without this, a dropped @@ -1196,17 +1233,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat errorMessage: t("planning.sessionUnrecoverableState", "This session could not be restored. Retry to continue the interview."), }); } else { - setRunningSummary(resume.summary); resetPlanningAutoRetryBudget(); - if (resume.kind === "task_created") { - setView({ type: "task_created", taskId: resume.taskId }); - } else { - setView({ - type: "plan_review", - session: { sessionId, currentQuestion: null, summary: resume.summary }, - summary: resume.summary, - }); - } + applyCompletePlanningResume(sessionId, resume); } setStreamingOutput(""); } else if (session.status === "error") { @@ -1259,6 +1287,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setActivePlanPrompt(""); setView({ type: "initial" }); setError(null); + setLinkedTaskId(null); + setLinkedTask(null); setResponseHistory([]); setConversationHistory([]); setEditedSummary(null); @@ -1636,16 +1666,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } resetPlanningAutoRetryBudget(); clearPlanningDescription(projectId); - setRunningSummary(resume.summary); - if (resume.kind === "task_created") { - setView({ type: "task_created", taskId: resume.taskId }); - } else { - setView({ - type: "plan_review", - session: { sessionId: session.id, currentQuestion: null, summary: resume.summary }, - summary: resume.summary, - }); - } + applyCompletePlanningResume(session.id, resume); } else if (session.status === "error") { const terminalView: ViewState = { type: "error", @@ -1878,6 +1899,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat currentSessionIdRef.current = sessionId; setError(null); + /* + FNXC:PlanningMultiTask 2026-07-24-01:40: + Review finding (confidence 100): the linked-task banner leaked across session switches — + only the complete branch below set linkedTaskId, so opening session B after a + task-linked session A showed "Task was created from this plan" on B's plan + review with a working View task button. Clear it at load start; the complete branch + restores it for the session actually being loaded. + */ + setLinkedTaskId(null); + setLinkedTask(null); setStreamingOutput(""); setResponseHistory([]); setConversationHistory([]); @@ -2071,18 +2102,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat errorMessage: t("planning.sessionUnrecoverableState", "This session could not be restored. Retry to continue the interview."), }); } else { - setRunningSummary(resume.summary); resetPlanningAutoRetryBudget(); clearPlanningDescription(projectId); - if (resume.kind === "task_created") { - setView({ type: "task_created", taskId: resume.taskId }); - } else { - setView({ - type: "plan_review", - session: { sessionId, currentQuestion: null, summary: resume.summary }, - summary: resume.summary, - }); - } + applyCompletePlanningResume(sessionId, resume); } } else if (session.status === "generating") { setView({ type: "loading" }); @@ -3051,7 +3073,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat ...(workflowId !== undefined ? { workflowId } : {}), })); clearPlanningActiveSession(projectId); - setView({ type: "task_created", taskId: task.id, task }); + setLinkedTaskId(task.id); + setLinkedTask(task); + setView({ type: "task_created", taskId: task.id, task, sessionId }); } catch (err) { const errorMessage = getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task"); setView({ type: "create_retry", session, summary, errorMessage }); @@ -3094,7 +3118,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat try { const task = await createTaskAfterActiveClaim(() => createTaskFromPlanning(view.session.sessionId, view.summary, projectId, { ...(workflowId !== undefined ? { workflowId } : {}) })); clearPlanningActiveSession(projectId); - setView({ type: "task_created", taskId: task.id, task }); + setLinkedTaskId(task.id); + setLinkedTask(task); + setView({ type: "task_created", taskId: task.id, task, sessionId: view.session.sessionId }); } catch (err) { setView({ ...view, errorMessage: getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task") }); } finally { @@ -3997,6 +4023,33 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat {view.type === "plan_review" && (
+ {/* + 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). + */} + {linkedTaskId && ( +
+ + + {t("planning.linkedTaskNote", "Task {{taskId}} was created from this plan. Keep refining 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). */} + +
+ )} {renderPlanPane(view.summary)}
)} @@ -4023,6 +4076,37 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat {t("planning.viewTask", "View task")} + {/* + 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. + */} + {view.sessionId && runningSummary && ( + + )} diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx index bda4243be8..9b906f6bc7 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx @@ -267,7 +267,12 @@ describe("PlanningModeModal autosize", () => { expect(screen.queryByText("This plan is still being prepared")).toBeNull(); }); - it("opens the linked task when a complete session already has a createdTaskId", async () => { + /* + FNXC:PlanningMultiTask 2026-07-24-00:20: + A session whose task exists resumes to the editable plan review workspace with a banner + linking that task — not a terminal handoff — so the plan can evolve into further tasks. + */ + it("resumes a task-linked complete session to plan review with the linked-task banner", async () => { mockFetchAiSession.mockResolvedValueOnce({ id: "session-complete-linked", type: "planning", @@ -305,7 +310,10 @@ describe("PlanningModeModal autosize", () => { /> ); - expect(await screen.findByText("FN-9001")).toBeInTheDocument(); + expect(await screen.findByTestId("planning-plan-review")).toBeInTheDocument(); + expect(screen.getByTestId("planning-linked-task-note")).toBeInTheDocument(); + expect(screen.getByTestId("planning-linked-task-note").textContent).toContain("FN-9001"); + expect(screen.getByRole("button", { name: "Proceed with plan" })).toBeInTheDocument(); expect(screen.queryByTestId("planning-create-retry")).toBeNull(); }); }); diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx index 6f78aa14aa..1184b3ca7a 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -661,7 +661,13 @@ describe("PlanningModeModal sequential flow", () => { expect(screen.getByRole("button", { name: "Return to sessions" })).toBeEnabled(); }); - it("restores a linked task into the created-task handoff", async () => { + /* + FNXC:PlanningMultiTask 2026-07-24-00:20: + A session whose task exists resumes to the EDITABLE plan review workspace with a banner + linking that task — not a terminal created-task handoff — so the plan can keep evolving + into further tasks. Reopen never re-fires onTaskCreated for a previously created task. + */ + it("restores a linked task as a plan-review banner with a live View task action", async () => { mockFetchAiSession.mockResolvedValue({ ...base, status: "complete", @@ -673,12 +679,17 @@ describe("PlanningModeModal sequential flow", () => { const onViewTask = vi.fn(); render(); - expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-001"); - await waitFor(() => expect(onTaskCreated).toHaveBeenCalledWith(mockTasks[0])); - expect(screen.getByRole("button", { name: "View task" })).toBeEnabled(); + expect(await screen.findByTestId("planning-linked-task-note")).toHaveTextContent("FN-001"); + expect(screen.getByTestId("planning-plan-review")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Proceed with plan" })).toBeInTheDocument(); + expect(onTaskCreated).not.toHaveBeenCalled(); + const viewTask = screen.getByRole("button", { name: "View task" }); + expect(viewTask).toBeEnabled(); + fireEvent.click(viewTask); + expect(onViewTask).toHaveBeenCalledWith(mockTasks[0]); }); - it("waits for a restored linked task before enabling its task handoff", async () => { + it("disables the linked-task banner action until the restored task is loaded", async () => { mockFetchAiSession.mockResolvedValue({ ...base, status: "complete", @@ -689,11 +700,67 @@ describe("PlanningModeModal sequential flow", () => { const onTaskCreated = vi.fn(); render(); - expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-LATER"); + expect(await screen.findByTestId("planning-linked-task-note")).toHaveTextContent("FN-LATER"); expect(screen.getByRole("button", { name: "View task" })).toBeDisabled(); expect(onTaskCreated).not.toHaveBeenCalled(); }); + /* + FNXC:PlanningMultiTask 2026-07-24-01:40: + Review findings: Continue planning must return to the editable plan review with a working + linked-task banner (resolving the just-created Task object, before the tasks prop refreshes), + and the banner must never leak across session switches. + */ + it("Continue planning returns from the task handoff to an editable plan review with a live banner", async () => { + mockFetchAiSession.mockResolvedValue({ + ...base, + status: "complete", + currentQuestion: null, + result: JSON.stringify(mockSummary), + inputPayload: JSON.stringify({ validated: true }), + }); + mockCreateTaskFromPlanning.mockResolvedValue(mockTasks[0]); + // tasks={[]} proves the banner resolves the just-created Task object, not the tasks prop. + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Proceed with plan" })); + expect(await screen.findByTestId("planning-task-created")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Continue planning" })); + expect(await screen.findByTestId("planning-plan-review")).toBeInTheDocument(); + 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(); + }); + + it("clears the linked-task banner when switching to a session without a created task", async () => { + mockFetchAiSession.mockImplementation(async (sessionId: string) => sessionId === "session-1" + ? { + ...base, + id: "session-1", + status: "complete", + currentQuestion: null, + result: JSON.stringify(mockSummary), + inputPayload: JSON.stringify({ validated: true, createdTaskId: "FN-001" }), + } + : { + ...base, + id: "session-2", + status: "awaiting_input", + currentQuestion: null, + result: JSON.stringify(mockSummary), + inputPayload: "{}", + }); + const props = { isOpen: true, onClose: vi.fn(), onTaskCreated: vi.fn(), onTasksCreated: vi.fn(), tasks: mockTasks, projectId: "project-1" }; + const { rerender } = render(); + + expect(await screen.findByTestId("planning-linked-task-note")).toBeInTheDocument(); + + rerender(); + await waitFor(() => expect(screen.queryByTestId("planning-linked-task-note")).toBeNull()); + expect(screen.getByTestId("planning-plan-review")).toBeInTheDocument(); + }); + it("uses full-view Questions and Plan preview tabs on mobile", async () => { mockViewportMode.mockReturnValue("mobile"); mockFetchAiSession.mockResolvedValue({ diff --git a/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts b/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts index dc5761fd55..24598fd58f 100644 --- a/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts +++ b/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts @@ -111,10 +111,23 @@ function installContextAwareAgent() { } function createStore() { - let createdTask: { id: string; title: string; description: string } | undefined; - const createTask = vi.fn(async (input: { title: string; description: string }) => { - createdTask = { id: "FN-E2E-001", title: input.title, description: input.description }; - return createdTask; + /* + FNXC:PlanningMultiTask 2026-07-24-01:40: + Tasks record their proposalClaimId and stay listed so findCreatedTask's epoch-keyed + crash-window reconciliation is exercised for real (review finding: only epoch 0 was + covered). `tasks` is exposed so tests can inject an orphaned row simulating a crash + between task insert and session finalize. + */ + const tasks: Array<{ id: string; title: string; description: string; proposalClaimId?: string }> = []; + const createTask = vi.fn(async (input: { title: string; description: string; proposalClaimId?: string }) => { + const created = { + id: `FN-E2E-00${tasks.length + 1}`, + title: input.title, + description: input.description, + proposalClaimId: input.proposalClaimId, + }; + tasks.push(created); + return created; }); return { getSettings: vi.fn().mockResolvedValue({ @@ -123,15 +136,17 @@ function createStore() { ntfyEnabled: false, }), getRootDir: vi.fn().mockReturnValue("/tmp/planning-e2e"), - listTasks: vi.fn(async () => createdTask ? [createdTask] : []), + listTasks: vi.fn(async () => [...tasks]), getTask: vi.fn(async (id: string) => { - if (createdTask?.id === id) return createdTask; + const found = tasks.find((task) => task.id === id); + if (found) return found; throw new Error("not found"); }), createTask, updateTask: vi.fn().mockResolvedValue(undefined), logEntry: vi.fn().mockResolvedValue(undefined), - } as unknown as TaskStore & { createTask: typeof createTask }; + tasks, + } as unknown as TaskStore & { createTask: typeof createTask; tasks: typeof tasks }; } function buildApp(store: TaskStore): express.Express { @@ -227,6 +242,83 @@ describe("Planning Mode plan creation E2E", () => { expect(store.createTask).toHaveBeenCalledTimes(1); }); + /* + FNXC:PlanningMultiTask 2026-07-24-00:20: + One plan can produce multiple tasks. Editing the plan after a task exists rotates the + creation epoch: the next Proceed creates a FRESH task under a new epoch-suffixed + proposalClaimId instead of replaying the first task, while unedited replays (above) stay + idempotent within their epoch. + */ + it("creates a second task after the plan is edited past the first one", async () => { + const start = await post(app, "/api/planning/start", { initialPlan: "Build secure account recovery" }); + expect(start.status).toBe(201); + const sessionId = start.body.sessionId as string; + + const created = await post(app, "/api/planning/create-task", { sessionId }); + expect(created.status).toBe(201); + expect(store.createTask).toHaveBeenCalledTimes(1); + expect(store.createTask.mock.calls[0][0]).toMatchObject({ proposalClaimId: `planning-session:${sessionId}` }); + + // Editing the validated plan reopens it and rotates the creation epoch. + const refined = await post(app, "/api/planning/respond", { sessionId, responses: { refine: true, focus: "split rollout" } }); + expect(refined.status).toBe(200); + const session = await getSession(sessionId); + expect(session?.taskCreationEpoch).toBe(1); + expect(session?.createdTaskIds).toEqual(["FN-E2E-001"]); + + const second = await post(app, "/api/planning/create-task", { sessionId }); + expect(second.status).toBe(201); + expect(second.body.alreadyCreated).toBe(false); + expect(store.createTask).toHaveBeenCalledTimes(2); + expect(store.createTask.mock.calls[1][0]).toMatchObject({ proposalClaimId: `planning-session:${sessionId}#1` }); + + /* + FNXC:PlanningMultiTask 2026-07-24-01:40: + Replay idempotency must hold INSIDE a rotated epoch too (review finding: only epoch 0's + replay was pinned): an unedited Proceed after the second create returns that same task. + */ + const replayAfterRotation = await post(app, "/api/planning/create-task", { sessionId }); + expect(replayAfterRotation.status).toBe(200); + expect(replayAfterRotation.body.alreadyCreated).toBe(true); + expect(replayAfterRotation.body.task.id).toBe("FN-E2E-002"); + expect(store.createTask).toHaveBeenCalledTimes(2); + }); + + /* + FNXC:PlanningMultiTask 2026-07-24-01:40: + Crash-after-insert recovery inside a ROTATED epoch: a task row exists under the epoch-1 + claim key but the session linkage was never finalized. Proceed must reconcile that row via + findCreatedTask on the current epoch key instead of inserting a duplicate (review finding: + the crash-window regression test only covered the un-suffixed epoch-0 key). + */ + it("reconciles a crash-orphaned task row under an epoch-suffixed claim key instead of duplicating it", async () => { + const start = await post(app, "/api/planning/start", { initialPlan: "Build secure account recovery" }); + expect(start.status).toBe(201); + const sessionId = start.body.sessionId as string; + + const created = await post(app, "/api/planning/create-task", { sessionId }); + expect(created.status).toBe(201); + expect(store.createTask).toHaveBeenCalledTimes(1); + + const refined = await post(app, "/api/planning/respond", { sessionId, responses: { refine: true, focus: "split rollout" } }); + expect(refined.status).toBe(200); + expect((await getSession(sessionId))?.taskCreationEpoch).toBe(1); + + // Simulate the crash window: the epoch-1 task row landed, but finalize never ran. + store.tasks.push({ + id: "FN-E2E-CRASH", + title: "Crash-orphaned second task", + description: "inserted before finalize", + proposalClaimId: `planning-session:${sessionId}#1`, + }); + + const retry = await post(app, "/api/planning/create-task", { sessionId }); + expect(retry.status).toBe(200); + expect(retry.body.alreadyCreated).toBe(true); + expect(retry.body.task.id).toBe("FN-E2E-CRASH"); + expect(store.createTask).toHaveBeenCalledTimes(1); + }); + it("keeps AI-authored options and Other in the input language", async () => { const start = await post(app, "/api/planning/start", { initialPlan: "Quiero crear una recuperación segura de cuentas" }); expect(start.status).toBe(201); diff --git a/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts b/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts index d9cdcb035c..d2103a205f 100644 --- a/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts +++ b/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts @@ -40,7 +40,10 @@ import { __setCreateFnAgent, createSessionWithAgent, getSession, + InvalidSessionStateError, + planningProposalClaimId, planningStreamManager, + rewindSession, setAiSessionStore, submitResponse, } from "../planning.js"; @@ -147,6 +150,129 @@ describe("planning question regeneration instead of no-active-question errors", expect(session.currentQuestion).toBeDefined(); }); + /* + FNXC:PlanningMultiTask 2026-07-24-00:20: + One plan can produce multiple tasks. Editing a plan whose current epoch already created a + task rotates the creation epoch (new proposalClaimId key, claim state reset, task recorded + in createdTaskIds) so the next Proceed creates a fresh task; a session without a created + task keeps its epoch so unedited Proceed replays stay idempotent. + */ + it("rotates the task-creation epoch when a task-linked plan is edited", async () => { + const { sessionId } = await startSessionAwaitingInput("10.2.0.10"); + const session = (await getSession(sessionId))!; + session.validated = true; + session.createdTaskId = "FN-100"; + session.createClaimStatus = "created"; + session.currentQuestion = undefined; + + expect(planningProposalClaimId(sessionId, session.taskCreationEpoch)).toBe(`planning-session:${sessionId}`); + + const result = await submitResponse(sessionId, { refine: true }, "/tmp/project", undefined, MOCK_TASK_STORE); + + expect(result.type).toBe("question"); + expect(session.taskCreationEpoch).toBe(1); + expect(session.createdTaskId).toBeUndefined(); + expect(session.createClaimStatus).toBe("none"); + expect(session.createdTaskIds).toEqual(["FN-100"]); + expect(planningProposalClaimId(sessionId, session.taskCreationEpoch)).toBe(`planning-session:${sessionId}#1`); + + // A second edit without a new created task must NOT rotate again. + const secondResult = await submitResponse(sessionId, { refine: true, focus: "smaller scope" }, "/tmp/project", undefined, MOCK_TASK_STORE); + expect(secondResult.type).toBe("question"); + expect(session.taskCreationEpoch).toBe(1); + expect(session.createdTaskIds).toEqual(["FN-100"]); + }); + + /* + FNXC:PlanningMultiTask 2026-07-24-01:40: + Review findings: (a) rewindSession shares the rotation invariant with submitResponse and + needs its own regression coverage; (b) a REJECTED request must never burn a phantom + rotation — reopen/rotation only run after admission and preconditions pass. + */ + it("rotates the epoch when rewinding an answered question on a task-linked plan", async () => { + const { sessionId } = await startSessionAwaitingInput("10.2.0.11"); + const session = (await getSession(sessionId))!; + const question = session.currentQuestion!; + await submitResponse(sessionId, { [question.id]: "first answer" }, "/tmp/project", undefined, MOCK_TASK_STORE); + expect(session.history.length).toBeGreaterThan(0); + + session.validated = true; + session.createdTaskId = "FN-200"; + session.createClaimStatus = "created"; + + await rewindSession(sessionId, undefined, "/tmp/project", undefined, MOCK_TASK_STORE); + + expect(session.taskCreationEpoch).toBe(1); + expect(session.createdTaskIds).toEqual(["FN-200"]); + expect(session.createdTaskId).toBeUndefined(); + expect(session.createClaimStatus).toBe("none"); + expect(session.validated).toBe(false); + }); + + it("does not rotate the epoch when a rewind is rejected before admission", async () => { + const { sessionId } = await startSessionAwaitingInput("10.2.0.12"); + const session = (await getSession(sessionId))!; + // Live task-linked session with NO history: rewind must reject without touching claim state. + session.validated = true; + session.createdTaskId = "FN-300"; + session.createClaimStatus = "created"; + session.history = []; + + await expect(rewindSession(sessionId, undefined, "/tmp/project", undefined, MOCK_TASK_STORE)) + .rejects.toBeInstanceOf(InvalidSessionStateError); + + expect(session.taskCreationEpoch).toBeUndefined(); + expect(session.createdTaskId).toBe("FN-300"); + expect(session.createClaimStatus).toBe("created"); + expect(session.validated).toBe(true); + }); + + /* + FNXC:PlanningMultiTask 2026-07-24-01:40: + Durable round-trip of the new epoch fields through buildSessionFromRow's normalization + (review finding: the positive-integer guard and string-array filter were untested). + */ + it("restores and normalizes taskCreationEpoch/createdTaskIds from a persisted row", async () => { + const baseRow = { + type: "planning", + status: "awaiting_input", + title: "Restored plan", + conversationHistory: "[]", + currentQuestion: null, + result: null, + thinkingOutput: "", + error: null, + projectId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + const rows: Record = { + "row-valid": { + ...baseRow, + id: "row-valid", + inputPayload: JSON.stringify({ initialPlan: "Restore me", taskCreationEpoch: 2, createdTaskIds: ["FN-1", 42, "FN-2"] }), + }, + "row-malformed": { + ...baseRow, + id: "row-malformed", + inputPayload: JSON.stringify({ initialPlan: "Restore me", taskCreationEpoch: -3, createdTaskIds: "not-an-array" }), + }, + }; + setAiSessionStore(Object.assign(new EventEmitter(), { + upsert: vi.fn(async () => {}), + get: vi.fn(async (id: string) => rows[id] ?? null), + updateThinking: vi.fn(), + }) as never); + + const valid = (await getSession("row-valid"))!; + expect(valid.taskCreationEpoch).toBe(2); + expect(valid.createdTaskIds).toEqual(["FN-1", "FN-2"]); + + const malformed = (await getSession("row-malformed"))!; + expect(malformed.taskCreationEpoch).toBeUndefined(); + expect(malformed.createdTaskIds).toBeUndefined(); + }); + it("contextual comments with no summary still apply via the rebuilt running summary", async () => { const { sessionId } = await startSessionAwaitingInput("10.2.0.3"); const session = (await getSession(sessionId))!; diff --git a/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts b/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts index 8b1567f468..00c635a6a5 100644 --- a/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts @@ -43,6 +43,9 @@ vi.mock("../planning.js", () => ({ releasePlanningTaskCreation: vi.fn(async () => undefined), // FNXC:PlanningMode 2026-07-23-12:10: create-task terminalizes the session after creation. validateSession: vi.fn(async () => undefined), + // FNXC:PlanningMultiTask 2026-07-24-00:20: create-task derives an epoch-scoped proposalClaimId. + planningProposalClaimId: (sessionId: string, epoch?: number) => + epoch && epoch > 0 ? `planning-session:${sessionId}#${epoch}` : `planning-session:${sessionId}`, })); function deferred() { diff --git a/packages/dashboard/src/__tests__/routes-planning.test.ts b/packages/dashboard/src/__tests__/routes-planning.test.ts index e36d66823b..e61172fbb7 100644 --- a/packages/dashboard/src/__tests__/routes-planning.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning.test.ts @@ -2915,6 +2915,58 @@ describe("Planning Mode Routes", () => { }); }); + /* + FNXC:PlanningMultiTask 2026-07-24-01:40: + Review finding: creating a task while a planning turn is still generating raced the + turn-completion persist against finalize and could tear the created-task linkage. The + route now rejects with 409 while the durable session status is "generating". + */ + it("rejects create-task with 409 while the session is still generating", async () => { + const sessionId = "planning-generating-409"; + const generatingRow = { + id: sessionId, + type: "planning", + status: "generating", + title: "Still generating", + inputPayload: JSON.stringify({ initialPlan: "Build a thing" }), + conversationHistory: "[]", + currentQuestion: null, + result: JSON.stringify({ + title: "Draft plan", + description: "Mid-turn running plan", + suggestedSize: "M", + suggestedDependencies: [], + keyDeliverables: ["Implementation"], + }), + thinkingOutput: "", + error: null, + projectId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + const mockAiSessionStore = { + on: vi.fn(), + upsert: vi.fn(), + get: vi.fn(async () => generatingRow), + listAll: vi.fn(() => []), + listActive: vi.fn(() => []), + }; + const appWithAiSessionStore = express(); + appWithAiSessionStore.use(express.json()); + appWithAiSessionStore.use("/api", createApiRoutes(store, { aiSessionStore: mockAiSessionStore as any })); + + const res = await REQUEST( + appWithAiSessionStore, + "POST", + "/api/planning/create-task", + JSON.stringify({ sessionId }), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(409); + expect(store.createTask).not.toHaveBeenCalled(); + }); + it.each([ { sessionSource: "live", diff --git a/packages/dashboard/src/ai-session-store.ts b/packages/dashboard/src/ai-session-store.ts index f2bcf62169..032375556e 100644 --- a/packages/dashboard/src/ai-session-store.ts +++ b/packages/dashboard/src/ai-session-store.ts @@ -168,8 +168,9 @@ export class AiSessionStore extends EventEmitter { return row; } - async reconcilePlanningTaskCreation(sessionId: string, taskId: string): Promise { - const row = await reconcilePlanningSessionTaskCreation(this.dbAsync, sessionId, taskId) as AiSessionRow | null; + // FNXC:PlanningMultiTask 2026-07-24-01:40: expectedTaskCreationEpoch guards reconcile against a concurrent epoch rotation — see core reconcilePlanningSessionTaskCreation. + async reconcilePlanningTaskCreation(sessionId: string, taskId: string, expectedTaskCreationEpoch?: number): Promise { + const row = await reconcilePlanningSessionTaskCreation(this.dbAsync, sessionId, taskId, expectedTaskCreationEpoch) as AiSessionRow | null; if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt)); return row; } diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index 49ad65ee58..e540b7f695 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -307,6 +307,8 @@ export interface DraftInputPayload { createClaimStatus?: "none" | "creating" | "created"; claimOwnerToken?: string; claimStartedAt?: string; + taskCreationEpoch?: number; + createdTaskIds?: string[]; } /** Session TTL in milliseconds (7 days) */ @@ -395,6 +397,16 @@ interface Session { createClaimStatus?: "none" | "creating" | "created"; claimOwnerToken?: string; claimStartedAt?: string; + /* + FNXC:PlanningMultiTask 2026-07-24-00:20: + One plan may produce multiple tasks. Each creation attempt belongs to an epoch: epoch 0 uses + the legacy `planning-session:{id}` proposalClaimId, epoch N uses `planning-session:{id}#N`. + Replaying Proceed without editing stays idempotent within the current epoch (same task, + alreadyCreated); editing the plan after a task exists ROTATES the epoch so the next Proceed + creates a fresh task. createdTaskIds records every task created from this plan. + */ + taskCreationEpoch?: number; + createdTaskIds?: string[]; /** Whether the current generation must end at plan review rather than a question. */ generationPurpose?: "initial_plan" | "plan_update" | "question"; /** Durable start time for the active turn so each concurrent session owns its elapsed clock. */ @@ -734,6 +746,8 @@ function persistSession(session: Session, status: "generating" | "awaiting_input ...(session.createClaimStatus ? { createClaimStatus: session.createClaimStatus } : {}), ...(session.claimOwnerToken ? { claimOwnerToken: session.claimOwnerToken } : {}), ...(session.claimStartedAt ? { claimStartedAt: session.claimStartedAt } : {}), + ...(session.taskCreationEpoch ? { taskCreationEpoch: session.taskCreationEpoch } : {}), + ...(session.createdTaskIds?.length ? { createdTaskIds: session.createdTaskIds } : {}), ...(typeof session.clarificationEnabled === "boolean" ? { clarificationEnabled: session.clarificationEnabled } : {}), @@ -909,6 +923,12 @@ function buildSessionFromRow(row: AiSessionRow): Session { validated: payload.validated === true, createdTaskId: typeof payload.createdTaskId === "string" ? payload.createdTaskId : undefined, createClaimStatus: payload.createClaimStatus, + taskCreationEpoch: typeof payload.taskCreationEpoch === "number" && Number.isInteger(payload.taskCreationEpoch) && payload.taskCreationEpoch > 0 + ? payload.taskCreationEpoch + : undefined, + createdTaskIds: Array.isArray(payload.createdTaskIds) + ? payload.createdTaskIds.filter((id): id is string => typeof id === "string") + : undefined, claimOwnerToken: typeof payload.claimOwnerToken === "string" ? payload.claimOwnerToken : undefined, claimStartedAt: typeof payload.claimStartedAt === "string" ? payload.claimStartedAt : undefined, thinkingOutput: row.thinkingOutput, @@ -3152,20 +3172,6 @@ export async function submitResponse( throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`); } - /* - FNXC:PlanningReopenAfterValidate 2026-07-23-23:30: - A validated plan must never be a read-only dead end: the operator can keep refining, - commenting, or answering, and create the task whenever they choose. A new turn on a - validated session REOPENS it (clears the terminal marker; the turn's own - persistSession("generating") durably writes validated:false and moves the row out of - "complete"), rather than rejecting with "already been validated". validateSession remains - the only terminalizer, and a session whose task already exists keeps its one-task claim - (proposalClaimId is never rotated), so Proceed after re-editing returns the linked task. - */ - if (session.validated) { - session.validated = false; - } - // Stash store/rootDir on the session so subsequent ensureSessionAgent calls // (after the agent is disposed for retry/rewind) can rebuild without the // caller having to thread context through every API. @@ -3181,6 +3187,28 @@ export async function submitResponse( } const releaseTurn = reservePlanningTurn(session.id); + /* + FNXC:PlanningReopenAfterValidate 2026-07-23-23:30: + A validated plan must never be a read-only dead end: the operator can keep refining, + commenting, or answering, and create the task whenever they choose. A new turn on a + validated session REOPENS it (clears the terminal marker; the turn's own + persistSession("generating") durably writes validated:false and moves the row out of + "complete"), rather than rejecting with "already been validated". validateSession remains + the only terminalizer. + + FNXC:PlanningMultiTask 2026-07-24-01:40: + Reopen (validated flip) and epoch rotation run only AFTER turn admission succeeds. Review + finding (3 reviewers): mutating the shared in-memory session before the admission guards + meant a rejected request (GenerationInProgressError, duplicate submit) burned a phantom + rotation that was never persisted — in-memory epoch N+1 vs durable N — and a later Proceed + could derive the wrong claim key. Past admission, every branch reaches + persistSession("generating"), so the rotation always lands durably with its turn. + */ + if (session.validated) { + session.validated = false; + } + rotateTaskCreationEpochOnReopen(session); + /* FNXC:PlanningRetry 2026-07-14-00:00: Reported bug: planning got stuck cycling retry/regeneration after the user had already answered. @@ -3458,11 +3486,6 @@ export async function rewindSession( throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`); } - // FNXC:PlanningReopenAfterValidate 2026-07-23-23:30: editing an earlier answer reopens a validated plan — see submitResponse. - if (session.validated) { - session.validated = false; - } - if (store && !session.store) session.store = store; if (rootDir && !session.rootDir) session.rootDir = rootDir; @@ -3497,6 +3520,15 @@ export async function rewindSession( } const releaseTurn = reservePlanningTurn(session.id); + /* + FNXC:PlanningReopenAfterValidate 2026-07-23-23:30: editing an earlier answer reopens a validated plan — see submitResponse. + FNXC:PlanningMultiTask 2026-07-24-01:40: reopen + rotation only after admission and the empty-history precondition, so a rejected rewind never mutates claim state (see submitResponse). + */ + if (session.validated) { + session.validated = false; + } + rotateTaskCreationEpochOnReopen(session); + try { /* FNXC:PlanningTurnAdmission 2026-07-23-10:10: @@ -3876,6 +3908,48 @@ function restoreClaimSession(row: import("./ai-session-store.js").AiSessionRow): return restored; } +/* +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. +Epoch 0 keeps the legacy un-suffixed key so pre-existing linked sessions stay reconciled. +*/ +export function planningProposalClaimId(sessionId: string, taskCreationEpoch?: number): string { + const epoch = taskCreationEpoch ?? 0; + return epoch > 0 ? `planning-session:${sessionId}#${epoch}` : `planning-session:${sessionId}`; +} + +/* +Reopen-time rotation: a plan edited after its current epoch created a task starts a new epoch. + +FNXC:PlanningMultiTask 2026-07-24-01:40: +Rotation is rotate-on-intent: it commits with the edit turn's persistSession("generating"), +so an edit turn that later FAILS still leaves the epoch advanced — a subsequent Proceed on +the unchanged plan then creates a second task with identical content. Accepted trade-off +(review finding): the alternative (rotate only on turn success) would let a failed edit +silently re-link the edited plan to the pre-edit task, which is worse. The guard below also +deliberately requires finalize's "created" status or a set createdTaskId, so a crash between +claim ("creating") and finalize can never rotate away from the epoch whose task row needs +reconciliation. +*/ +function rotateTaskCreationEpochOnReopen(session: Session): void { + const currentEpochHasTask = Boolean(session.createdTaskId) || session.createClaimStatus === "created"; + if (!currentEpochHasTask) return; + if (session.createdTaskId) { + const history = session.createdTaskIds ?? []; + if (!history.includes(session.createdTaskId)) { + session.createdTaskIds = [...history, session.createdTaskId]; + } + } + session.taskCreationEpoch = (session.taskCreationEpoch ?? 0) + 1; + session.createdTaskId = undefined; + session.createClaimStatus = "none"; + session.claimOwnerToken = undefined; + session.claimStartedAt = undefined; +} + /** Read durable claim state rather than trusting a process-local session cache. */ export async function getDurablePlanningSession(sessionId: string): Promise { if (!_aiSessionStore) return getSession(sessionId); @@ -3906,13 +3980,16 @@ export async function finalizePlanningTaskCreation(sessionId: string, ownerToken return row ? restoreClaimSession(row) : undefined; } -export async function reconcilePlanningTaskCreation(sessionId: string, taskId: string): Promise { +// FNXC:PlanningMultiTask 2026-07-24-01:40: expectedTaskCreationEpoch makes reconcile a no-op when the plan was edited (epoch rotated) since the task's claim key was derived — never re-link an archived task to the new epoch. +export async function reconcilePlanningTaskCreation(sessionId: string, taskId: string, expectedTaskCreationEpoch?: number): Promise { if (!_aiSessionStore || typeof (_aiSessionStore as unknown as { reconcilePlanningTaskCreation?: unknown }).reconcilePlanningTaskCreation !== "function") { const session = await getSession(sessionId); - if (session) Object.assign(session, { createClaimStatus: "created", createdTaskId: taskId, claimOwnerToken: undefined, claimStartedAt: undefined }); + if (session && (expectedTaskCreationEpoch === undefined || (session.taskCreationEpoch ?? 0) === expectedTaskCreationEpoch)) { + Object.assign(session, { createClaimStatus: "created", createdTaskId: taskId, claimOwnerToken: undefined, claimStartedAt: undefined }); + } return session; } - const row = await _aiSessionStore.reconcilePlanningTaskCreation(sessionId, taskId); + const row = await _aiSessionStore.reconcilePlanningTaskCreation(sessionId, taskId, expectedTaskCreationEpoch); return row ? restoreClaimSession(row) : undefined; } diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index 155c6f654f..f6a4d822c2 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -1188,6 +1188,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann reconcilePlanningTaskCreation, releasePlanningTaskCreation, validateSession, + planningProposalClaimId, } = await import("../planning.js"); let session = await getSession(sessionId); @@ -1245,27 +1246,75 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann } releaseCreateLock = await acquirePlanningCreateLock(sessionId); - // Re-read after the local single-flight queue: an earlier caller may have finalized while we waited. - session = await getSession(sessionId); + /* + FNXC:PlanningMultiTask 2026-07-24-01:40: + Creating a task while a planning turn is still generating raced the turn's full-row + persistSession against finalize: the turn-completion write could clobber the fresh + createdTaskId linkage, which then disabled the next epoch rotation (review finding). + The durable status is the cross-process signal, so a generating session gets a clean + 409 instead of a torn linkage; the client retries after the turn settles. + */ + if (aiSessionStore) { + // `await` tolerates sync-returning adapter stores; a failed read must not block creation. + let liveRow: { type?: string; status?: string } | null = null; + try { + liveRow = (await aiSessionStore.get(sessionId)) as { type?: string; status?: string } | null; + } catch { + liveRow = null; + } + if (liveRow?.type === "planning" && liveRow.status === "generating") { + throw conflict("Plan is still generating — wait for the current turn to finish, then create the task."); + } + } + // Re-read after the local single-flight queue: an earlier caller may have finalized while + // we waited. Durable-first so another process's epoch rotation (plan edited after a task + // was created) is honored when deriving this attempt's claim key; fall back to the + // in-memory read for adapters/rows the strict durable restore rejects. + try { + session = (await getDurablePlanningSession(sessionId)) ?? await getSession(sessionId); + } catch (durableReadError) { + /* + FNXC:PlanningMultiTask 2026-07-24-01:40: + The fallback must be loud: deriving the claim key from a stale in-memory epoch after a + silent durable-read failure can replay a prior epoch's task as alreadyCreated (bounded + degradation — never a fork, since rotation implies that epoch's task row exists). + */ + logPlanningCreateWarning( + "Planning create-task durable session read failed; falling back to in-memory session for claim-key derivation", + durableReadError, + { sessionId }, + ); + session = await getSession(sessionId); + } /* FNXC:PlanningMode 2026-07-20-15:45: - FN-8442 derives the never-rotated key `planning-session:${sessionId}` at task creation. - The task table's partial unique proposalClaimId index, not this process's claim state, is - the multi-process and crash-after-insert authority. A session linkage is a durable cache - reconciled from that key; a missing linked task fails closed rather than silently forking. + FN-8442: the task table's partial unique proposalClaimId index, not this process's claim + state, is the multi-process and crash-after-insert authority. A session linkage is a + 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. */ - const proposalClaimId = `planning-session:${sessionId}`; + const claimEpoch = session?.taskCreationEpoch ?? 0; + const proposalClaimId = planningProposalClaimId(sessionId, claimEpoch); const findCreatedTask = async () => (await scopedStore.listTasks({ includeArchived: true })).find((candidate) => candidate.proposalClaimId === proposalClaimId); /* - FNXC:PlanningMode 2026-07-23-12:10: - A planning session whose task exists is done: the claim model allows exactly one task per - session, so after creation the session must stop advertising awaiting_input in the session - list/banner. The Proceed-with-plan flow calls this route without the legacy /validate step, - so terminalize here through validateSession (the sole terminal transition) on every path - that ends with a created task, including alreadyCreated reconciliation. Best-effort: a - failure to terminalize must not fail the task creation itself. + 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 + 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 + the un-suffixed key and replay epoch 0's task instead of creating a new one (bounded + degradation, no duplicate). */ const markSessionComplete = () => runPlanningCreateSideEffect( @@ -1288,7 +1337,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann // A task row is the crash-window authority. Reconcile it before trying to claim. const existingTask = await findCreatedTask(); if (existingTask) { - await reconcilePlanningTaskCreation(sessionId, existingTask.id); + await reconcilePlanningTaskCreation(sessionId, existingTask.id, claimEpoch); await markSessionComplete(); res.status(200).json({ task: existingTask, alreadyCreated: true }); return; @@ -1315,7 +1364,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann session = await getDurablePlanningSession(sessionId) ?? session; const recoveredTask = await findCreatedTask(); if (recoveredTask) { - await reconcilePlanningTaskCreation(sessionId, recoveredTask.id); + await reconcilePlanningTaskCreation(sessionId, recoveredTask.id, claimEpoch); await markSessionComplete(); res.status(200).json({ task: recoveredTask, alreadyCreated: true }); return;