fix(planning): allow repeated task creation
Advance the durable creation epoch for each explicit Planning Mode action while preserving idempotency for transport retries. Recover soft-deleted task links with a fresh claim key and cover live, deleted, and retry paths.
This commit is contained in:
7
.changeset/planning-reusable-task-creation.md
Normal file
7
.changeset/planning-reusable-task-creation.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Allow Planning Mode to create another task from the same plan without requiring an edit.
|
||||||
|
category: fix
|
||||||
|
dev: Advances the durable planning creation epoch while preserving per-action idempotency.
|
||||||
@@ -75,9 +75,22 @@ Seven tests failed in `plugin-runner.test.ts`, but only this one identity surviv
|
|||||||
|
|
||||||
The failure is unrelated to the mobile question footer: it exercises the completed-plan Proceed handoff, while FN-8757 changes only the active-question footer. The file retains substantial coverage, so this first sighting is recorded rather than quarantined; a second sighting requires the normal file-level quarantine.
|
The failure is unrelated to the mobile question footer: it exercises the completed-plan Proceed handoff, while FN-8757 changes only the active-question footer. The file retains substantial coverage, so this first sighting is recorded rather than quarantined; a second sighting requires the normal file-level quarantine.
|
||||||
|
|
||||||
|
## 5. Planning Mode mobile plan-tab selection
|
||||||
|
|
||||||
|
- **File:** `packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx`
|
||||||
|
- **Exact test:** `PlanningModeModal sequential flow > uses full-view Questions and Plan preview tabs on mobile`
|
||||||
|
- **Observed tree/SHA:** `main` at `4ff41a723c` with the Planning Mode task-creation fix uncommitted.
|
||||||
|
- **Observed frequency:** first observation in a targeted three-file dashboard run.
|
||||||
|
|
||||||
|
| run | result |
|
||||||
|
|---|---|
|
||||||
|
| targeted three-file dashboard run | **1 failed** / 198 passed; React reported an update outside `act(...)`, and the Plan tab still had `aria-selected="false"` immediately after `fireEvent.click` |
|
||||||
|
|
||||||
|
The failure exercises the pre-existing mobile tab transition, while the task-creation fix changes the completed-plan Proceed handoff. The file retains substantial coverage, so this first sighting is recorded rather than quarantined; a second sighting requires the normal file-level quarantine.
|
||||||
|
|
||||||
## Common shape and unverified suspicion
|
## Common shape and unverified suspicion
|
||||||
|
|
||||||
All three are PostgreSQL-backed or PostgreSQL-suite-adjacent, pass in isolation, and appear only under full-suite parallelism. This points at shared database state between test files rather than any of the three tests. It is **unverified and uninvestigated**, not a diagnosis; do not infer a root-cause fix from this record.
|
The first three entries are PostgreSQL-backed or PostgreSQL-suite-adjacent, pass in isolation, and appear only under full-suite parallelism. This points at shared database state between those test files rather than any one test. It is **unverified and uninvestigated**, not a diagnosis; do not infer a root-cause fix from this record. The Planning Mode entries are separate frontend timing observations.
|
||||||
|
|
||||||
## Policy and escalation
|
## Policy and escalation
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type SharedPgTaskStoreHarness,
|
type SharedPgTaskStoreHarness,
|
||||||
} from "../../__test-utils__/pg-test-harness.js";
|
} from "../../__test-utils__/pg-test-harness.js";
|
||||||
import {
|
import {
|
||||||
|
advancePlanningSessionTaskCreationEpoch,
|
||||||
claimPlanningSessionTaskCreation,
|
claimPlanningSessionTaskCreation,
|
||||||
finalizePlanningSessionTaskCreation,
|
finalizePlanningSessionTaskCreation,
|
||||||
getAiSession,
|
getAiSession,
|
||||||
@@ -143,4 +144,56 @@ pgTest("planning session claim lifecycle (multi-task epochs)", () => {
|
|||||||
const finalized = await finalizePlanningSessionTaskCreation(db, sessionId, "live-token", "FN-4", 2);
|
const finalized = await finalizePlanningSessionTaskCreation(db, sessionId, "live-token", "FN-4", 2);
|
||||||
expect(payloadOf(finalized).createdTaskId).toBe("FN-4");
|
expect(payloadOf(finalized).createdTaskId).toBe("FN-4");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("advances the epoch after the linked task is soft-deleted so recreation gets a fresh proposal key", async () => {
|
||||||
|
const db = h.layer().db;
|
||||||
|
const store = h.store();
|
||||||
|
const sessionId = "planning-soft-deleted-task";
|
||||||
|
const firstProposalKey = `planning-session:${sessionId}`;
|
||||||
|
const firstTask = await store.createTask({
|
||||||
|
title: "First planned task",
|
||||||
|
description: "The task that will be soft-deleted.",
|
||||||
|
proposalClaimId: firstProposalKey,
|
||||||
|
});
|
||||||
|
await upsertAiSession(db, planningRow(sessionId, {
|
||||||
|
createClaimStatus: "created",
|
||||||
|
createdTaskId: firstTask.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await store.deleteTask(firstTask.id);
|
||||||
|
expect((await store.listTasks({ includeArchived: true })).some((task) => task.id === firstTask.id)).toBe(false);
|
||||||
|
await expect(store.createTask({
|
||||||
|
title: "Colliding replacement",
|
||||||
|
description: "The soft-deleted row must retain its unique proposal key.",
|
||||||
|
proposalClaimId: firstProposalKey,
|
||||||
|
})).rejects.toThrow();
|
||||||
|
|
||||||
|
const advanced = await advancePlanningSessionTaskCreationEpoch(
|
||||||
|
db,
|
||||||
|
sessionId,
|
||||||
|
firstTask.id,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(payloadOf(advanced)).toMatchObject({
|
||||||
|
createClaimStatus: "none",
|
||||||
|
taskCreationEpoch: 1,
|
||||||
|
createdTaskIds: [firstTask.id],
|
||||||
|
});
|
||||||
|
expect(payloadOf(advanced).createdTaskId).toBeUndefined();
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningMultiTask 2026-08-03-18:32:
|
||||||
|
Retried requests carry the old task id and epoch. The epoch CAS must advance only once.
|
||||||
|
*/
|
||||||
|
expect(await advancePlanningSessionTaskCreationEpoch(db, sessionId, firstTask.id, 0)).toBeNull();
|
||||||
|
expect(payloadOf(await getAiSession(db, sessionId)).taskCreationEpoch).toBe(1);
|
||||||
|
|
||||||
|
const replacement = await store.createTask({
|
||||||
|
title: "Replacement planned task",
|
||||||
|
description: "A fresh task from the same planning session.",
|
||||||
|
proposalClaimId: `planning-session:${sessionId}#1`,
|
||||||
|
});
|
||||||
|
expect(replacement.id).not.toBe(firstTask.id);
|
||||||
|
expect(replacement.proposalClaimId).toBe(`planning-session:${sessionId}#1`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -418,6 +418,49 @@ export async function releasePlanningSessionTaskCreation(
|
|||||||
return rows[0] ? rowToSession(rows[0]) : null;
|
return rows[0] ? rowToSession(rows[0]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:PlanningMultiTask 2026-08-03-18:32:
|
||||||
|
* Move a planning session past the claim owned by its latest created task.
|
||||||
|
*
|
||||||
|
* The linked task id acts as the idempotency token for the operator's next create action.
|
||||||
|
* This compare-and-set advances exactly once and clears only linkage fields while preserving
|
||||||
|
* concurrent planning payload. It also handles soft-deleted tasks, whose rows intentionally
|
||||||
|
* retain their proposalClaimId and therefore require a fresh epoch.
|
||||||
|
*/
|
||||||
|
export async function advancePlanningSessionTaskCreationEpoch(
|
||||||
|
handle: QueryHandle,
|
||||||
|
sessionId: string,
|
||||||
|
previousTaskId: string,
|
||||||
|
expectedTaskCreationEpoch: number,
|
||||||
|
): Promise<AiSessionRow | null> {
|
||||||
|
const inputPayload = schema.project.aiSessions.inputPayload;
|
||||||
|
const nextEpoch = expectedTaskCreationEpoch + 1;
|
||||||
|
const rows = await handle.update(schema.project.aiSessions)
|
||||||
|
.set({
|
||||||
|
inputPayload: sql`(
|
||||||
|
(${inputPayload} - 'createdTaskId' - 'claimOwnerToken' - 'claimStartedAt')
|
||||||
|
|| jsonb_build_object(
|
||||||
|
'createClaimStatus', 'none',
|
||||||
|
'taskCreationEpoch', ${nextEpoch}::int,
|
||||||
|
'createdTaskIds', CASE
|
||||||
|
WHEN coalesce(${inputPayload}->'createdTaskIds', '[]'::jsonb) @> jsonb_build_array(${previousTaskId}::text)
|
||||||
|
THEN coalesce(${inputPayload}->'createdTaskIds', '[]'::jsonb)
|
||||||
|
ELSE coalesce(${inputPayload}->'createdTaskIds', '[]'::jsonb) || jsonb_build_array(${previousTaskId}::text)
|
||||||
|
END
|
||||||
|
)
|
||||||
|
)`,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(and(
|
||||||
|
eq(schema.project.aiSessions.id, sessionId),
|
||||||
|
eq(schema.project.aiSessions.type, "planning"),
|
||||||
|
sql`${inputPayload}->>'createdTaskId' = ${previousTaskId}`,
|
||||||
|
...epochGuard(expectedTaskCreationEpoch),
|
||||||
|
))
|
||||||
|
.returning();
|
||||||
|
return rows[0] ? rowToSession(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateAiSessionTitle(
|
export async function updateAiSessionTitle(
|
||||||
handle: QueryHandle,
|
handle: QueryHandle,
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -2590,6 +2590,7 @@ export {
|
|||||||
finalizePlanningSessionTaskCreation,
|
finalizePlanningSessionTaskCreation,
|
||||||
reconcilePlanningSessionTaskCreation,
|
reconcilePlanningSessionTaskCreation,
|
||||||
releasePlanningSessionTaskCreation,
|
releasePlanningSessionTaskCreation,
|
||||||
|
advancePlanningSessionTaskCreationEpoch,
|
||||||
listActiveAiSessions,
|
listActiveAiSessions,
|
||||||
listAllAiSessions,
|
listAllAiSessions,
|
||||||
listRecoverableAiSessions,
|
listRecoverableAiSessions,
|
||||||
|
|||||||
@@ -289,6 +289,8 @@ export function createTaskFromPlanning(
|
|||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
};
|
};
|
||||||
workflowId?: string | null;
|
workflowId?: string | null;
|
||||||
|
/** FNXC:PlanningMultiTask 2026-08-03-18:32: Latest task visible when the operator intentionally requests another task from this plan. */
|
||||||
|
previousTaskId?: string;
|
||||||
},
|
},
|
||||||
): Promise<Task> {
|
): Promise<Task> {
|
||||||
return api<{ task: Task; alreadyCreated: boolean }>(withProjectId("/planning/create-task", projectId), {
|
return api<{ task: Task; alreadyCreated: boolean }>(withProjectId("/planning/create-task", projectId), {
|
||||||
@@ -299,6 +301,7 @@ export function createTaskFromPlanning(
|
|||||||
...(options?.baseBranch !== undefined ? { baseBranch: options.baseBranch } : {}),
|
...(options?.baseBranch !== undefined ? { baseBranch: options.baseBranch } : {}),
|
||||||
...(options?.branchSelection ? { branchSelection: options.branchSelection } : {}),
|
...(options?.branchSelection ? { branchSelection: options.branchSelection } : {}),
|
||||||
...(options?.workflowId !== undefined ? { workflowId: options.workflowId } : {}),
|
...(options?.workflowId !== undefined ? { workflowId: options.workflowId } : {}),
|
||||||
|
...(options?.previousTaskId ? { previousTaskId: options.previousTaskId } : {}),
|
||||||
}),
|
}),
|
||||||
}).then((response) => response.task);
|
}).then((response) => response.task);
|
||||||
}
|
}
|
||||||
@@ -504,4 +507,3 @@ export function connectPlanningStream(
|
|||||||
|
|
||||||
return connection;
|
return connection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -283,8 +283,8 @@ function resolveCompletePlanningResume(
|
|||||||
|
|
||||||
FNXC:PlanningMultiTask 2026-07-24-00:20:
|
FNXC:PlanningMultiTask 2026-07-24-00:20:
|
||||||
Sessions whose task already exists resume to plan review too, carrying the linked task for
|
Sessions whose task already exists resume to plan review too, carrying the linked task for
|
||||||
the banner. Proceed without editing idempotently returns that task; editing rotates the
|
the banner. Each explicit Proceed advances the server-side creation epoch, while transport
|
||||||
server-side creation epoch so the next Proceed creates a fresh task from the evolved plan.
|
retries for that action retain the old linked-task token and reconcile its canonical task.
|
||||||
*/
|
*/
|
||||||
return { kind: "plan_review", summary, ...(createdTaskId ? { linkedTaskId: createdTaskId } : {}) };
|
return { kind: "plan_review", summary, ...(createdTaskId ? { linkedTaskId: createdTaskId } : {}) };
|
||||||
}
|
}
|
||||||
@@ -500,7 +500,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
const [_activePlanPrompt, setActivePlanPrompt] = useState("");
|
const [_activePlanPrompt, setActivePlanPrompt] = useState("");
|
||||||
const [view, setView] = useState<ViewState>({ type: "initial" });
|
const [view, setView] = useState<ViewState>({ type: "initial" });
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// FNXC:PlanningMultiTask 2026-07-24-00:20: latest task created from this plan, shown as a plan-review banner; editing the plan rotates the server-side creation epoch so Proceed can create another.
|
// FNXC:PlanningMultiTask 2026-08-03-18:32: Latest task created from this plan. Passing its id with the next explicit Proceed action
|
||||||
|
// advances the server-side creation epoch, whether or not the plan was edited.
|
||||||
const [linkedTaskId, setLinkedTaskId] = useState<string | null>(null);
|
const [linkedTaskId, setLinkedTaskId] = useState<string | null>(null);
|
||||||
// FNXC:PlanningMultiTask 2026-07-24-01:40: the just-created Task object, so the banner's View task works immediately after creation without waiting for the tasks prop to refresh (review finding).
|
// FNXC:PlanningMultiTask 2026-07-24-01:40: the just-created Task object, so the banner's View task works immediately after creation without waiting for the tasks prop to refresh (review finding).
|
||||||
const [linkedTask, setLinkedTask] = useState<Task | null>(null);
|
const [linkedTask, setLinkedTask] = useState<Task | null>(null);
|
||||||
@@ -3211,6 +3212,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
try {
|
try {
|
||||||
const task = await createTaskAfterActiveClaim(() => createTaskFromPlanning(sessionId, summary, projectId, {
|
const task = await createTaskAfterActiveClaim(() => createTaskFromPlanning(sessionId, summary, projectId, {
|
||||||
...(workflowId !== undefined ? { workflowId } : {}),
|
...(workflowId !== undefined ? { workflowId } : {}),
|
||||||
|
...(linkedTaskId ? { previousTaskId: linkedTaskId } : {}),
|
||||||
}));
|
}));
|
||||||
clearPlanningActiveSession(projectId);
|
clearPlanningActiveSession(projectId);
|
||||||
setLinkedTaskId(task.id);
|
setLinkedTaskId(task.id);
|
||||||
@@ -3222,7 +3224,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
} finally {
|
} finally {
|
||||||
validateCreateInFlightRef.current = false;
|
validateCreateInFlightRef.current = false;
|
||||||
}
|
}
|
||||||
}, [projectId, t, workflowId, workspaceQuestion]);
|
}, [linkedTaskId, projectId, t, workflowId, workspaceQuestion]);
|
||||||
|
|
||||||
const handleMobileKeyboardActionPointerDown = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
|
const handleMobileKeyboardActionPointerDown = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||||
/*
|
/*
|
||||||
@@ -3256,7 +3258,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
validateCreateInFlightRef.current = true;
|
validateCreateInFlightRef.current = true;
|
||||||
setView({ type: "creating_task", session: view.session, summary: view.summary });
|
setView({ type: "creating_task", session: view.session, summary: view.summary });
|
||||||
try {
|
try {
|
||||||
const task = await createTaskAfterActiveClaim(() => createTaskFromPlanning(view.session.sessionId, view.summary, projectId, { ...(workflowId !== undefined ? { workflowId } : {}) }));
|
const task = await createTaskAfterActiveClaim(() => createTaskFromPlanning(view.session.sessionId, view.summary, projectId, {
|
||||||
|
...(workflowId !== undefined ? { workflowId } : {}),
|
||||||
|
...(linkedTaskId ? { previousTaskId: linkedTaskId } : {}),
|
||||||
|
}));
|
||||||
clearPlanningActiveSession(projectId);
|
clearPlanningActiveSession(projectId);
|
||||||
setLinkedTaskId(task.id);
|
setLinkedTaskId(task.id);
|
||||||
setLinkedTask(task);
|
setLinkedTask(task);
|
||||||
@@ -3266,7 +3271,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
} finally {
|
} finally {
|
||||||
validateCreateInFlightRef.current = false;
|
validateCreateInFlightRef.current = false;
|
||||||
}
|
}
|
||||||
}, [projectId, t, view, workflowId]);
|
}, [linkedTaskId, projectId, t, view, workflowId]);
|
||||||
|
|
||||||
const handleCreateTask = useCallback(async () => {
|
const handleCreateTask = useCallback(async () => {
|
||||||
if (view.type !== "summary") return;
|
if (view.type !== "summary") return;
|
||||||
@@ -3290,6 +3295,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
Planning Mode saves must carry the workflow lane that opened the modal so created tasks do not land on the main board before appearing on the selected sub-board.
|
Planning Mode saves must carry the workflow lane that opened the modal so created tasks do not land on the main board before appearing on the selected sub-board.
|
||||||
*/
|
*/
|
||||||
...(workflowId !== undefined ? { workflowId } : {}),
|
...(workflowId !== undefined ? { workflowId } : {}),
|
||||||
|
...(linkedTaskId ? { previousTaskId: linkedTaskId } : {}),
|
||||||
});
|
});
|
||||||
onTaskCreated(task);
|
onTaskCreated(task);
|
||||||
// Single-task creation should preserve completed planning history, so
|
// Single-task creation should preserve completed planning history, so
|
||||||
@@ -3303,7 +3309,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
} finally {
|
} finally {
|
||||||
setIsCreatingTask(false);
|
setIsCreatingTask(false);
|
||||||
}
|
}
|
||||||
}, [baseBranch, branchMode, branchName, editedSummary, view, projectId, workflowId, onTaskCreated, handleClose]);
|
}, [baseBranch, branchMode, branchName, editedSummary, view, projectId, workflowId, linkedTaskId, onTaskCreated, handleClose]);
|
||||||
|
|
||||||
const handleStartBreakdown = useCallback(async () => {
|
const handleStartBreakdown = useCallback(async () => {
|
||||||
if (view.type !== "summary") return;
|
if (view.type !== "summary") return;
|
||||||
@@ -4175,15 +4181,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
{/*
|
{/*
|
||||||
FNXC:PlanningMultiTask 2026-07-24-00:20:
|
FNXC:PlanningMultiTask 2026-07-24-00:20:
|
||||||
A plan that already produced a task stays a live work surface. The banner links the
|
A plan that already produced a task stays a live work surface. The banner links the
|
||||||
latest created task; continuing to edit rotates the server-side creation epoch, so
|
latest created task; the next explicit Proceed action advances the server-side
|
||||||
Proceed creates a fresh task from the evolved plan (unedited Proceed replays return
|
creation epoch so the plan can create another task without requiring an edit.
|
||||||
the same task).
|
|
||||||
*/}
|
*/}
|
||||||
{linkedTaskId && (
|
{linkedTaskId && (
|
||||||
<div className="planning-linked-task-note" data-testid="planning-linked-task-note" role="status">
|
<div className="planning-linked-task-note" data-testid="planning-linked-task-note" role="status">
|
||||||
<CheckCircle size={16} />
|
<CheckCircle size={16} />
|
||||||
<span>
|
<span>
|
||||||
{t("planning.linkedTaskNote", "Task {{taskId}} was created from this plan. Keep refining to create another.", { taskId: linkedTaskId })}
|
{t("planning.linkedTaskNote", "Task {{taskId}} was created from this plan. Proceed again to create another.", { taskId: linkedTaskId })}
|
||||||
</span>
|
</span>
|
||||||
{/* FNXC:PlanningMultiTask 2026-07-24-01:40: resolve the just-created Task object first so View task is enabled immediately after creation, before the tasks prop refreshes (mirrors the task_created view's view.task ?? tasks.find pattern). */}
|
{/* FNXC:PlanningMultiTask 2026-07-24-01:40: resolve the just-created Task object first so View task is enabled immediately after creation, before the tasks prop refreshes (mirrors the task_created view's view.task ?? tasks.find pattern). */}
|
||||||
<button
|
<button
|
||||||
@@ -4228,8 +4233,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
{/*
|
{/*
|
||||||
FNXC:PlanningMultiTask 2026-07-24-00:20:
|
FNXC:PlanningMultiTask 2026-07-24-00:20:
|
||||||
Task creation is not the end of the plan. Continue planning returns to the plan
|
Task creation is not the end of the plan. Continue planning returns to the plan
|
||||||
review workspace where the plan stays readable and editable; edits rotate the
|
review workspace where the plan stays readable and editable; the next Proceed
|
||||||
creation epoch so Proceed can create another task from the evolved plan.
|
action creates another task, even when the plan is unchanged.
|
||||||
*/}
|
*/}
|
||||||
{view.sessionId && runningSummary && (
|
{view.sessionId && runningSummary && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -792,6 +792,43 @@ describe("PlanningModeModal sequential flow", () => {
|
|||||||
expect(screen.getByTestId("planning-linked-task-note")).toHaveTextContent(mockTasks[0].id);
|
expect(screen.getByTestId("planning-linked-task-note")).toHaveTextContent(mockTasks[0].id);
|
||||||
expect(screen.getByRole("button", { name: "View task" })).toBeEnabled();
|
expect(screen.getByRole("button", { name: "View task" })).toBeEnabled();
|
||||||
expect(screen.getByRole("button", { name: "Proceed with plan" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Proceed with plan" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
mockCreateTaskFromPlanning.mockResolvedValueOnce({ id: "FN-002" });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Proceed with plan" }));
|
||||||
|
await waitFor(() => expect(mockCreateTaskFromPlanning).toHaveBeenCalledTimes(2));
|
||||||
|
expect(mockCreateTaskFromPlanning.mock.calls[1]?.[3]).toEqual(expect.objectContaining({
|
||||||
|
previousTaskId: mockTasks[0].id,
|
||||||
|
}));
|
||||||
|
expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-002");
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningMultiTask 2026-08-03-18:32:
|
||||||
|
A failed response may arrive after the server advanced the creation epoch. Manual Retry must
|
||||||
|
retain the same previous-task token so the server reconciles that action instead of advancing again.
|
||||||
|
*/
|
||||||
|
it("retries a failed explicit create with the same previous-task token", async () => {
|
||||||
|
mockFetchAiSession.mockResolvedValue({
|
||||||
|
...base,
|
||||||
|
status: "complete",
|
||||||
|
currentQuestion: null,
|
||||||
|
result: JSON.stringify(mockSummary),
|
||||||
|
inputPayload: JSON.stringify({ validated: true, createdTaskId: mockTasks[0].id }),
|
||||||
|
});
|
||||||
|
mockCreateTaskFromPlanning
|
||||||
|
.mockRejectedValueOnce(new Error("Response lost after create"))
|
||||||
|
.mockResolvedValueOnce({ id: "FN-RECONCILED" });
|
||||||
|
|
||||||
|
render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} onViewTask={vi.fn()} tasks={mockTasks} projectId="project-1" resumeSessionId="session-1" />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Proceed with plan" }));
|
||||||
|
expect(await screen.findByTestId("planning-create-retry")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Retry create" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockCreateTaskFromPlanning).toHaveBeenCalledTimes(2));
|
||||||
|
expect(mockCreateTaskFromPlanning.mock.calls[0]?.[3]).toEqual(expect.objectContaining({ previousTaskId: mockTasks[0].id }));
|
||||||
|
expect(mockCreateTaskFromPlanning.mock.calls[1]?.[3]).toEqual(expect.objectContaining({ previousTaskId: mockTasks[0].id }));
|
||||||
|
expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-RECONCILED");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears the linked-task banner when switching to a session without a created task", async () => {
|
it("clears the linked-task banner when switching to a session without a created task", async () => {
|
||||||
|
|||||||
@@ -334,8 +334,9 @@ describe("planning question regeneration instead of no-active-question errors",
|
|||||||
expect(afterDeletion.alreadyCreated).toBe(false);
|
expect(afterDeletion.alreadyCreated).toBe(false);
|
||||||
expect(afterDeletion.task.id).not.toBe(second.task.id);
|
expect(afterDeletion.task.id).not.toBe(second.task.id);
|
||||||
expect(createTask).toHaveBeenCalledTimes(3);
|
expect(createTask).toHaveBeenCalledTimes(3);
|
||||||
// The replacement task reuses the current epoch's claim key (its unique-index row died with the deleted task).
|
// FNXC:PlanningMultiTask 2026-08-03-18:32: Production deletion is soft: the tombstone retains the epoch-1 unique claim key, so
|
||||||
expect(createTask.mock.calls[2][0].proposalClaimId).toBe(`planning-session:${sessionId}#1`);
|
// deleted-link recovery must advance before inserting the replacement.
|
||||||
|
expect(createTask.mock.calls[2][0].proposalClaimId).toBe(`planning-session:${sessionId}#2`);
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -3041,6 +3041,97 @@ describe("Planning Mode Routes", () => {
|
|||||||
expect(res.body.alreadyCreated).toBe(false);
|
expect(res.body.alreadyCreated).toBe(false);
|
||||||
expect(res.body.task.id).toBe("FN-REBORN");
|
expect(res.body.task.id).toBe("FN-REBORN");
|
||||||
expect(store.createTask).toHaveBeenCalledTimes(1);
|
expect(store.createTask).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
proposalClaimId: `planning-session:${sessionId}#1`,
|
||||||
|
}));
|
||||||
|
expect(JSON.parse((await mockStore.get(sessionId))!.inputPayload)).toMatchObject({
|
||||||
|
createClaimStatus: "created",
|
||||||
|
createdTaskId: "FN-REBORN",
|
||||||
|
taskCreationEpoch: 1,
|
||||||
|
createdTaskIds: ["FN-GONE"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates another task from an unchanged plan when the request identifies the previous task", async () => {
|
||||||
|
const sessionId = "planning-create-another-unchanged";
|
||||||
|
const previousTaskId = "FN-PREVIOUS";
|
||||||
|
const mockStore = new MockAiSessionStore();
|
||||||
|
await mockStore.upsert(buildPlanningRow({
|
||||||
|
id: sessionId,
|
||||||
|
status: "complete",
|
||||||
|
inputPayload: JSON.stringify({
|
||||||
|
initialPlan: "Build a thing",
|
||||||
|
validated: true,
|
||||||
|
createdTaskId: previousTaskId,
|
||||||
|
createClaimStatus: "created",
|
||||||
|
}),
|
||||||
|
result: JSON.stringify({
|
||||||
|
title: "Reusable plan",
|
||||||
|
description: "Create more than one task without editing the plan",
|
||||||
|
suggestedSize: "M",
|
||||||
|
suggestedDependencies: [],
|
||||||
|
keyDeliverables: ["Implementation"],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
setAiSessionStore(mockStore as unknown as Parameters<typeof setAiSessionStore>[0]);
|
||||||
|
|
||||||
|
const nextTask = {
|
||||||
|
id: "FN-NEXT",
|
||||||
|
description: "Another task",
|
||||||
|
column: "triage",
|
||||||
|
dependencies: [],
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
proposalClaimId: `planning-session:${sessionId}#1`,
|
||||||
|
};
|
||||||
|
let created = false;
|
||||||
|
(store.listTasks as ReturnType<typeof vi.fn>).mockImplementation(async () => [
|
||||||
|
{ id: previousTaskId, proposalClaimId: `planning-session:${sessionId}` },
|
||||||
|
...(created ? [nextTask] : []),
|
||||||
|
]);
|
||||||
|
(store.createTask as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
||||||
|
created = true;
|
||||||
|
return nextTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
const appWithAiSessionStore = express();
|
||||||
|
appWithAiSessionStore.use(express.json());
|
||||||
|
appWithAiSessionStore.use("/api", createApiRoutes(store, { aiSessionStore: mockStore as any }));
|
||||||
|
const res = await REQUEST(
|
||||||
|
appWithAiSessionStore,
|
||||||
|
"POST",
|
||||||
|
"/api/planning/create-task",
|
||||||
|
JSON.stringify({ sessionId, previousTaskId }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||||
|
expect(res.body.task.id).toBe("FN-NEXT");
|
||||||
|
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
proposalClaimId: `planning-session:${sessionId}#1`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningMultiTask 2026-08-03-18:32:
|
||||||
|
A transport retry repeats the old linked-task token. It must reconcile epoch 1's
|
||||||
|
canonical task instead of treating the retry as a third explicit create action.
|
||||||
|
*/
|
||||||
|
const replay = await REQUEST(
|
||||||
|
appWithAiSessionStore,
|
||||||
|
"POST",
|
||||||
|
"/api/planning/create-task",
|
||||||
|
JSON.stringify({ sessionId, previousTaskId }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(replay.status).toBe(200);
|
||||||
|
expect(replay.body.alreadyCreated).toBe(true);
|
||||||
|
expect(replay.body.task.id).toBe("FN-NEXT");
|
||||||
|
expect(store.createTask).toHaveBeenCalledTimes(1);
|
||||||
|
expect(JSON.parse((await mockStore.get(sessionId))!.inputPayload)).toMatchObject({
|
||||||
|
createdTaskId: "FN-NEXT",
|
||||||
|
taskCreationEpoch: 1,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps failing closed when the linked task is still listed but unreadable", async () => {
|
it("keeps failing closed when the linked task is still listed but unreadable", async () => {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
finalizePlanningSessionTaskCreation,
|
finalizePlanningSessionTaskCreation,
|
||||||
reconcilePlanningSessionTaskCreation,
|
reconcilePlanningSessionTaskCreation,
|
||||||
releasePlanningSessionTaskCreation,
|
releasePlanningSessionTaskCreation,
|
||||||
|
advancePlanningSessionTaskCreationEpoch,
|
||||||
listActiveAiSessions,
|
listActiveAiSessions,
|
||||||
listAllAiSessions,
|
listAllAiSessions,
|
||||||
listRecoverableAiSessions,
|
listRecoverableAiSessions,
|
||||||
@@ -182,6 +183,12 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async advancePlanningTaskCreationEpoch(sessionId: string, taskId: string, expectedTaskCreationEpoch: number): Promise<AiSessionRow | null> {
|
||||||
|
const row = await advancePlanningSessionTaskCreationEpoch(this.dbAsync, sessionId, taskId, expectedTaskCreationEpoch) as AiSessionRow | null;
|
||||||
|
if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt));
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update only the thinkingOutput field, debounced to reduce write frequency.
|
* Update only the thinkingOutput field, debounced to reduce write frequency.
|
||||||
* Flushes immediately if `flush` is true (e.g. on status transition).
|
* Flushes immediately if `flush` is true (e.g. on status transition).
|
||||||
|
|||||||
@@ -4046,9 +4046,10 @@ function restoreClaimSession(row: import("./ai-session-store.js").AiSessionRow):
|
|||||||
/*
|
/*
|
||||||
FNXC:PlanningMultiTask 2026-07-24-00:20:
|
FNXC:PlanningMultiTask 2026-07-24-00:20:
|
||||||
One plan may produce multiple tasks, one per creation epoch. The task table's partial unique
|
One plan may produce multiple tasks, one per creation epoch. The task table's partial unique
|
||||||
proposalClaimId index stays the multi-process crash authority WITHIN an epoch: replaying
|
proposalClaimId index stays the multi-process crash authority WITHIN an epoch. A new explicit
|
||||||
Proceed without editing dedupes to the same task (alreadyCreated), while editing the plan
|
create action carrying the latest task id advances the epoch; transport retries carrying the
|
||||||
after a task exists rotates to a new epoch/key so the next Proceed creates a fresh task.
|
prior id stay on the already-advanced epoch and dedupe to its canonical task. Editing the plan
|
||||||
|
after a task exists also rotates to a new epoch/key.
|
||||||
Epoch 0 keeps the legacy un-suffixed key so pre-existing linked sessions stay reconciled.
|
Epoch 0 keeps the legacy un-suffixed key so pre-existing linked sessions stay reconciled.
|
||||||
*/
|
*/
|
||||||
export function planningProposalClaimId(sessionId: string, taskCreationEpoch?: number): string {
|
export function planningProposalClaimId(sessionId: string, taskCreationEpoch?: number): string {
|
||||||
@@ -4116,10 +4117,10 @@ export async function createTaskFromPlanSession(
|
|||||||
const summary = session.summary ?? buildRunningSummary(session.initialPlan, session.history);
|
const summary = session.summary ?? buildRunningSummary(session.initialPlan, session.history);
|
||||||
if (!summary) throw new InvalidSessionStateError("Planning session has no plan to create a task from");
|
if (!summary) throw new InvalidSessionStateError("Planning session has no plan to create a task from");
|
||||||
|
|
||||||
const claimEpoch = session.taskCreationEpoch ?? 0;
|
let claimEpoch = session.taskCreationEpoch ?? 0;
|
||||||
const proposalClaimId = planningProposalClaimId(sessionId, claimEpoch);
|
const currentProposalClaimId = () => planningProposalClaimId(sessionId, claimEpoch);
|
||||||
const findCreatedTask = async (): Promise<Task | undefined> =>
|
const findCreatedTask = async (): Promise<Task | undefined> =>
|
||||||
(await store.listTasks({ includeArchived: true })).find((candidate) => candidate.proposalClaimId === proposalClaimId);
|
(await store.listTasks({ includeArchived: true })).find((candidate) => candidate.proposalClaimId === currentProposalClaimId());
|
||||||
const markSessionComplete = async (): Promise<void> => {
|
const markSessionComplete = async (): Promise<void> => {
|
||||||
const current = await getSession(sessionId);
|
const current = await getSession(sessionId);
|
||||||
if (current && !current.validated) {
|
if (current && !current.validated) {
|
||||||
@@ -4146,18 +4147,31 @@ export async function createTaskFromPlanSession(
|
|||||||
const clearStaleLinkedTask = async (staleTaskId: string): Promise<boolean> => {
|
const clearStaleLinkedTask = async (staleTaskId: string): Promise<boolean> => {
|
||||||
const allTasks = await store.listTasks({ includeArchived: true }).catch(() => null);
|
const allTasks = await store.listTasks({ includeArchived: true }).catch(() => null);
|
||||||
if (allTasks === null || allTasks.some((candidate) => candidate.id === staleTaskId)) return false;
|
if (allTasks === null || allTasks.some((candidate) => candidate.id === staleTaskId)) return false;
|
||||||
diagnostics.warn("Planning session linked task no longer exists; clearing stale linkage", {
|
diagnostics.warn("Planning session linked task no longer exists; advancing creation epoch", {
|
||||||
sessionId,
|
sessionId,
|
||||||
staleTaskId,
|
staleTaskId,
|
||||||
operation: "create-task-session",
|
operation: "create-task-session",
|
||||||
});
|
});
|
||||||
await updatePlanningCreateClaim(sessionId, { createClaimStatus: "none", createdTaskId: undefined, claimOwnerToken: undefined, claimStartedAt: undefined }).catch(() => undefined);
|
const advanced = await advancePlanningTaskCreationEpoch(
|
||||||
if (session) {
|
sessionId,
|
||||||
session.createdTaskId = undefined;
|
staleTaskId,
|
||||||
session.createClaimStatus = "none";
|
claimEpoch,
|
||||||
session.claimOwnerToken = undefined;
|
);
|
||||||
session.claimStartedAt = undefined;
|
if (!advanced) {
|
||||||
|
/*
|
||||||
|
FNXC:PlanningMultiTask 2026-08-03-18:32:
|
||||||
|
Losing the epoch CAS means another process may already have advanced this session.
|
||||||
|
Refresh that state once so agent creation can reconcile or claim the winning epoch.
|
||||||
|
*/
|
||||||
|
const refreshed = await getDurablePlanningSession(sessionId).catch(() => undefined);
|
||||||
|
if (!refreshed
|
||||||
|
|| ((refreshed.taskCreationEpoch ?? 0) === claimEpoch && refreshed.createdTaskId === staleTaskId)) return false;
|
||||||
|
session = refreshed;
|
||||||
|
claimEpoch = refreshed.taskCreationEpoch ?? claimEpoch;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
session = advanced;
|
||||||
|
claimEpoch = advanced.taskCreationEpoch ?? claimEpoch + 1;
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4218,7 +4232,7 @@ export async function createTaskFromPlanSession(
|
|||||||
priority: isTaskPriority(summary.priority) ? summary.priority : DEFAULT_TASK_PRIORITY,
|
priority: isTaskPriority(summary.priority) ? summary.priority : DEFAULT_TASK_PRIORITY,
|
||||||
source: { sourceType: options?.sourceType ?? "cli" },
|
source: { sourceType: options?.sourceType ?? "cli" },
|
||||||
...(options?.baseBranch?.trim() ? { baseBranch: options.baseBranch.trim() } : {}),
|
...(options?.baseBranch?.trim() ? { baseBranch: options.baseBranch.trim() } : {}),
|
||||||
proposalClaimId,
|
proposalClaimId: currentProposalClaimId(),
|
||||||
});
|
});
|
||||||
insertedTask = task;
|
insertedTask = task;
|
||||||
// FNXC:PlanningMultiTask 2026-07-24-03:20: best-effort side effects must be LOUD on failure (review finding) — a task missing its plan document with no signal is undebuggable.
|
// FNXC:PlanningMultiTask 2026-07-24-03:20: best-effort side effects must be LOUD on failure (review finding) — a task missing its plan document with no signal is undebuggable.
|
||||||
@@ -4236,7 +4250,7 @@ export async function createTaskFromPlanSession(
|
|||||||
if (originalRequest) {
|
if (originalRequest) {
|
||||||
await sideEffect("Planning create-task original description document write failed", () => store.upsertTaskDocument?.(task.id, { key: "original-description", content: originalRequest, author: "planning", metadata: { planningSessionId: sessionId, source: "planning-mode-initial-plan" } }));
|
await sideEffect("Planning create-task original description document write failed", () => store.upsertTaskDocument?.(task.id, { key: "original-description", content: originalRequest, author: "planning", metadata: { planningSessionId: sessionId, source: "planning-mode-initial-plan" } }));
|
||||||
}
|
}
|
||||||
await sideEffect("Planning create-task log entry failed", () => store.logEntry?.(task.id, "Created via Planning Mode", `Initial plan: ${(session.initialPlan ?? "").slice(0, 200)}`));
|
await sideEffect("Planning create-task log entry failed", () => store.logEntry?.(task.id, "Created via Planning Mode", `Initial plan: ${(session?.initialPlan ?? "").slice(0, 200)}`));
|
||||||
await finalizePlanningTaskCreation(sessionId, claimOwnerToken, task.id, claimEpoch);
|
await finalizePlanningTaskCreation(sessionId, claimOwnerToken, task.id, claimEpoch);
|
||||||
await markSessionComplete();
|
await markSessionComplete();
|
||||||
return { task, alreadyCreated: false };
|
return { task, alreadyCreated: false };
|
||||||
@@ -4309,6 +4323,35 @@ export async function releasePlanningTaskCreation(sessionId: string, ownerToken:
|
|||||||
return row ? restoreClaimSession(row) : undefined;
|
return row ? restoreClaimSession(row) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** FNXC:PlanningMultiTask 2026-08-03-18:32: Advance once when an explicit new create action identifies the session's latest task. */
|
||||||
|
export async function advancePlanningTaskCreationEpoch(
|
||||||
|
sessionId: string,
|
||||||
|
previousTaskId: string,
|
||||||
|
expectedTaskCreationEpoch: number,
|
||||||
|
): Promise<Session | undefined> {
|
||||||
|
if (_aiSessionStore && typeof (_aiSessionStore as unknown as { advancePlanningTaskCreationEpoch?: unknown }).advancePlanningTaskCreationEpoch === "function") {
|
||||||
|
const row = await _aiSessionStore.advancePlanningTaskCreationEpoch(
|
||||||
|
sessionId,
|
||||||
|
previousTaskId,
|
||||||
|
expectedTaskCreationEpoch,
|
||||||
|
);
|
||||||
|
return row ? restoreClaimSession(row) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getSession(sessionId);
|
||||||
|
if (!session
|
||||||
|
|| session.createdTaskId !== previousTaskId
|
||||||
|
|| (session.taskCreationEpoch ?? 0) !== expectedTaskCreationEpoch) return undefined;
|
||||||
|
rotateTaskCreationEpochOnReopen(session);
|
||||||
|
await updatePlanningCreateClaim(sessionId, {
|
||||||
|
createClaimStatus: "none",
|
||||||
|
createdTaskId: undefined,
|
||||||
|
claimOwnerToken: undefined,
|
||||||
|
claimStartedAt: undefined,
|
||||||
|
});
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate subtasks from a completed planning summary.
|
* Generate subtasks from a completed planning summary.
|
||||||
* Uses the planning session's summary to create a SubtaskItem[] for multi-task creation.
|
* Uses the planning session's summary to create a SubtaskItem[] for multi-task creation.
|
||||||
|
|||||||
@@ -1171,13 +1171,14 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
let claimedOwnerToken: string | undefined;
|
let claimedOwnerToken: string | undefined;
|
||||||
let claimedSessionId: string | undefined;
|
let claimedSessionId: string | undefined;
|
||||||
try {
|
try {
|
||||||
const { sessionId, summary: summaryInput, branch, baseBranch, branchSelection, workflowId } = req.body as {
|
const { sessionId, summary: summaryInput, branch, baseBranch, branchSelection, workflowId, previousTaskId } = req.body as {
|
||||||
sessionId?: unknown;
|
sessionId?: unknown;
|
||||||
summary?: unknown;
|
summary?: unknown;
|
||||||
branch?: unknown;
|
branch?: unknown;
|
||||||
baseBranch?: unknown;
|
baseBranch?: unknown;
|
||||||
branchSelection?: unknown;
|
branchSelection?: unknown;
|
||||||
workflowId?: unknown;
|
workflowId?: unknown;
|
||||||
|
previousTaskId?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!sessionId || typeof sessionId !== "string") {
|
if (!sessionId || typeof sessionId !== "string") {
|
||||||
@@ -1187,6 +1188,10 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
if (workflowId !== undefined && workflowId !== null && typeof workflowId !== "string") {
|
if (workflowId !== undefined && workflowId !== null && typeof workflowId !== "string") {
|
||||||
throw badRequest("workflowId must be a string or null");
|
throw badRequest("workflowId must be a string or null");
|
||||||
}
|
}
|
||||||
|
if (previousTaskId !== undefined && (typeof previousTaskId !== "string" || !previousTaskId.trim())) {
|
||||||
|
throw badRequest("previousTaskId must be a non-empty string");
|
||||||
|
}
|
||||||
|
const normalizedPreviousTaskId = typeof previousTaskId === "string" ? previousTaskId.trim() : undefined;
|
||||||
|
|
||||||
const summaryOverride = parsePlanningSummaryOverride(summaryInput);
|
const summaryOverride = parsePlanningSummaryOverride(summaryInput);
|
||||||
|
|
||||||
@@ -1200,6 +1205,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
finalizePlanningTaskCreation,
|
finalizePlanningTaskCreation,
|
||||||
reconcilePlanningTaskCreation,
|
reconcilePlanningTaskCreation,
|
||||||
releasePlanningTaskCreation,
|
releasePlanningTaskCreation,
|
||||||
|
advancePlanningTaskCreationEpoch,
|
||||||
validateSession,
|
validateSession,
|
||||||
planningProposalClaimId,
|
planningProposalClaimId,
|
||||||
formatPlanningTaskHandoff,
|
formatPlanningTaskHandoff,
|
||||||
@@ -1301,6 +1307,23 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
session = await getSession(sessionId);
|
session = await getSession(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningMultiTask 2026-08-03-18:32:
|
||||||
|
An explicit create action from a plan that already produced a task starts a new creation
|
||||||
|
epoch even when the plan was not edited. The previous task id is the idempotency token:
|
||||||
|
the first request advances while retries carrying the same old id observe the already-
|
||||||
|
advanced/current epoch and reconcile its one canonical task.
|
||||||
|
*/
|
||||||
|
if (normalizedPreviousTaskId && session?.createdTaskId === normalizedPreviousTaskId) {
|
||||||
|
const priorEpoch = session.taskCreationEpoch ?? 0;
|
||||||
|
const advanced = await advancePlanningTaskCreationEpoch(
|
||||||
|
sessionId,
|
||||||
|
normalizedPreviousTaskId,
|
||||||
|
priorEpoch,
|
||||||
|
);
|
||||||
|
session = advanced ?? await getDurablePlanningSession(sessionId) ?? session;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:PlanningMode 2026-07-20-15:45:
|
FNXC:PlanningMode 2026-07-20-15:45:
|
||||||
FN-8442: the task table's partial unique proposalClaimId index, not this process's claim
|
FN-8442: the task table's partial unique proposalClaimId index, not this process's claim
|
||||||
@@ -1308,22 +1331,21 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
durable cache reconciled from that key; a missing linked task fails closed rather than
|
durable cache reconciled from that key; a missing linked task fails closed rather than
|
||||||
silently forking.
|
silently forking.
|
||||||
|
|
||||||
FNXC:PlanningMultiTask 2026-07-24-00:20:
|
The key is per creation epoch (`planning-session:{id}` for epoch 0, `…#N` afterward).
|
||||||
The key is now per creation EPOCH (`planning-session:{id}` for epoch 0, `…#N` after the
|
An explicit action carrying the latest task id advances the epoch; retries carrying the
|
||||||
plan is edited past a created task), so one plan can produce multiple tasks while
|
prior id observe the already-advanced epoch and dedupe to its canonical task.
|
||||||
Proceed replays inside an epoch still dedupe to that epoch's task.
|
|
||||||
*/
|
*/
|
||||||
const claimEpoch = session?.taskCreationEpoch ?? 0;
|
let claimEpoch = session?.taskCreationEpoch ?? 0;
|
||||||
const proposalClaimId = planningProposalClaimId(sessionId, claimEpoch);
|
const currentProposalClaimId = () => planningProposalClaimId(sessionId, claimEpoch);
|
||||||
const findCreatedTask = async () =>
|
const findCreatedTask = async () =>
|
||||||
(await scopedStore.listTasks({ includeArchived: true })).find((candidate) => candidate.proposalClaimId === proposalClaimId);
|
(await scopedStore.listTasks({ includeArchived: true })).find((candidate) => candidate.proposalClaimId === currentProposalClaimId());
|
||||||
/*
|
/*
|
||||||
FNXC:PlanningMode 2026-07-23-12:10 (updated FNXC:PlanningMultiTask 2026-07-24-01:40):
|
FNXC:PlanningMode 2026-07-23-12:10 (updated FNXC:PlanningMultiTask 2026-07-24-01:40):
|
||||||
The claim model allows exactly one task per creation EPOCH — a session can produce
|
The claim model allows exactly one task per creation EPOCH — a session can produce
|
||||||
multiple tasks across epochs (rotation happens when the plan is edited past a created
|
multiple tasks across epochs (rotation happens on a new explicit create action or when
|
||||||
task). After each creation the session must stop advertising awaiting_input in the
|
the plan is edited past a created task). After each creation the session must stop
|
||||||
session list/banner, so terminalize here through validateSession (the sole terminal
|
advertising awaiting_input in the session list/banner, so terminalize here through
|
||||||
transition) on every path that ends with a created task, including alreadyCreated
|
validateSession (the sole terminal transition) on every path that ends with a created task, including alreadyCreated
|
||||||
reconciliation; a later edit reopens it. Best-effort: a failure to terminalize must not
|
reconciliation; a later edit reopens it. Best-effort: a failure to terminalize must not
|
||||||
fail the task creation itself. Deploy assumption: the dashboard serves a single code
|
fail the task creation itself. Deploy assumption: the dashboard serves a single code
|
||||||
version per DB at a time — a pre-epoch binary handling a rotated session would derive
|
version per DB at a time — a pre-epoch binary handling a rotated session would derive
|
||||||
@@ -1360,17 +1382,15 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
const allTasks = await scopedStore.listTasks({ includeArchived: true }).catch(() => null);
|
const allTasks = await scopedStore.listTasks({ includeArchived: true }).catch(() => null);
|
||||||
const stillListed = allTasks === null || allTasks.some((task) => task.id === candidate.createdTaskId);
|
const stillListed = allTasks === null || allTasks.some((task) => task.id === candidate.createdTaskId);
|
||||||
if (stillListed) throw conflict("PLANNING_CREATED_TASK_MISSING");
|
if (stillListed) throw conflict("PLANNING_CREATED_TASK_MISSING");
|
||||||
await runPlanningCreateSideEffect(
|
const staleTaskId = candidate.createdTaskId;
|
||||||
"Planning create-task stale linkage clear failed",
|
const advanced = await advancePlanningTaskCreationEpoch(
|
||||||
() => updatePlanningCreateClaim(sessionId, { createClaimStatus: "none", createdTaskId: undefined, claimOwnerToken: undefined, claimStartedAt: undefined }),
|
sessionId,
|
||||||
{ sessionId, staleTaskId: candidate.createdTaskId },
|
staleTaskId,
|
||||||
|
claimEpoch,
|
||||||
);
|
);
|
||||||
if (session) {
|
if (!advanced) throw conflict("Planning task creation state changed; retry creation");
|
||||||
session.createdTaskId = undefined;
|
session = advanced;
|
||||||
session.createClaimStatus = "none";
|
claimEpoch = advanced.taskCreationEpoch ?? claimEpoch + 1;
|
||||||
session.claimOwnerToken = undefined;
|
|
||||||
session.claimStartedAt = undefined;
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1453,7 +1473,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
Planning Mode creates tasks from the board context, so an active workflow lane must be materialized at create time when the client supplies it.
|
Planning Mode creates tasks from the board context, so an active workflow lane must be materialized at create time when the client supplies it.
|
||||||
*/
|
*/
|
||||||
...(workflowId !== undefined ? { workflowId: workflowId as string | null } : {}),
|
...(workflowId !== undefined ? { workflowId: workflowId as string | null } : {}),
|
||||||
proposalClaimId,
|
proposalClaimId: currentProposalClaimId(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update task with suggested size if provided.
|
// Update task with suggested size if provided.
|
||||||
|
|||||||
Reference in New Issue
Block a user