diff --git a/.changeset/planning-multi-task-per-plan.md b/.changeset/planning-multi-task-per-plan.md index 4793b27d85..80489a554b 100644 --- a/.changeset/planning-multi-task-per-plan.md +++ b/.changeset/planning-multi-task-per-plan.md @@ -2,6 +2,6 @@ "@runfusion/fusion": minor --- -summary: One plan can now create multiple tasks — keep refining after a task is created and Proceed again. +summary: One plan can now create multiple tasks — in the dashboard, the CLI, and agent tools alike. 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. +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. `fn task plan` / `fn_task_plan` now create through the shared claim-aware `createTaskFromPlanSession` (idempotent, session-linked, epoch-aware) and gain `--resume ` / `resumeSessionId` plus an interactive keep-refining loop. diff --git a/packages/cli/skill/fusion/references/extension-tools.md b/packages/cli/skill/fusion/references/extension-tools.md index d0b8387018..c18bfd1d32 100644 --- a/packages/cli/skill/fusion/references/extension-tools.md +++ b/packages/cli/skill/fusion/references/extension-tools.md @@ -211,7 +211,7 @@ Import GitLab project merge requests as Fusion review tasks using configured Git ### fn_task_plan -Create a task via AI-guided planning mode — interactive conversation to refine your idea into a well-specified task. +Create a task via AI-guided planning mode — interactive conversation to refine your idea into a well-specified task. Pass resumeSessionId to reopen an existing planning session (even one whose task was already created) and create another task from the evolved plan. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| diff --git a/packages/cli/skill/fusion/references/fusion-capabilities.md b/packages/cli/skill/fusion/references/fusion-capabilities.md index 9cb638c5eb..8d71be62c0 100644 --- a/packages/cli/skill/fusion/references/fusion-capabilities.md +++ b/packages/cli/skill/fusion/references/fusion-capabilities.md @@ -45,7 +45,7 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names | `fn_task_import_gitlab_group_issues` | Import GitLab group issues as Fusion tasks using each issue's originating project identity. | | `fn_task_browse_gitlab_merge_requests` | List GitLab project merge requests from the configured GitLab instance. | | `fn_task_import_gitlab_merge_requests` | Import GitLab project merge requests as Fusion review tasks using configured GitLab HTTP API auth. | -| `fn_task_plan` | Create a task via AI-guided planning mode — interactive conversation to refine your idea into a well-specified task. | +| `fn_task_plan` | Create a task via AI-guided planning mode — interactive conversation to refine your idea into a well-specified task. Pass resumeSessionId to reopen an existing planning session (even one whose task was already created) and create another task from the evolved plan. | | `fn_web_fetch` | Lightweight URL fetch (no JS rendering). Use agent-browser skill for JS-heavy pages. URL to fetch (http/https) Optional extraction hint for downstream summarization Timeout in milliseconds (default: 30000) Max bytes to return (default: 512000) | | `fn_secret_get` | Read a secret by key using per-secret access policy. | | `fn_experiment_finalize` | Group kept experiment runs into reviewable branches and finalize the session. Use dryRun=true to preview the plan without touching git. | diff --git a/packages/cli/src/__tests__/task-plan.test.ts b/packages/cli/src/__tests__/task-plan.test.ts index 1c5efc0484..8d537d8827 100644 --- a/packages/cli/src/__tests__/task-plan.test.ts +++ b/packages/cli/src/__tests__/task-plan.test.ts @@ -28,6 +28,23 @@ vi.mock("../project-context.js", () => ({ vi.mock("@fusion/dashboard/planning", () => ({ createSession: vi.fn(), submitResponse: vi.fn(), + validateSession: vi.fn(), + getSession: vi.fn(), + /* + FNXC:PlanningMultiTask 2026-07-24-02:30: + The CLI now creates through the claim-aware shared path (idempotency + session linkage + + epoch awareness) instead of a raw store.createTask. + */ + createTaskFromPlanSession: vi.fn(async () => ({ + task: { + id: "FN-042", + title: "Planned Task", + description: "A well-planned task", + column: "triage", + dependencies: ["FN-001"], + }, + alreadyCreated: false, + })), RateLimitError: class RateLimitError extends Error { constructor(message: string) { super(message); @@ -50,7 +67,7 @@ vi.mock("@fusion/dashboard/planning", () => ({ // Import after mocking import { createInterface } from "node:readline/promises"; -import { createSession, submitResponse, RateLimitError, SessionNotFoundError } from "@fusion/dashboard/planning"; +import { createSession, createTaskFromPlanSession, getSession, submitResponse, RateLimitError, SessionNotFoundError } from "@fusion/dashboard/planning"; import { runTaskPlan } from "../commands/task.js"; describe("runTaskPlan", () => { @@ -415,13 +432,13 @@ describe("runTaskPlan", () => { const taskId = await runTaskPlan("Build something", true); expect(taskId).toBe("FN-042"); - expect(mockCreateTask).toHaveBeenCalledWith({ - title: "Planned Task", - description: "A well-planned task", - column: "triage", - dependencies: ["FN-001"], - source: { sourceType: "cli" }, - }); + // FNXC:PlanningMultiTask 2026-07-24-02:30: creation must flow through the claim-aware shared path, never a raw store.createTask (no idempotency/linkage). + expect(createTaskFromPlanSession).toHaveBeenCalledWith( + "test-session-123", + expect.anything(), + { baseBranch: undefined }, + ); + expect(mockCreateTask).not.toHaveBeenCalled(); }); it("prompts for confirmation without --yes flag", async () => { @@ -450,7 +467,8 @@ describe("runTaskPlan", () => { mockQuestion .mockResolvedValueOnce("y") - .mockResolvedValueOnce("y"); + .mockResolvedValueOnce("y") + .mockResolvedValueOnce("n"); const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("Process.exit called"); @@ -462,11 +480,48 @@ describe("runTaskPlan", () => { // expected } - expect(mockQuestion).toHaveBeenLastCalledWith(" Create this task? [Y/n]: "); + expect(mockQuestion).toHaveBeenCalledWith(" Create this task? [Y/n]: "); + // FNXC:PlanningMultiTask 2026-07-24-02:30: after creation the interactive flow offers to keep refining for another task. + expect(mockQuestion).toHaveBeenLastCalledWith(" Keep refining this plan to create another task? [y/N]: "); exitSpy.mockRestore(); }); + /* + FNXC:PlanningMultiTask 2026-07-24-02:30: + Agent/CLI resume parity: --resume reopens an existing session without creating a new one, + regenerating a question when none is awaiting input, and creates through the claim-aware path. + */ + it("resumes an existing session with --resume and creates through the claim-aware path", async () => { + setupTaskStoreMock(); + + (getSession as unknown as ReturnType).mockResolvedValueOnce({ + id: "resume-session-9", + currentQuestion: null, + summary: { title: "Existing plan", description: "d", suggestedSize: "M", suggestedDependencies: [], keyDeliverables: [] }, + }); + (submitResponse as unknown as ReturnType) + .mockResolvedValueOnce({ + type: "question", + data: { id: "q-resumed", type: "text", question: "What changed?", description: "" }, + }) + .mockResolvedValueOnce({ + type: "complete", + data: { title: "Second task", description: "d2", suggestedSize: "S", suggestedDependencies: [], keyDeliverables: ["X"] }, + }); + mockQuestion + .mockResolvedValueOnce("tighten the scope") + .mockResolvedValueOnce("DONE"); + + const taskId = await runTaskPlan(undefined, true, undefined, undefined, "resume-session-9"); + + expect(createSession).not.toHaveBeenCalled(); + // The no-question resume issues a refine turn to regenerate the interview. + expect(submitResponse).toHaveBeenNthCalledWith(1, "resume-session-9", { refine: true }, "/test/project", undefined, expect.anything()); + expect(createTaskFromPlanSession).toHaveBeenCalledWith("resume-session-9", expect.anything(), { baseBranch: undefined }); + expect(taskId).toBe("FN-042"); + }); + it("handles RateLimitError with proper message", async () => { setupTaskStoreMock(); @@ -575,6 +630,7 @@ describe("runTaskPlan", () => { expect(taskId).toBeUndefined(); expect(mockCreateTask).not.toHaveBeenCalled(); + expect(createTaskFromPlanSession).not.toHaveBeenCalled(); expect(mockConsoleLog).toHaveBeenCalledWith( expect.stringContaining("Task creation cancelled") ); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index b898dd3d6f..a00b5930e8 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -322,7 +322,7 @@ Usage: Update Fusion on the selected release channel fn upgrade Alias for fn update fn task create [desc] [opts] Create a new task (goes to triage; supports --node , --no-dedup) - fn task plan [description] [opts] Create task via AI-guided planning + fn task plan [description] [opts] Create task via AI-guided planning (--resume continues a plan to create another task) fn task list List all tasks fn task show Show task details, steps, log fn task logs [--follow] [--limit ] [--type ] @@ -1280,6 +1280,8 @@ async function main() { const planArgs = args.slice(2); const yesFlag = planArgs.includes("--yes"); let baseBranch: string | undefined; + // FNXC:PlanningMultiTask 2026-07-24-02:30: --resume reopens an existing planning session (even a validated one whose task exists) to keep refining and create another task. + let resumeSessionId: string | undefined; const descParts: string[] = []; for (let i = 0; i < planArgs.length; i++) { if (planArgs[i] === "--yes") { @@ -1287,12 +1289,15 @@ async function main() { } else if (planArgs[i] === "--base-branch" && i + 1 < planArgs.length) { baseBranch = planArgs[i + 1]; i++; + } else if (planArgs[i] === "--resume" && i + 1 < planArgs.length) { + resumeSessionId = planArgs[i + 1]; + i++; } else { descParts.push(planArgs[i]); } } const initialPlan = descParts.join(" "); - await runTaskPlan(initialPlan || undefined, yesFlag, projectName, baseBranch); + await runTaskPlan(initialPlan || undefined, yesFlag, projectName, baseBranch, resumeSessionId); break; } case "list": diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index e7b6b16ae9..92995d90fc 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -2,7 +2,7 @@ import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatc import { isInReviewMissingWorktreeSessionStartFailure, runAiMerge, landWorkspaceTask, installBaselineArchiveWorktreeDisposer } from "@fusion/engine"; import { createInterface } from "node:readline/promises"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; -import { createSession, submitResponse, validateSession, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning"; +import { createSession, createTaskFromPlanSession, getSession as getPlanningSession, submitResponse, validateSession, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning"; import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs"; import { basename, join } from "node:path"; import * as dashboard from "@fusion/dashboard"; @@ -2174,11 +2174,12 @@ export async function runTaskPlan( yesFlag = false, projectName?: string, baseBranch?: string, + resumeSessionId?: string, ): Promise { let initialPlan = initialPlanArg; // If no initial plan, prompt interactively - if (!initialPlan) { + if (!initialPlan && !resumeSessionId) { const rl = createInterface({ input: process.stdin, output: process.stdout }); console.log("\n Let's plan your task. What would you like to accomplish?\n"); initialPlan = await rl.question(" Describe your idea: "); @@ -2202,17 +2203,49 @@ export async function runTaskPlan( const context = await resolveBoardContext(projectName, "plan", "resolve project"); const store = context.store; - // Create planning session + // Create (or resume) the planning session let sessionId: string; let firstQuestion: PlanningQuestion; try { showThinking(); const projectPath = context.projectPath; - const result = await createSession("127.0.0.1", initialPlan.trim(), store, projectPath); - clearThinking(); - sessionId = result.sessionId; - firstQuestion = result.firstQuestion; + if (resumeSessionId) { + /* + FNXC:PlanningMultiTask 2026-07-24-02:30: + Agent/CLI parity with the dashboard's reopen loop: resuming an existing session (even a + validated one that already created a task) continues the interview. When no question is + awaiting input, a refine turn regenerates one — the server reopens the session and + rotates the creation epoch when its current epoch already produced a task, so a later + /validate creates a NEW task instead of replaying the old one. + */ + const existing = await getPlanningSession(resumeSessionId); + if (!existing) { + clearThinking(); + console.error(`\n Planning session ${resumeSessionId} not found or expired.\n`); + await closeBoardContextAndExit(context, 1); + return undefined; + } + sessionId = resumeSessionId; + if (existing.currentQuestion) { + firstQuestion = existing.currentQuestion; + } else { + const regenerated = await submitResponse(sessionId, { refine: true }, projectPath, undefined, store); + if (regenerated.type !== "question") { + clearThinking(); + console.error("\n Could not resume the interview for this session.\n"); + await closeBoardContextAndExit(context, 1); + return undefined; + } + firstQuestion = regenerated.data; + } + clearThinking(); + } else { + const result = await createSession("127.0.0.1", initialPlan!.trim(), store, projectPath); + clearThinking(); + sessionId = result.sessionId; + firstQuestion = result.firstQuestion; + } } catch (err) { clearThinking(); @@ -2227,6 +2260,9 @@ export async function runTaskPlan( return undefined; } + // FNXC:PlanningMultiTask 2026-07-24-02:30: surface the session id so agents/operators can resume this plan later (`fn task plan --resume ` / fn_task_plan resumeSessionId) to create further tasks. + console.log(`\n Planning session: ${sessionId}`); + // Interactive Q&A loop let currentQuestion = firstQuestion; let cancelled = false; @@ -2334,27 +2370,54 @@ export async function runTaskPlan( } if (confirmed) { - // Create the task — the ONE discrete board write in this flow, - // retried independently (FN-7734). - // FN-5060: intentional same-content sibling; deterministic guard skipped here. - const task = await retryBoardCall(context, "plan", "create task", () => store.createTask({ - title: result.data.title, - description: result.data.description, - column: "triage", - dependencies: result.data.suggestedDependencies, - baseBranch: baseBranch?.trim() || undefined, - source: { sourceType: "cli" }, - })); + /* + FNXC:PlanningMultiTask 2026-07-24-02:30: + Review finding (P1 agent-native parity): the CLI/agent surface used to call + store.createTask directly with no proposalClaimId — no idempotency, no session + linkage, tasks outside the epoch sequence. It now creates through the same + claim-aware path as the dashboard (epoch-derived key, claim CAS lifecycle, + validate-on-create), which also makes the retryBoardCall wrapper safe: a retried + call reconciles the already-inserted task instead of duplicating it (FN-7734). + */ + const { task, alreadyCreated } = await retryBoardCall(context, "plan", "create task", () => + createTaskFromPlanSession(sessionId, store, { baseBranch: baseBranch?.trim() || undefined })); console.log(); - console.log(` ✓ Created ${task.id}: ${task.title || task.description.slice(0, 60)}${task.description.length > 60 ? "…" : ""}`); - console.log(` Column: triage`); + console.log(` ${alreadyCreated ? "✓ Task already created from this plan:" : "✓ Created"} ${task.id}: ${task.title || task.description.slice(0, 60)}${task.description.length > 60 ? "…" : ""}`); + console.log(` Column: ${task.column ?? "triage"}`); if (task.dependencies.length > 0) { console.log(` Dependencies: ${task.dependencies.join(", ")}`); } console.log(` Path: .fusion/tasks/${task.id}/`); console.log(); + /* + FNXC:PlanningMultiTask 2026-07-24-02:30: + Task creation is not the end of the plan (dashboard parity): the operator can keep + refining and create another task — the refine turn reopens the session and rotates + the creation epoch server-side. Non-interactive callers (--yes / fn_task_plan) stop + after one task and can continue later via `fn task plan --resume `. + */ + if (!yesFlag) { + const rlContinue = createInterface({ input: process.stdin, output: process.stdout }); + const continueAnswer = await rlContinue.question(" Keep refining this plan to create another task? [y/N]: "); + const wantsMore = ["y", "yes"].includes(continueAnswer.trim().toLowerCase()); + if (!wantsMore) { + rlContinue.close(); + return task.id; + } + const focus = (await rlContinue.question(" What should the next refinement focus on? ")).trim(); + rlContinue.close(); + showThinking(); + const refined = await submitResponse(sessionId, { refine: true, ...(focus ? { focus } : {}) }, context.projectPath, undefined, store); + clearThinking(); + if (refined.type === "question") { + currentQuestion = refined.data; + continue; + } + console.log("\n Could not continue the interview; the created task is ready.\n"); + } + return task.id; } diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 5f22b940d9..166a8985a8 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -2567,11 +2567,12 @@ export default function kbExtension(pi: ExtensionAPI) { name: "fn_task_plan", label: "fn: Plan Task", description: - "Create a task via AI-guided planning mode — interactive conversation to refine your idea into a well-specified task.", + "Create a task via AI-guided planning mode — interactive conversation to refine your idea into a well-specified task. Pass resumeSessionId to reopen an existing planning session (even one whose task was already created) and create another task from the evolved plan.", promptSnippet: "Create a task via AI-guided planning mode", promptGuidelines: [ "Use for breaking down vague ideas into actionable tasks", "The AI will ask clarifying questions before creating the task", + "One plan can produce multiple tasks: resume the session with resumeSessionId to refine further and create another", ], parameters: Type.Object({ description: Type.Optional( @@ -2580,6 +2581,8 @@ export default function kbExtension(pi: ExtensionAPI) { }) ), baseBranch: Type.Optional(Type.String({ description: "Optional base branch for the task created from this planning session" })), + // FNXC:PlanningMultiTask 2026-07-24-02:30: agent parity with the dashboard's reopen loop — resuming rotates the creation epoch when the plan already produced a task. + resumeSessionId: Type.Optional(Type.String({ description: "Existing planning session id to resume instead of starting a new session" })), }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { @@ -2604,7 +2607,7 @@ export default function kbExtension(pi: ExtensionAPI) { let taskId: string | undefined; try { - taskId = await runTaskPlan(params.description, true, undefined, params.baseBranch); // Use --yes flag for non-interactive + taskId = await runTaskPlan(params.description, true, undefined, params.baseBranch, params.resumeSessionId); // Use --yes flag for non-interactive } catch (err) { console.error = originalError; console.log = originalLog; diff --git a/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts b/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts index d2103a205f..699d912e9b 100644 --- a/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts +++ b/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts @@ -39,6 +39,7 @@ import { __resetPlanningState, __setCreateFnAgent, createSessionWithAgent, + createTaskFromPlanSession, getSession, InvalidSessionStateError, planningProposalClaimId, @@ -227,6 +228,60 @@ describe("planning question regeneration instead of no-active-question errors", expect(session.validated).toBe(true); }); + /* + FNXC:PlanningMultiTask 2026-07-24-02:30: + Agent-surface twin of the create-task route: createTaskFromPlanSession must be claim-aware + (proposalClaimId recorded on the task), idempotent on replay, and epoch-aware after the plan + is edited past a created task (review finding: the CLI previously bypassed all of this). + */ + it("createTaskFromPlanSession is claim-aware, idempotent on replay, and epoch-aware after edits", async () => { + const { sessionId } = await startSessionAwaitingInput("10.2.0.13"); + + const tasks: Array<{ id: string; title: string; description: string; column: string; dependencies: string[]; proposalClaimId?: string }> = []; + const createTask = vi.fn(async (input: { title: string; description: string; dependencies?: string[]; proposalClaimId?: string }) => { + const task = { + id: `FN-CLI-${tasks.length + 1}`, + title: input.title, + description: input.description, + column: "triage", + dependencies: input.dependencies ?? [], + proposalClaimId: input.proposalClaimId, + }; + tasks.push(task); + return task; + }); + const taskStore = { + listTasks: vi.fn(async () => [...tasks]), + getTask: vi.fn(async (id: string) => { + const found = tasks.find((task) => task.id === id); + if (found) return found; + throw new Error("not found"); + }), + createTask, + } as unknown as TaskStore; + + const first = await createTaskFromPlanSession(sessionId, taskStore); + expect(first.alreadyCreated).toBe(false); + expect(createTask).toHaveBeenCalledTimes(1); + expect(createTask.mock.calls[0][0].proposalClaimId).toBe(`planning-session:${sessionId}`); + expect((await getSession(sessionId))?.validated).toBe(true); + + const replay = await createTaskFromPlanSession(sessionId, taskStore); + expect(replay.alreadyCreated).toBe(true); + expect(replay.task.id).toBe(first.task.id); + expect(createTask).toHaveBeenCalledTimes(1); + + // Editing the plan reopens the session and rotates the creation epoch. + const refined = await submitResponse(sessionId, { refine: true, focus: "split rollout" }, "/tmp/project", undefined, MOCK_TASK_STORE); + expect(refined.type).toBe("question"); + + const second = await createTaskFromPlanSession(sessionId, taskStore); + expect(second.alreadyCreated).toBe(false); + expect(second.task.id).not.toBe(first.task.id); + expect(createTask).toHaveBeenCalledTimes(2); + expect(createTask.mock.calls[1][0].proposalClaimId).toBe(`planning-session:${sessionId}#1`); + }); + /* FNXC:PlanningMultiTask 2026-07-24-01:40: Durable round-trip of the new epoch fields through buildSessionFromRow's normalization diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index e540b7f695..0fb8a9e4ec 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -27,9 +27,11 @@ import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, THINKING_LEVELS, + formatPlanningPlanMd, summarizeTitle, type PromptOverrideMap, } from "@fusion/core"; +import type { Task } from "@fusion/core"; import type { SubtaskItem } from "./subtask-breakdown.js"; import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; @@ -3950,6 +3952,110 @@ function rotateTaskCreationEpochOnReopen(session: Session): void { session.claimStartedAt = undefined; } +/* +FNXC:PlanningMultiTask 2026-07-24-02:30: +Agent-surface twin of POST /planning/create-task (review finding: fn task plan / fn_task_plan +created tasks via a raw store.createTask with no proposalClaimId, so agent-created tasks had +no idempotency, no session linkage, and lived outside the epoch sequence — a later dashboard +Proceed would create a duplicate). This function shares every invariant primitive with the +route (planningProposalClaimId, claim/finalize/reconcile/release CAS lifecycle including the +30s stale-lease takeover, formatPlanningPlanMd task shape, validate-on-create); only the HTTP +concerns (branch selection, workflow lane, GitHub tracking dispatch) stay route-only. The +route remains the dashboard authority — keep the two orchestrations semantically aligned. +*/ +export async function createTaskFromPlanSession( + sessionId: string, + store: TaskStore, + options?: { baseBranch?: string; sourceType?: "cli" | "api" }, +): Promise<{ task: Task; alreadyCreated: boolean }> { + let session = (await getDurablePlanningSession(sessionId).catch(() => undefined)) ?? await getSession(sessionId); + if (!session) throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`); + if (isPlanningTurnActive(sessionId) || planningStreamManager.hasPendingInitialTurn(sessionId)) { + throw new GenerationInProgressError("Plan is still generating — wait for the current turn to finish, then create the task."); + } + 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); + const findCreatedTask = async (): Promise => + (await store.listTasks({ includeArchived: true })).find((candidate) => candidate.proposalClaimId === proposalClaimId); + const markSessionComplete = async (): Promise => { + const current = await getSession(sessionId); + if (current && !current.validated) await validateSession(sessionId).catch(() => undefined); + }; + const returnExisting = async (task: Task): Promise<{ task: Task; alreadyCreated: true }> => { + await reconcilePlanningTaskCreation(sessionId, task.id, claimEpoch).catch(() => undefined); + await markSessionComplete(); + return { task, alreadyCreated: true }; + }; + + // The task row under this epoch's key is the crash-window authority. + const existingTask = await findCreatedTask(); + if (existingTask) return returnExisting(existingTask); + if (session.createdTaskId) { + const linked = await store.getTask(session.createdTaskId).catch(() => null); + if (linked) { + await markSessionComplete(); + return { task: linked, alreadyCreated: true }; + } + } + + const claimOwnerToken = randomUUID(); + let claimed = await claimPlanningTaskCreation(sessionId, claimOwnerToken, new Date().toISOString()); + if (!claimed) { + session = (await getDurablePlanningSession(sessionId).catch(() => undefined)) ?? session; + const recovered = await findCreatedTask(); + if (recovered) return returnExisting(recovered); + if (session.createdTaskId) { + const linked = await store.getTask(session.createdTaskId).catch(() => null); + if (linked) { + await markSessionComplete(); + return { task: linked, alreadyCreated: true }; + } + } + const startedAt = session.claimStartedAt ? Date.parse(session.claimStartedAt) : Number.NaN; + const leaseExpired = session.createClaimStatus === "creating" && Number.isFinite(startedAt) && Date.now() - startedAt >= 30_000; + if (!leaseExpired || !session.claimOwnerToken) { + throw new GenerationInProgressError("Planning task creation is already in progress"); + } + await releasePlanningTaskCreation(sessionId, session.claimOwnerToken); + claimed = await claimPlanningTaskCreation(sessionId, claimOwnerToken, new Date().toISOString()); + if (!claimed) throw new GenerationInProgressError("Planning task creation is already in progress"); + } + + try { + const planMd = formatPlanningPlanMd(summary); + const originalRequest = session.initialPlan?.trim() || summary.description.trim(); + const task = await store.createTask({ + title: summary.title, + description: planMd, + dependencies: summary.suggestedDependencies?.length ? summary.suggestedDependencies : undefined, + priority: isTaskPriority(summary.priority) ? summary.priority : DEFAULT_TASK_PRIORITY, + source: { sourceType: options?.sourceType ?? "cli" }, + ...(options?.baseBranch?.trim() ? { baseBranch: options.baseBranch.trim() } : {}), + proposalClaimId, + }); + if (summary.suggestedSize) { + await Promise.resolve(store.updateTask?.(task.id, { size: summary.suggestedSize })).catch(() => undefined); + } + await Promise.resolve(store.upsertTaskDocument?.(task.id, { key: "plan", content: planMd, author: "planning", metadata: { planningSessionId: sessionId, source: "planning-mode" } })).catch(() => undefined); + if (originalRequest) { + await Promise.resolve(store.upsertTaskDocument?.(task.id, { key: "original-description", content: originalRequest, author: "planning", metadata: { planningSessionId: sessionId, source: "planning-mode-initial-plan" } })).catch(() => undefined); + } + await Promise.resolve(store.logEntry?.(task.id, "Created via Planning Mode", `Initial plan: ${(session.initialPlan ?? "").slice(0, 200)}`)).catch(() => undefined); + await finalizePlanningTaskCreation(sessionId, claimOwnerToken, task.id); + await markSessionComplete(); + return { task, alreadyCreated: false }; + } catch (err) { + // A raced insert under the same key is the idempotent success case; anything else releases the claim. + const raced = await findCreatedTask().catch(() => undefined); + await releasePlanningTaskCreation(sessionId, claimOwnerToken).catch(() => undefined); + if (raced) return returnExisting(raced); + throw err; + } +} + /** Read durable claim state rather than trusting a process-local session cache. */ export async function getDurablePlanningSession(sessionId: string): Promise { if (!_aiSessionStore) return getSession(sessionId);