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 "@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 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 ### 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 | | 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_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_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_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_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_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. | | `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", () => ({ vi.mock("@fusion/dashboard/planning", () => ({
createSession: vi.fn(), createSession: vi.fn(),
submitResponse: 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 { RateLimitError: class RateLimitError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);
@@ -50,7 +67,7 @@ vi.mock("@fusion/dashboard/planning", () => ({
// Import after mocking // Import after mocking
import { createInterface } from "node:readline/promises"; 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"; import { runTaskPlan } from "../commands/task.js";
describe("runTaskPlan", () => { describe("runTaskPlan", () => {
@@ -415,13 +432,13 @@ describe("runTaskPlan", () => {
const taskId = await runTaskPlan("Build something", true); const taskId = await runTaskPlan("Build something", true);
expect(taskId).toBe("FN-042"); expect(taskId).toBe("FN-042");
expect(mockCreateTask).toHaveBeenCalledWith({ // FNXC:PlanningMultiTask 2026-07-24-02:30: creation must flow through the claim-aware shared path, never a raw store.createTask (no idempotency/linkage).
title: "Planned Task", expect(createTaskFromPlanSession).toHaveBeenCalledWith(
description: "A well-planned task", "test-session-123",
column: "triage", expect.anything(),
dependencies: ["FN-001"], { baseBranch: undefined },
source: { sourceType: "cli" }, );
}); expect(mockCreateTask).not.toHaveBeenCalled();
}); });
it("prompts for confirmation without --yes flag", async () => { it("prompts for confirmation without --yes flag", async () => {
@@ -450,7 +467,8 @@ describe("runTaskPlan", () => {
mockQuestion mockQuestion
.mockResolvedValueOnce("y") .mockResolvedValueOnce("y")
.mockResolvedValueOnce("y"); .mockResolvedValueOnce("y")
.mockResolvedValueOnce("n");
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("Process.exit called"); throw new Error("Process.exit called");
@@ -462,11 +480,48 @@ describe("runTaskPlan", () => {
// expected // 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(); 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 () => { it("handles RateLimitError with proper message", async () => {
setupTaskStoreMock(); setupTaskStoreMock();
@@ -575,6 +630,7 @@ describe("runTaskPlan", () => {
expect(taskId).toBeUndefined(); expect(taskId).toBeUndefined();
expect(mockCreateTask).not.toHaveBeenCalled(); expect(mockCreateTask).not.toHaveBeenCalled();
expect(createTaskFromPlanSession).not.toHaveBeenCalled();
expect(mockConsoleLog).toHaveBeenCalledWith( expect(mockConsoleLog).toHaveBeenCalledWith(
expect.stringContaining("Task creation cancelled") expect.stringContaining("Task creation cancelled")
); );

View File

@@ -322,7 +322,7 @@ Usage:
Update Fusion on the selected release channel Update Fusion on the selected release channel
fn upgrade Alias for fn update 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 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 list List all tasks
fn task show <id> Show task details, steps, log fn task show <id> Show task details, steps, log
fn task logs <id> [--follow] [--limit <n>] [--type <type>] fn task logs <id> [--follow] [--limit <n>] [--type <type>]
@@ -1280,6 +1280,8 @@ async function main() {
const planArgs = args.slice(2); const planArgs = args.slice(2);
const yesFlag = planArgs.includes("--yes"); const yesFlag = planArgs.includes("--yes");
let baseBranch: string | undefined; 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[] = []; const descParts: string[] = [];
for (let i = 0; i < planArgs.length; i++) { for (let i = 0; i < planArgs.length; i++) {
if (planArgs[i] === "--yes") { if (planArgs[i] === "--yes") {
@@ -1287,12 +1289,15 @@ async function main() {
} else if (planArgs[i] === "--base-branch" && i + 1 < planArgs.length) { } else if (planArgs[i] === "--base-branch" && i + 1 < planArgs.length) {
baseBranch = planArgs[i + 1]; baseBranch = planArgs[i + 1];
i++; i++;
} else if (planArgs[i] === "--resume" && i + 1 < planArgs.length) {
resumeSessionId = planArgs[i + 1];
i++;
} else { } else {
descParts.push(planArgs[i]); descParts.push(planArgs[i]);
} }
} }
const initialPlan = descParts.join(" "); const initialPlan = descParts.join(" ");
await runTaskPlan(initialPlan || undefined, yesFlag, projectName, baseBranch); await runTaskPlan(initialPlan || undefined, yesFlag, projectName, baseBranch, resumeSessionId);
break; break;
} }
case "list": case "list":

View File

@@ -2,7 +2,7 @@ import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatc
import { isInReviewMissingWorktreeSessionStartFailure, runAiMerge, landWorkspaceTask, installBaselineArchiveWorktreeDisposer } from "@fusion/engine"; import { isInReviewMissingWorktreeSessionStartFailure, runAiMerge, landWorkspaceTask, installBaselineArchiveWorktreeDisposer } from "@fusion/engine";
import { createInterface } from "node:readline/promises"; import { createInterface } from "node:readline/promises";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; 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 { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
import { basename, join } from "node:path"; import { basename, join } from "node:path";
import * as dashboard from "@fusion/dashboard"; import * as dashboard from "@fusion/dashboard";
@@ -2174,11 +2174,12 @@ export async function runTaskPlan(
yesFlag = false, yesFlag = false,
projectName?: string, projectName?: string,
baseBranch?: string, baseBranch?: string,
resumeSessionId?: string,
): Promise<string | undefined> { ): Promise<string | undefined> {
let initialPlan = initialPlanArg; let initialPlan = initialPlanArg;
// If no initial plan, prompt interactively // If no initial plan, prompt interactively
if (!initialPlan) { if (!initialPlan && !resumeSessionId) {
const rl = createInterface({ input: process.stdin, output: process.stdout }); const rl = createInterface({ input: process.stdin, output: process.stdout });
console.log("\n Let's plan your task. What would you like to accomplish?\n"); console.log("\n Let's plan your task. What would you like to accomplish?\n");
initialPlan = await rl.question(" Describe your idea: "); initialPlan = await rl.question(" Describe your idea: ");
@@ -2202,17 +2203,49 @@ export async function runTaskPlan(
const context = await resolveBoardContext(projectName, "plan", "resolve project"); const context = await resolveBoardContext(projectName, "plan", "resolve project");
const store = context.store; const store = context.store;
// Create planning session // Create (or resume) the planning session
let sessionId: string; let sessionId: string;
let firstQuestion: PlanningQuestion; let firstQuestion: PlanningQuestion;
try { try {
showThinking(); showThinking();
const projectPath = context.projectPath; const projectPath = context.projectPath;
const result = await createSession("127.0.0.1", initialPlan.trim(), store, projectPath); if (resumeSessionId) {
clearThinking(); /*
sessionId = result.sessionId; FNXC:PlanningMultiTask 2026-07-24-02:30:
firstQuestion = result.firstQuestion; 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) { } catch (err) {
clearThinking(); clearThinking();
@@ -2227,6 +2260,9 @@ export async function runTaskPlan(
return undefined; 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 // Interactive Q&A loop
let currentQuestion = firstQuestion; let currentQuestion = firstQuestion;
let cancelled = false; let cancelled = false;
@@ -2334,27 +2370,54 @@ export async function runTaskPlan(
} }
if (confirmed) { if (confirmed) {
// Create the task — the ONE discrete board write in this flow, /*
// retried independently (FN-7734). FNXC:PlanningMultiTask 2026-07-24-02:30:
// FN-5060: intentional same-content sibling; deterministic guard skipped here. Review finding (P1 agent-native parity): the CLI/agent surface used to call
const task = await retryBoardCall(context, "plan", "create task", () => store.createTask({ store.createTask directly with no proposalClaimId — no idempotency, no session
title: result.data.title, linkage, tasks outside the epoch sequence. It now creates through the same
description: result.data.description, claim-aware path as the dashboard (epoch-derived key, claim CAS lifecycle,
column: "triage", validate-on-create), which also makes the retryBoardCall wrapper safe: a retried
dependencies: result.data.suggestedDependencies, call reconciles the already-inserted task instead of duplicating it (FN-7734).
baseBranch: baseBranch?.trim() || undefined, */
source: { sourceType: "cli" }, const { task, alreadyCreated } = await retryBoardCall(context, "plan", "create task", () =>
})); createTaskFromPlanSession(sessionId, store, { baseBranch: baseBranch?.trim() || undefined }));
console.log(); console.log();
console.log(` ✓ Created ${task.id}: ${task.title || task.description.slice(0, 60)}${task.description.length > 60 ? "…" : ""}`); 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: triage`); console.log(` Column: ${task.column ?? "triage"}`);
if (task.dependencies.length > 0) { if (task.dependencies.length > 0) {
console.log(` Dependencies: ${task.dependencies.join(", ")}`); console.log(` Dependencies: ${task.dependencies.join(", ")}`);
} }
console.log(` Path: .fusion/tasks/${task.id}/`); console.log(` Path: .fusion/tasks/${task.id}/`);
console.log(); 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; return task.id;
} }

View File

@@ -2567,11 +2567,12 @@ export default function kbExtension(pi: ExtensionAPI) {
name: "fn_task_plan", name: "fn_task_plan",
label: "fn: Plan Task", label: "fn: Plan Task",
description: 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", promptSnippet: "Create a task via AI-guided planning mode",
promptGuidelines: [ promptGuidelines: [
"Use for breaking down vague ideas into actionable tasks", "Use for breaking down vague ideas into actionable tasks",
"The AI will ask clarifying questions before creating the task", "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({ parameters: Type.Object({
description: Type.Optional( 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" })), 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) { async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
@@ -2604,7 +2607,7 @@ export default function kbExtension(pi: ExtensionAPI) {
let taskId: string | undefined; let taskId: string | undefined;
try { 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) { } catch (err) {
console.error = originalError; console.error = originalError;
console.log = originalLog; console.log = originalLog;

View File

@@ -39,6 +39,7 @@ import {
__resetPlanningState, __resetPlanningState,
__setCreateFnAgent, __setCreateFnAgent,
createSessionWithAgent, createSessionWithAgent,
createTaskFromPlanSession,
getSession, getSession,
InvalidSessionStateError, InvalidSessionStateError,
planningProposalClaimId, planningProposalClaimId,
@@ -227,6 +228,60 @@ describe("planning question regeneration instead of no-active-question errors",
expect(session.validated).toBe(true); 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: FNXC:PlanningMultiTask 2026-07-24-01:40:
Durable round-trip of the new epoch fields through buildSessionFromRow's normalization Durable round-trip of the new epoch fields through buildSessionFromRow's normalization

View File

@@ -27,9 +27,11 @@ import {
DEFAULT_TASK_PRIORITY, DEFAULT_TASK_PRIORITY,
TASK_PRIORITIES, TASK_PRIORITIES,
THINKING_LEVELS, THINKING_LEVELS,
formatPlanningPlanMd,
summarizeTitle, summarizeTitle,
type PromptOverrideMap, type PromptOverrideMap,
} from "@fusion/core"; } from "@fusion/core";
import type { Task } from "@fusion/core";
import type { SubtaskItem } from "./subtask-breakdown.js"; import type { SubtaskItem } from "./subtask-breakdown.js";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
@@ -3950,6 +3952,110 @@ function rotateTaskCreationEpochOnReopen(session: Session): void {
session.claimStartedAt = undefined; 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. */ /** Read durable claim state rather than trusting a process-local session cache. */
export async function getDurablePlanningSession(sessionId: string): Promise<Session | undefined> { export async function getDurablePlanningSession(sessionId: string): Promise<Session | undefined> {
if (!_aiSessionStore) return getSession(sessionId); if (!_aiSessionStore) return getSession(sessionId);