feat(cli): claim-aware multi-task planning parity for fn task plan

Closes the P1 agent-native gap from the multi-task review: the CLI and
fn_task_plan pi tool created tasks via a raw store.createTask with no
proposalClaimId — no idempotency, no session linkage, and tasks outside
the epoch sequence, so a later dashboard Proceed would duplicate them.

- New shared createTaskFromPlanSession in @fusion/dashboard/planning:
  the agent-surface twin of POST /planning/create-task (epoch-derived
  claim key, claim/finalize/reconcile/release CAS lifecycle with the 30s
  stale-lease takeover, formatPlanningPlanMd task shape, plan/original-
  description documents, validate-on-create, generating guard).
- runTaskPlan creates through it (making the FN-7734 retry wrapper
  genuinely safe), prints the session id, and offers an interactive
  keep-refining loop that creates further tasks from the evolved plan.
- fn task plan --resume <sessionId> / fn_task_plan resumeSessionId reopen
  an existing session — even a validated one whose task exists — and the
  no-question resume regenerates the interview via a refine turn, which
  rotates the creation epoch server-side.

Tests: CLI suite pins claim-aware creation, the continue prompt, and the
resume flow; dashboard suite pins createTaskFromPlanSession idempotent
replay and epoch-aware second creation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-23 20:01:50 -07:00
parent fbaf3c56d2
commit fdd1202328
9 changed files with 326 additions and 38 deletions

View File

@@ -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 <sessionId>` / `resumeSessionId` plus an interactive keep-refining loop.

View File

@@ -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 |
|-----------|------|----------|-------------|

View File

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

View File

@@ -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<typeof vi.fn>).mockResolvedValueOnce({
id: "resume-session-9",
currentQuestion: null,
summary: { title: "Existing plan", description: "d", suggestedSize: "M", suggestedDependencies: [], keyDeliverables: [] },
});
(submitResponse as unknown as ReturnType<typeof vi.fn>)
.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")
);

View File

@@ -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 <name>, --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 <sessionId> continues a plan to create another task)
fn task list List all tasks
fn task show <id> Show task details, steps, log
fn task logs <id> [--follow] [--limit <n>] [--type <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":

View File

@@ -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<string | undefined> {
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 <id>` / 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 <sessionId>`.
*/
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;
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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<Task | undefined> =>
(await store.listTasks({ includeArchived: true })).find((candidate) => candidate.proposalClaimId === proposalClaimId);
const markSessionComplete = async (): Promise<void> => {
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<Session | undefined> {
if (!_aiSessionStore) return getSession(sessionId);