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:
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|-----------|------|----------|-------------|
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user