feat(dashboard): allow multiple tasks from one plan via epoch-scoped claims

One planning session can now create multiple tasks. Task-creation claims
are epoch-scoped: proposalClaimId stays planning-session:{id} for epoch 0
and becomes planning-session:{id}#N after the plan is edited past a
created task (rotateTaskCreationEpochOnReopen archives createdTaskId into
createdTaskIds and resets claim state). Unedited Proceed replays stay
idempotent within an epoch; crash-after-insert dedup still reconciles via
the epoch-keyed task row. Complete sessions resume to an editable plan
review with a linked-task banner; the task-created handoff gains a
Continue planning action.

Hardening from the multi-agent code review (9 reviewers):
- Reopen + rotation run only AFTER turn admission, so a rejected request
  never burns a phantom rotation (P1, 3 reviewers).
- Claim-lifecycle CAS writes are surgical jsonb merges and reconcile takes
  an expected-epoch guard, so a concurrent rotation can never be reverted
  or an archived task re-linked to a new epoch.
- create-task 409s while the session is still generating (turn-completion
  persist could tear the fresh linkage).
- Durable-read fallback in create-task now logs before trusting the
  in-memory epoch.
- linkedTaskId no longer leaks across session switches; the banner
  resolves the just-created Task before the tasks prop refreshes and
  falls back to the newest archived task after rotation; Continue
  planning re-registers the active session.
- Shared applyCompletePlanningResume helper replaces triplicated resume
  view-transitions; stale one-task-per-session comment corrected.

Tests: post-rotation replay idempotency and epoch-keyed crash reconcile
(e2e), rewind rotation + rejected-rewind non-rotation + payload
normalization round-trip (unit), Continue planning + banner-leak (UI),
create-task 409 (routes), and a new PG integration suite pinning the
surgical CAS merge and reconcile epoch guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-23 19:37:49 -07:00
parent 907e8d03e6
commit ca4639bb67
14 changed files with 860 additions and 122 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: One plan can now create multiple tasks — keep refining after a task is created and Proceed again.
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.

View File

@@ -0,0 +1,120 @@
/*
FNXC:PlanningMultiTask 2026-07-24-01:40:
PostgreSQL integration coverage for the planning create-claim lifecycle after the
multi-task-per-plan change. Pins two invariants against the real jsonb SQL:
1. Claim-lifecycle writes are SURGICAL — claim/finalize/reconcile/release only touch the four
claim keys, so concurrently-written epoch fields (taskCreationEpoch, createdTaskIds) always
survive (review finding: the previous whole-payload read-modify-write could silently revert
a concurrent epoch rotation).
2. reconcile's expectedTaskCreationEpoch guard is a no-op when the row's epoch has advanced,
so an archived task is never re-linked onto a newer epoch.
*/
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest";
import {
createSharedPgTaskStoreTestHarness,
pgDescribe,
type SharedPgTaskStoreHarness,
} from "../../__test-utils__/pg-test-harness.js";
import {
claimPlanningSessionTaskCreation,
finalizePlanningSessionTaskCreation,
getAiSession,
reconcilePlanningSessionTaskCreation,
releasePlanningSessionTaskCreation,
upsertAiSession,
type AiSessionRow,
} from "../../async-ai-session-store.js";
const pgTest = pgDescribe;
function planningRow(id: string, inputPayload: Record<string, unknown>): AiSessionRow {
const now = new Date().toISOString();
return {
id,
type: "planning",
status: "complete",
title: "Multi-task plan",
inputPayload: JSON.stringify({ initialPlan: "Build the thing", ...inputPayload }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "",
error: null,
projectId: null,
createdAt: now,
updatedAt: now,
} as AiSessionRow;
}
function payloadOf(row: AiSessionRow | null): Record<string, unknown> {
return JSON.parse((row?.inputPayload as string) ?? "{}") as Record<string, unknown>;
}
pgTest("planning session claim lifecycle (multi-task epochs)", () => {
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_planning_claim",
});
beforeAll(h.beforeAll);
beforeEach(h.beforeEach);
afterEach(h.afterEach);
afterAll(h.afterAll);
it("claim/finalize/release only touch claim keys and preserve concurrent epoch fields", async () => {
const db = h.layer().db;
const sessionId = "planning-claim-surgical";
await upsertAiSession(db, planningRow(sessionId, {
taskCreationEpoch: 1,
createdTaskIds: ["FN-1"],
}));
const token = "owner-token-1";
const claimed = await claimPlanningSessionTaskCreation(db, sessionId, token, new Date().toISOString());
expect(claimed).not.toBeNull();
const afterClaim = payloadOf(claimed);
expect(afterClaim.createClaimStatus).toBe("creating");
expect(afterClaim.taskCreationEpoch).toBe(1);
expect(afterClaim.createdTaskIds).toEqual(["FN-1"]);
expect(afterClaim.createdTaskId).toBeUndefined();
expect(afterClaim.initialPlan).toBe("Build the thing");
// Second claim while creating must lose the CAS.
expect(await claimPlanningSessionTaskCreation(db, sessionId, "other-token", new Date().toISOString())).toBeNull();
const finalized = await finalizePlanningSessionTaskCreation(db, sessionId, token, "FN-2");
const afterFinalize = payloadOf(finalized);
expect(afterFinalize.createClaimStatus).toBe("created");
expect(afterFinalize.createdTaskId).toBe("FN-2");
expect(afterFinalize.claimOwnerToken).toBeUndefined();
expect(afterFinalize.taskCreationEpoch).toBe(1);
expect(afterFinalize.createdTaskIds).toEqual(["FN-1"]);
// Wrong-token release is a no-op; the row keeps its finalized linkage.
expect(await releasePlanningSessionTaskCreation(db, sessionId, "other-token")).toBeNull();
expect(payloadOf(await getAiSession(db, sessionId)).createClaimStatus).toBe("created");
});
it("reconcile with a stale expected epoch is a no-op instead of re-linking an archived task", async () => {
const db = h.layer().db;
const sessionId = "planning-claim-epoch-guard";
await upsertAiSession(db, planningRow(sessionId, {
taskCreationEpoch: 2,
createdTaskIds: ["FN-1", "FN-2"],
}));
// Caller derived its claim key under epoch 1; the plan has since rotated to epoch 2.
expect(await reconcilePlanningSessionTaskCreation(db, sessionId, "FN-STALE", 1)).toBeNull();
const untouched = payloadOf(await getAiSession(db, sessionId));
expect(untouched.createdTaskId).toBeUndefined();
expect(untouched.taskCreationEpoch).toBe(2);
// Matching epoch reconciles normally and preserves the epoch fields.
const reconciled = await reconcilePlanningSessionTaskCreation(db, sessionId, "FN-3", 2);
const afterReconcile = payloadOf(reconciled);
expect(afterReconcile.createClaimStatus).toBe("created");
expect(afterReconcile.createdTaskId).toBe("FN-3");
expect(afterReconcile.taskCreationEpoch).toBe(2);
expect(afterReconcile.createdTaskIds).toEqual(["FN-1", "FN-2"]);
});
});

View File

@@ -297,18 +297,30 @@ FN-8442 requires a database compare-and-set before Planning Mode creates a task.
never-rotated proposalClaimId prevents duplicate task rows, while this conditional
ai_sessions transition assigns exactly one live creator across dashboard processes.
*/
/*
FNXC:PlanningMultiTask 2026-07-24-01:40:
Claim-lifecycle writes are SURGICAL jsonb merges (`input_payload || patch - removedKeys`),
never read-modify-write of the whole payload. Review finding: the previous stale-spread
(`{...input, ...}`) could silently revert a concurrent epoch rotation (taskCreationEpoch /
createdTaskIds written by an edit turn between this function's read and its UPDATE), which
either re-linked an archived task to the new epoch or dropped the rotation entirely. The
merge form only touches the four claim keys, so concurrent non-claim payload fields always
survive. The WHERE guards still evaluate against the CURRENT row at update time.
*/
const CLAIM_KEYS_PATCH = (patch: Record<string, string>, removeKeys: string[]) =>
sql`(${schema.project.aiSessions.inputPayload} || ${JSON.stringify(patch)}::jsonb)${sql.raw(removeKeys.map((key) => ` - '${key.replace(/'/g, "''")}'`).join(""))}`;
export async function claimPlanningSessionTaskCreation(
handle: QueryHandle,
sessionId: string,
claimOwnerToken: string,
claimStartedAt: string,
): Promise<AiSessionRow | null> {
const existing = await getAiSession(handle, sessionId);
if (!existing || existing.type !== "planning") return null;
const input = safeJsonParse(existing.inputPayload, {}) as Record<string, unknown>;
const inputPayload = { ...input, createClaimStatus: "creating", claimOwnerToken, claimStartedAt, createdTaskId: undefined };
const rows = await handle.update(schema.project.aiSessions)
.set({ inputPayload, updatedAt: claimStartedAt })
.set({
inputPayload: CLAIM_KEYS_PATCH({ createClaimStatus: "creating", claimOwnerToken, claimStartedAt }, ["createdTaskId"]),
updatedAt: claimStartedAt,
})
.where(and(
eq(schema.project.aiSessions.id, sessionId),
eq(schema.project.aiSessions.type, "planning"),
@@ -325,30 +337,47 @@ export async function finalizePlanningSessionTaskCreation(
claimOwnerToken: string,
createdTaskId: string,
): Promise<AiSessionRow | null> {
const existing = await getAiSession(handle, sessionId);
if (!existing || existing.type !== "planning") return null;
const input = safeJsonParse(existing.inputPayload, {}) as Record<string, unknown>;
const inputPayload = { ...input, createClaimStatus: "created", createdTaskId, claimOwnerToken: undefined, claimStartedAt: undefined };
const rows = await handle.update(schema.project.aiSessions)
.set({ inputPayload, updatedAt: new Date().toISOString() })
.set({
inputPayload: CLAIM_KEYS_PATCH({ createClaimStatus: "created", createdTaskId }, ["claimOwnerToken", "claimStartedAt"]),
updatedAt: new Date().toISOString(),
})
.where(and(eq(schema.project.aiSessions.id, sessionId), sql`${schema.project.aiSessions.inputPayload}->>'claimOwnerToken' = ${claimOwnerToken}`))
.returning();
return rows[0] ? rowToSession(rows[0]) : null;
}
/** Reconcile a task created before a process could finalize its session linkage. */
/*
Reconcile a task created before a process could finalize its session linkage.
FNXC:PlanningMultiTask 2026-07-24-01:40:
`expectedTaskCreationEpoch` guards reconcile against racing an edit-turn rotation: the caller
derived the task's claim key under a specific epoch, and stamping that task's id onto a row
whose epoch has since advanced would link an archived task to the NEW epoch. When provided,
the conditional update only fires while the row's epoch still matches; a lost race is a
harmless no-op (the task itself was already returned to the caller).
*/
export async function reconcilePlanningSessionTaskCreation(
handle: QueryHandle,
sessionId: string,
createdTaskId: string,
expectedTaskCreationEpoch?: number,
): Promise<AiSessionRow | null> {
const existing = await getAiSession(handle, sessionId);
if (!existing || existing.type !== "planning") return null;
const input = safeJsonParse(existing.inputPayload, {}) as Record<string, unknown>;
const inputPayload = { ...input, createClaimStatus: "created", createdTaskId, claimOwnerToken: undefined, claimStartedAt: undefined };
const conditions = [
eq(schema.project.aiSessions.id, sessionId),
eq(schema.project.aiSessions.type, "planning"),
];
if (expectedTaskCreationEpoch !== undefined) {
conditions.push(
sql`coalesce((${schema.project.aiSessions.inputPayload}->>'taskCreationEpoch')::int, 0) = ${expectedTaskCreationEpoch}`,
);
}
const rows = await handle.update(schema.project.aiSessions)
.set({ inputPayload, updatedAt: new Date().toISOString() })
.where(and(eq(schema.project.aiSessions.id, sessionId), eq(schema.project.aiSessions.type, "planning")))
.set({
inputPayload: CLAIM_KEYS_PATCH({ createClaimStatus: "created", createdTaskId }, ["claimOwnerToken", "claimStartedAt"]),
updatedAt: new Date().toISOString(),
})
.where(and(...conditions))
.returning();
return rows[0] ? rowToSession(rows[0]) : null;
}
@@ -359,12 +388,11 @@ export async function releasePlanningSessionTaskCreation(
sessionId: string,
claimOwnerToken: string,
): Promise<AiSessionRow | null> {
const existing = await getAiSession(handle, sessionId);
if (!existing || existing.type !== "planning") return null;
const input = safeJsonParse(existing.inputPayload, {}) as Record<string, unknown>;
const inputPayload = { ...input, createClaimStatus: "none", claimOwnerToken: undefined, claimStartedAt: undefined };
const rows = await handle.update(schema.project.aiSessions)
.set({ inputPayload, updatedAt: new Date().toISOString() })
.set({
inputPayload: CLAIM_KEYS_PATCH({ createClaimStatus: "none" }, ["claimOwnerToken", "claimStartedAt"]),
updatedAt: new Date().toISOString(),
})
.where(and(eq(schema.project.aiSessions.id, sessionId), sql`${schema.project.aiSessions.inputPayload}->>'claimOwnerToken' = ${claimOwnerToken}`))
.returning();
return rows[0] ? rowToSession(rows[0]) : null;

View File

@@ -1132,6 +1132,30 @@ That gutter read as dead space around the framed cards; panes now sit flush edge
font-size: var(--font-size-sm);
}
/*
FNXC:PlanningMultiTask 2026-07-24-00:20:
Plan-review banner linking the latest task created from this plan; the plan remains a live,
editable work surface after creation.
*/
.planning-linked-task-note {
display: flex;
align-items: center;
/* FNXC:PlanningMultiTask 2026-07-24-01:40: wrap on narrow/mobile viewports so the View task action never clips (review finding). */
flex-wrap: wrap;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--success, var(--accent)) 8%, var(--card));
color: var(--text);
font-size: var(--font-size-sm);
}
.planning-linked-task-note span {
flex: 1;
min-width: 0;
}
.planning-question {
display: flex;
flex-direction: column;

View File

@@ -163,7 +163,7 @@ type ViewState =
| { type: "plan_review"; session: PlanningSession; summary: PlanningSummary }
| { type: "creating_task"; session: PlanningSession; summary: PlanningSummary }
| { type: "create_retry"; session: PlanningSession; summary: PlanningSummary; errorMessage: string }
| { type: "task_created"; taskId: string; task?: Task }
| { type: "task_created"; taskId: string; task?: Task; sessionId?: string }
| { type: "error"; session: PlanningSession; errorMessage: string }
| { type: "breakdown"; sessionId: string; originalSubtasks: SubtaskItem[]; subtasks: SubtaskItem[]; dirty: boolean }
| { type: "loading" }
@@ -200,13 +200,25 @@ function parsePlanningInputPayload(session: { inputPayload?: string | null }): {
const payload = JSON.parse(session.inputPayload ?? "{}") as {
validated?: unknown;
createdTaskId?: unknown;
createdTaskIds?: unknown;
};
if (typeof payload !== "object" || payload === null) {
return { validated: false };
}
/*
FNXC:PlanningMultiTask 2026-07-24-01:40:
Epoch rotation clears createdTaskId (it archives into createdTaskIds), so a resumed
mid-epoch session would lose its banner without this fallback to the newest archived
task id (review finding).
*/
const archivedIds = Array.isArray(payload.createdTaskIds)
? payload.createdTaskIds.filter((id): id is string => typeof id === "string")
: [];
return {
validated: payload.validated === true,
createdTaskId: typeof payload.createdTaskId === "string" ? payload.createdTaskId : undefined,
createdTaskId: typeof payload.createdTaskId === "string"
? payload.createdTaskId
: archivedIds.length > 0 ? archivedIds[archivedIds.length - 1] : undefined,
};
} catch {
return { validated: false };
@@ -214,8 +226,7 @@ function parsePlanningInputPayload(session: { inputPayload?: string | null }): {
}
type CompletePlanningResume =
| { kind: "task_created"; taskId: string; summary: PlanningSummary }
| { kind: "plan_review"; summary: PlanningSummary }
| { kind: "plan_review"; summary: PlanningSummary; linkedTaskId?: string }
| { kind: "unrecoverable" };
function resolveCompletePlanningResume(
@@ -232,18 +243,20 @@ function resolveCompletePlanningResume(
}
if (!summary) return { kind: "unrecoverable" };
const { validated, createdTaskId } = parsePlanningInputPayload(session);
const terminal = session.status === "complete" || validated;
const { createdTaskId } = parsePlanningInputPayload(session);
/*
FNXC:PlanningReopenAfterValidate 2026-07-23-23:30:
A finished plan must never resume into a do-nothing screen. A validated session with no
created task lands on the full plan review workspace — read the plan, keep refining or
commenting (the server reopens a validated session on any new turn), and Proceed to create
the task at any time. Only a session whose task already exists resumes to the task handoff.
The create_retry view remains solely the transient failure screen of a live Proceed attempt.
A finished plan must never resume into a do-nothing screen: every recoverable complete
session lands on the full plan review workspace — read the plan, keep refining or
commenting (the server reopens a validated session on any new turn), and Proceed at any
time. The create_retry view remains solely the transient failure screen of a live Proceed.
FNXC:PlanningMultiTask 2026-07-24-00:20:
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
server-side creation epoch so the next Proceed creates a fresh task from the evolved plan.
*/
if (terminal && createdTaskId) return { kind: "task_created", taskId: createdTaskId, summary };
return { kind: "plan_review", summary };
return { kind: "plan_review", summary, ...(createdTaskId ? { linkedTaskId: createdTaskId } : {}) };
}
function getExamplePlans(t: TFunction<"app">): string[] {
@@ -457,6 +470,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [_activePlanPrompt, setActivePlanPrompt] = useState("");
const [view, setView] = useState<ViewState>({ type: "initial" });
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.
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).
const [linkedTask, setLinkedTask] = useState<Task | null>(null);
const [, setResponseHistory] = useState<QuestionResponse[]>([]);
const [conversationHistory, setConversationHistory] = useState<ConversationHistoryEntry[]>([]);
const conversationHistoryRef = useRef<ConversationHistoryEntry[]>([]);
@@ -1120,6 +1137,26 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
return () => clearInterval(timer);
}, [generationStartTime, view.type]);
/*
FNXC:PlanningMultiTask 2026-07-24-01:40:
Single application point for a complete-session resume (review finding: this exact
setRunningSummary + setLinkedTaskId + setView block was duplicated verbatim at the poll,
retry-refresh, and loadSession call sites, and grew a new line in all three copies).
*/
const applyCompletePlanningResume = useCallback(
(sessionId: string, resume: { summary: PlanningSummary; linkedTaskId?: string }) => {
setRunningSummary(resume.summary);
setLinkedTaskId(resume.linkedTaskId ?? null);
setLinkedTask(null);
setView({
type: "plan_review",
session: { sessionId, currentQuestion: null, summary: resume.summary },
summary: resume.summary,
});
},
[],
);
// Fallback for missed SSE 'question'/'summary' events: when the loading
// state lingers, periodically refetch the session and transition the view
// if the server has already moved past generating. Without this, a dropped
@@ -1196,17 +1233,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
errorMessage: t("planning.sessionUnrecoverableState", "This session could not be restored. Retry to continue the interview."),
});
} else {
setRunningSummary(resume.summary);
resetPlanningAutoRetryBudget();
if (resume.kind === "task_created") {
setView({ type: "task_created", taskId: resume.taskId });
} else {
setView({
type: "plan_review",
session: { sessionId, currentQuestion: null, summary: resume.summary },
summary: resume.summary,
});
}
applyCompletePlanningResume(sessionId, resume);
}
setStreamingOutput("");
} else if (session.status === "error") {
@@ -1259,6 +1287,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setActivePlanPrompt("");
setView({ type: "initial" });
setError(null);
setLinkedTaskId(null);
setLinkedTask(null);
setResponseHistory([]);
setConversationHistory([]);
setEditedSummary(null);
@@ -1636,16 +1666,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}
resetPlanningAutoRetryBudget();
clearPlanningDescription(projectId);
setRunningSummary(resume.summary);
if (resume.kind === "task_created") {
setView({ type: "task_created", taskId: resume.taskId });
} else {
setView({
type: "plan_review",
session: { sessionId: session.id, currentQuestion: null, summary: resume.summary },
summary: resume.summary,
});
}
applyCompletePlanningResume(session.id, resume);
} else if (session.status === "error") {
const terminalView: ViewState = {
type: "error",
@@ -1878,6 +1899,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
currentSessionIdRef.current = sessionId;
setError(null);
/*
FNXC:PlanningMultiTask 2026-07-24-01:40:
Review finding (confidence 100): the linked-task banner leaked across session switches —
only the complete branch below set linkedTaskId, so opening session B after a
task-linked session A showed "Task <A's task> was created from this plan" on B's plan
review with a working View task button. Clear it at load start; the complete branch
restores it for the session actually being loaded.
*/
setLinkedTaskId(null);
setLinkedTask(null);
setStreamingOutput("");
setResponseHistory([]);
setConversationHistory([]);
@@ -2071,18 +2102,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
errorMessage: t("planning.sessionUnrecoverableState", "This session could not be restored. Retry to continue the interview."),
});
} else {
setRunningSummary(resume.summary);
resetPlanningAutoRetryBudget();
clearPlanningDescription(projectId);
if (resume.kind === "task_created") {
setView({ type: "task_created", taskId: resume.taskId });
} else {
setView({
type: "plan_review",
session: { sessionId, currentQuestion: null, summary: resume.summary },
summary: resume.summary,
});
}
applyCompletePlanningResume(sessionId, resume);
}
} else if (session.status === "generating") {
setView({ type: "loading" });
@@ -3051,7 +3073,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
...(workflowId !== undefined ? { workflowId } : {}),
}));
clearPlanningActiveSession(projectId);
setView({ type: "task_created", taskId: task.id, task });
setLinkedTaskId(task.id);
setLinkedTask(task);
setView({ type: "task_created", taskId: task.id, task, sessionId });
} catch (err) {
const errorMessage = getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task");
setView({ type: "create_retry", session, summary, errorMessage });
@@ -3094,7 +3118,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
try {
const task = await createTaskAfterActiveClaim(() => createTaskFromPlanning(view.session.sessionId, view.summary, projectId, { ...(workflowId !== undefined ? { workflowId } : {}) }));
clearPlanningActiveSession(projectId);
setView({ type: "task_created", taskId: task.id, task });
setLinkedTaskId(task.id);
setLinkedTask(task);
setView({ type: "task_created", taskId: task.id, task, sessionId: view.session.sessionId });
} catch (err) {
setView({ ...view, errorMessage: getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task") });
} finally {
@@ -3997,6 +4023,33 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
{view.type === "plan_review" && (
<div className="planning-summary planning-plan-review" data-testid="planning-plan-review">
{/*
FNXC:PlanningMultiTask 2026-07-24-00:20:
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
Proceed creates a fresh task from the evolved plan (unedited Proceed replays return
the same task).
*/}
{linkedTaskId && (
<div className="planning-linked-task-note" data-testid="planning-linked-task-note" role="status">
<CheckCircle size={16} />
<span>
{t("planning.linkedTaskNote", "Task {{taskId}} was created from this plan. Keep refining to create another.", { taskId: linkedTaskId })}
</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). */}
<button
type="button"
className="btn"
disabled={!onViewTask || !((linkedTask?.id === linkedTaskId ? linkedTask : null) ?? tasks.find((candidate) => candidate.id === linkedTaskId))}
onClick={() => {
const task = (linkedTask?.id === linkedTaskId ? linkedTask : null) ?? tasks.find((candidate) => candidate.id === linkedTaskId);
if (task) onViewTask?.(task);
}}
>
{t("planning.viewTask", "View task")}
</button>
</div>
)}
{renderPlanPane(view.summary)}
</div>
)}
@@ -4023,6 +4076,37 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
{t("planning.viewTask", "View task")}
<ArrowRight size={16} />
</button>
{/*
FNXC:PlanningMultiTask 2026-07-24-00:20:
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
creation epoch so Proceed can create another task from the evolved plan.
*/}
{view.sessionId && runningSummary && (
<button
type="button"
className="btn"
onClick={() => {
const sessionId = view.sessionId!;
/*
FNXC:PlanningMultiTask 2026-07-24-01:40:
Re-register the session as selected AND as the durable active session
(task creation cleared it) so post-continue edits survive a reload and
the sidebar selection matches the visible plan (review finding).
*/
currentSessionIdRef.current = sessionId;
setSelectedSessionId(sessionId);
savePlanningActiveSession(sessionId, projectId);
setView({
type: "plan_review",
session: { sessionId, currentQuestion: null, summary: runningSummary },
summary: runningSummary,
});
}}
>
{t("planning.continuePlanning", "Continue planning")}
</button>
)}
<button type="button" className="btn" onClick={handleBackToList}>
{t("planning.returnToSessions", "Return to sessions")}
</button>

View File

@@ -267,7 +267,12 @@ describe("PlanningModeModal autosize", () => {
expect(screen.queryByText("This plan is still being prepared")).toBeNull();
});
it("opens the linked task when a complete session already has a createdTaskId", async () => {
/*
FNXC:PlanningMultiTask 2026-07-24-00:20:
A session whose task exists resumes to the editable plan review workspace with a banner
linking that task — not a terminal handoff — so the plan can evolve into further tasks.
*/
it("resumes a task-linked complete session to plan review with the linked-task banner", async () => {
mockFetchAiSession.mockResolvedValueOnce({
id: "session-complete-linked",
type: "planning",
@@ -305,7 +310,10 @@ describe("PlanningModeModal autosize", () => {
/>
);
expect(await screen.findByText("FN-9001")).toBeInTheDocument();
expect(await screen.findByTestId("planning-plan-review")).toBeInTheDocument();
expect(screen.getByTestId("planning-linked-task-note")).toBeInTheDocument();
expect(screen.getByTestId("planning-linked-task-note").textContent).toContain("FN-9001");
expect(screen.getByRole("button", { name: "Proceed with plan" })).toBeInTheDocument();
expect(screen.queryByTestId("planning-create-retry")).toBeNull();
});
});

View File

@@ -661,7 +661,13 @@ describe("PlanningModeModal sequential flow", () => {
expect(screen.getByRole("button", { name: "Return to sessions" })).toBeEnabled();
});
it("restores a linked task into the created-task handoff", async () => {
/*
FNXC:PlanningMultiTask 2026-07-24-00:20:
A session whose task exists resumes to the EDITABLE plan review workspace with a banner
linking that task — not a terminal created-task handoff — so the plan can keep evolving
into further tasks. Reopen never re-fires onTaskCreated for a previously created task.
*/
it("restores a linked task as a plan-review banner with a live View task action", async () => {
mockFetchAiSession.mockResolvedValue({
...base,
status: "complete",
@@ -673,12 +679,17 @@ describe("PlanningModeModal sequential flow", () => {
const onViewTask = vi.fn();
render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={onTaskCreated} onTasksCreated={vi.fn()} onViewTask={onViewTask} tasks={mockTasks} projectId="project-1" resumeSessionId="session-1" />);
expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-001");
await waitFor(() => expect(onTaskCreated).toHaveBeenCalledWith(mockTasks[0]));
expect(screen.getByRole("button", { name: "View task" })).toBeEnabled();
expect(await screen.findByTestId("planning-linked-task-note")).toHaveTextContent("FN-001");
expect(screen.getByTestId("planning-plan-review")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Proceed with plan" })).toBeInTheDocument();
expect(onTaskCreated).not.toHaveBeenCalled();
const viewTask = screen.getByRole("button", { name: "View task" });
expect(viewTask).toBeEnabled();
fireEvent.click(viewTask);
expect(onViewTask).toHaveBeenCalledWith(mockTasks[0]);
});
it("waits for a restored linked task before enabling its task handoff", async () => {
it("disables the linked-task banner action until the restored task is loaded", async () => {
mockFetchAiSession.mockResolvedValue({
...base,
status: "complete",
@@ -689,11 +700,67 @@ describe("PlanningModeModal sequential flow", () => {
const onTaskCreated = vi.fn();
render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={onTaskCreated} onTasksCreated={vi.fn()} onViewTask={vi.fn()} tasks={[]} projectId="project-1" resumeSessionId="session-1" />);
expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-LATER");
expect(await screen.findByTestId("planning-linked-task-note")).toHaveTextContent("FN-LATER");
expect(screen.getByRole("button", { name: "View task" })).toBeDisabled();
expect(onTaskCreated).not.toHaveBeenCalled();
});
/*
FNXC:PlanningMultiTask 2026-07-24-01:40:
Review findings: Continue planning must return to the editable plan review with a working
linked-task banner (resolving the just-created Task object, before the tasks prop refreshes),
and the banner must never leak across session switches.
*/
it("Continue planning returns from the task handoff to an editable plan review with a live banner", async () => {
mockFetchAiSession.mockResolvedValue({
...base,
status: "complete",
currentQuestion: null,
result: JSON.stringify(mockSummary),
inputPayload: JSON.stringify({ validated: true }),
});
mockCreateTaskFromPlanning.mockResolvedValue(mockTasks[0]);
// tasks={[]} proves the banner resolves the just-created Task object, not the tasks prop.
render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} onViewTask={vi.fn()} tasks={[]} projectId="project-1" resumeSessionId="session-1" />);
fireEvent.click(await screen.findByRole("button", { name: "Proceed with plan" }));
expect(await screen.findByTestId("planning-task-created")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Continue planning" }));
expect(await screen.findByTestId("planning-plan-review")).toBeInTheDocument();
expect(screen.getByTestId("planning-linked-task-note")).toHaveTextContent(mockTasks[0].id);
expect(screen.getByRole("button", { name: "View task" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Proceed with plan" })).toBeInTheDocument();
});
it("clears the linked-task banner when switching to a session without a created task", async () => {
mockFetchAiSession.mockImplementation(async (sessionId: string) => sessionId === "session-1"
? {
...base,
id: "session-1",
status: "complete",
currentQuestion: null,
result: JSON.stringify(mockSummary),
inputPayload: JSON.stringify({ validated: true, createdTaskId: "FN-001" }),
}
: {
...base,
id: "session-2",
status: "awaiting_input",
currentQuestion: null,
result: JSON.stringify(mockSummary),
inputPayload: "{}",
});
const props = { isOpen: true, onClose: vi.fn(), onTaskCreated: vi.fn(), onTasksCreated: vi.fn(), tasks: mockTasks, projectId: "project-1" };
const { rerender } = render(<PlanningModeModal {...props} resumeSessionId="session-1" />);
expect(await screen.findByTestId("planning-linked-task-note")).toBeInTheDocument();
rerender(<PlanningModeModal {...props} resumeSessionId="session-2" />);
await waitFor(() => expect(screen.queryByTestId("planning-linked-task-note")).toBeNull());
expect(screen.getByTestId("planning-plan-review")).toBeInTheDocument();
});
it("uses full-view Questions and Plan preview tabs on mobile", async () => {
mockViewportMode.mockReturnValue("mobile");
mockFetchAiSession.mockResolvedValue({

View File

@@ -111,10 +111,23 @@ function installContextAwareAgent() {
}
function createStore() {
let createdTask: { id: string; title: string; description: string } | undefined;
const createTask = vi.fn(async (input: { title: string; description: string }) => {
createdTask = { id: "FN-E2E-001", title: input.title, description: input.description };
return createdTask;
/*
FNXC:PlanningMultiTask 2026-07-24-01:40:
Tasks record their proposalClaimId and stay listed so findCreatedTask's epoch-keyed
crash-window reconciliation is exercised for real (review finding: only epoch 0 was
covered). `tasks` is exposed so tests can inject an orphaned row simulating a crash
between task insert and session finalize.
*/
const tasks: Array<{ id: string; title: string; description: string; proposalClaimId?: string }> = [];
const createTask = vi.fn(async (input: { title: string; description: string; proposalClaimId?: string }) => {
const created = {
id: `FN-E2E-00${tasks.length + 1}`,
title: input.title,
description: input.description,
proposalClaimId: input.proposalClaimId,
};
tasks.push(created);
return created;
});
return {
getSettings: vi.fn().mockResolvedValue({
@@ -123,15 +136,17 @@ function createStore() {
ntfyEnabled: false,
}),
getRootDir: vi.fn().mockReturnValue("/tmp/planning-e2e"),
listTasks: vi.fn(async () => createdTask ? [createdTask] : []),
listTasks: vi.fn(async () => [...tasks]),
getTask: vi.fn(async (id: string) => {
if (createdTask?.id === id) return createdTask;
const found = tasks.find((task) => task.id === id);
if (found) return found;
throw new Error("not found");
}),
createTask,
updateTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
} as unknown as TaskStore & { createTask: typeof createTask };
tasks,
} as unknown as TaskStore & { createTask: typeof createTask; tasks: typeof tasks };
}
function buildApp(store: TaskStore): express.Express {
@@ -227,6 +242,83 @@ describe("Planning Mode plan creation E2E", () => {
expect(store.createTask).toHaveBeenCalledTimes(1);
});
/*
FNXC:PlanningMultiTask 2026-07-24-00:20:
One plan can produce multiple tasks. Editing the plan after a task exists rotates the
creation epoch: the next Proceed creates a FRESH task under a new epoch-suffixed
proposalClaimId instead of replaying the first task, while unedited replays (above) stay
idempotent within their epoch.
*/
it("creates a second task after the plan is edited past the first one", async () => {
const start = await post(app, "/api/planning/start", { initialPlan: "Build secure account recovery" });
expect(start.status).toBe(201);
const sessionId = start.body.sessionId as string;
const created = await post(app, "/api/planning/create-task", { sessionId });
expect(created.status).toBe(201);
expect(store.createTask).toHaveBeenCalledTimes(1);
expect(store.createTask.mock.calls[0][0]).toMatchObject({ proposalClaimId: `planning-session:${sessionId}` });
// Editing the validated plan reopens it and rotates the creation epoch.
const refined = await post(app, "/api/planning/respond", { sessionId, responses: { refine: true, focus: "split rollout" } });
expect(refined.status).toBe(200);
const session = await getSession(sessionId);
expect(session?.taskCreationEpoch).toBe(1);
expect(session?.createdTaskIds).toEqual(["FN-E2E-001"]);
const second = await post(app, "/api/planning/create-task", { sessionId });
expect(second.status).toBe(201);
expect(second.body.alreadyCreated).toBe(false);
expect(store.createTask).toHaveBeenCalledTimes(2);
expect(store.createTask.mock.calls[1][0]).toMatchObject({ proposalClaimId: `planning-session:${sessionId}#1` });
/*
FNXC:PlanningMultiTask 2026-07-24-01:40:
Replay idempotency must hold INSIDE a rotated epoch too (review finding: only epoch 0's
replay was pinned): an unedited Proceed after the second create returns that same task.
*/
const replayAfterRotation = await post(app, "/api/planning/create-task", { sessionId });
expect(replayAfterRotation.status).toBe(200);
expect(replayAfterRotation.body.alreadyCreated).toBe(true);
expect(replayAfterRotation.body.task.id).toBe("FN-E2E-002");
expect(store.createTask).toHaveBeenCalledTimes(2);
});
/*
FNXC:PlanningMultiTask 2026-07-24-01:40:
Crash-after-insert recovery inside a ROTATED epoch: a task row exists under the epoch-1
claim key but the session linkage was never finalized. Proceed must reconcile that row via
findCreatedTask on the current epoch key instead of inserting a duplicate (review finding:
the crash-window regression test only covered the un-suffixed epoch-0 key).
*/
it("reconciles a crash-orphaned task row under an epoch-suffixed claim key instead of duplicating it", async () => {
const start = await post(app, "/api/planning/start", { initialPlan: "Build secure account recovery" });
expect(start.status).toBe(201);
const sessionId = start.body.sessionId as string;
const created = await post(app, "/api/planning/create-task", { sessionId });
expect(created.status).toBe(201);
expect(store.createTask).toHaveBeenCalledTimes(1);
const refined = await post(app, "/api/planning/respond", { sessionId, responses: { refine: true, focus: "split rollout" } });
expect(refined.status).toBe(200);
expect((await getSession(sessionId))?.taskCreationEpoch).toBe(1);
// Simulate the crash window: the epoch-1 task row landed, but finalize never ran.
store.tasks.push({
id: "FN-E2E-CRASH",
title: "Crash-orphaned second task",
description: "inserted before finalize",
proposalClaimId: `planning-session:${sessionId}#1`,
});
const retry = await post(app, "/api/planning/create-task", { sessionId });
expect(retry.status).toBe(200);
expect(retry.body.alreadyCreated).toBe(true);
expect(retry.body.task.id).toBe("FN-E2E-CRASH");
expect(store.createTask).toHaveBeenCalledTimes(1);
});
it("keeps AI-authored options and Other in the input language", async () => {
const start = await post(app, "/api/planning/start", { initialPlan: "Quiero crear una recuperación segura de cuentas" });
expect(start.status).toBe(201);

View File

@@ -40,7 +40,10 @@ import {
__setCreateFnAgent,
createSessionWithAgent,
getSession,
InvalidSessionStateError,
planningProposalClaimId,
planningStreamManager,
rewindSession,
setAiSessionStore,
submitResponse,
} from "../planning.js";
@@ -147,6 +150,129 @@ describe("planning question regeneration instead of no-active-question errors",
expect(session.currentQuestion).toBeDefined();
});
/*
FNXC:PlanningMultiTask 2026-07-24-00:20:
One plan can produce multiple tasks. Editing a plan whose current epoch already created a
task rotates the creation epoch (new proposalClaimId key, claim state reset, task recorded
in createdTaskIds) so the next Proceed creates a fresh task; a session without a created
task keeps its epoch so unedited Proceed replays stay idempotent.
*/
it("rotates the task-creation epoch when a task-linked plan is edited", async () => {
const { sessionId } = await startSessionAwaitingInput("10.2.0.10");
const session = (await getSession(sessionId))!;
session.validated = true;
session.createdTaskId = "FN-100";
session.createClaimStatus = "created";
session.currentQuestion = undefined;
expect(planningProposalClaimId(sessionId, session.taskCreationEpoch)).toBe(`planning-session:${sessionId}`);
const result = await submitResponse(sessionId, { refine: true }, "/tmp/project", undefined, MOCK_TASK_STORE);
expect(result.type).toBe("question");
expect(session.taskCreationEpoch).toBe(1);
expect(session.createdTaskId).toBeUndefined();
expect(session.createClaimStatus).toBe("none");
expect(session.createdTaskIds).toEqual(["FN-100"]);
expect(planningProposalClaimId(sessionId, session.taskCreationEpoch)).toBe(`planning-session:${sessionId}#1`);
// A second edit without a new created task must NOT rotate again.
const secondResult = await submitResponse(sessionId, { refine: true, focus: "smaller scope" }, "/tmp/project", undefined, MOCK_TASK_STORE);
expect(secondResult.type).toBe("question");
expect(session.taskCreationEpoch).toBe(1);
expect(session.createdTaskIds).toEqual(["FN-100"]);
});
/*
FNXC:PlanningMultiTask 2026-07-24-01:40:
Review findings: (a) rewindSession shares the rotation invariant with submitResponse and
needs its own regression coverage; (b) a REJECTED request must never burn a phantom
rotation — reopen/rotation only run after admission and preconditions pass.
*/
it("rotates the epoch when rewinding an answered question on a task-linked plan", async () => {
const { sessionId } = await startSessionAwaitingInput("10.2.0.11");
const session = (await getSession(sessionId))!;
const question = session.currentQuestion!;
await submitResponse(sessionId, { [question.id]: "first answer" }, "/tmp/project", undefined, MOCK_TASK_STORE);
expect(session.history.length).toBeGreaterThan(0);
session.validated = true;
session.createdTaskId = "FN-200";
session.createClaimStatus = "created";
await rewindSession(sessionId, undefined, "/tmp/project", undefined, MOCK_TASK_STORE);
expect(session.taskCreationEpoch).toBe(1);
expect(session.createdTaskIds).toEqual(["FN-200"]);
expect(session.createdTaskId).toBeUndefined();
expect(session.createClaimStatus).toBe("none");
expect(session.validated).toBe(false);
});
it("does not rotate the epoch when a rewind is rejected before admission", async () => {
const { sessionId } = await startSessionAwaitingInput("10.2.0.12");
const session = (await getSession(sessionId))!;
// Live task-linked session with NO history: rewind must reject without touching claim state.
session.validated = true;
session.createdTaskId = "FN-300";
session.createClaimStatus = "created";
session.history = [];
await expect(rewindSession(sessionId, undefined, "/tmp/project", undefined, MOCK_TASK_STORE))
.rejects.toBeInstanceOf(InvalidSessionStateError);
expect(session.taskCreationEpoch).toBeUndefined();
expect(session.createdTaskId).toBe("FN-300");
expect(session.createClaimStatus).toBe("created");
expect(session.validated).toBe(true);
});
/*
FNXC:PlanningMultiTask 2026-07-24-01:40:
Durable round-trip of the new epoch fields through buildSessionFromRow's normalization
(review finding: the positive-integer guard and string-array filter were untested).
*/
it("restores and normalizes taskCreationEpoch/createdTaskIds from a persisted row", async () => {
const baseRow = {
type: "planning",
status: "awaiting_input",
title: "Restored plan",
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const rows: Record<string, unknown> = {
"row-valid": {
...baseRow,
id: "row-valid",
inputPayload: JSON.stringify({ initialPlan: "Restore me", taskCreationEpoch: 2, createdTaskIds: ["FN-1", 42, "FN-2"] }),
},
"row-malformed": {
...baseRow,
id: "row-malformed",
inputPayload: JSON.stringify({ initialPlan: "Restore me", taskCreationEpoch: -3, createdTaskIds: "not-an-array" }),
},
};
setAiSessionStore(Object.assign(new EventEmitter(), {
upsert: vi.fn(async () => {}),
get: vi.fn(async (id: string) => rows[id] ?? null),
updateThinking: vi.fn(),
}) as never);
const valid = (await getSession("row-valid"))!;
expect(valid.taskCreationEpoch).toBe(2);
expect(valid.createdTaskIds).toEqual(["FN-1", "FN-2"]);
const malformed = (await getSession("row-malformed"))!;
expect(malformed.taskCreationEpoch).toBeUndefined();
expect(malformed.createdTaskIds).toBeUndefined();
});
it("contextual comments with no summary still apply via the rebuilt running summary", async () => {
const { sessionId } = await startSessionAwaitingInput("10.2.0.3");
const session = (await getSession(sessionId))!;

View File

@@ -43,6 +43,9 @@ vi.mock("../planning.js", () => ({
releasePlanningTaskCreation: vi.fn(async () => undefined),
// FNXC:PlanningMode 2026-07-23-12:10: create-task terminalizes the session after creation.
validateSession: vi.fn(async () => undefined),
// FNXC:PlanningMultiTask 2026-07-24-00:20: create-task derives an epoch-scoped proposalClaimId.
planningProposalClaimId: (sessionId: string, epoch?: number) =>
epoch && epoch > 0 ? `planning-session:${sessionId}#${epoch}` : `planning-session:${sessionId}`,
}));
function deferred<T>() {

View File

@@ -2915,6 +2915,58 @@ describe("Planning Mode Routes", () => {
});
});
/*
FNXC:PlanningMultiTask 2026-07-24-01:40:
Review finding: creating a task while a planning turn is still generating raced the
turn-completion persist against finalize and could tear the created-task linkage. The
route now rejects with 409 while the durable session status is "generating".
*/
it("rejects create-task with 409 while the session is still generating", async () => {
const sessionId = "planning-generating-409";
const generatingRow = {
id: sessionId,
type: "planning",
status: "generating",
title: "Still generating",
inputPayload: JSON.stringify({ initialPlan: "Build a thing" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify({
title: "Draft plan",
description: "Mid-turn running plan",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Implementation"],
}),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const mockAiSessionStore = {
on: vi.fn(),
upsert: vi.fn(),
get: vi.fn(async () => generatingRow),
listAll: vi.fn(() => []),
listActive: vi.fn(() => []),
};
const appWithAiSessionStore = express();
appWithAiSessionStore.use(express.json());
appWithAiSessionStore.use("/api", createApiRoutes(store, { aiSessionStore: mockAiSessionStore as any }));
const res = await REQUEST(
appWithAiSessionStore,
"POST",
"/api/planning/create-task",
JSON.stringify({ sessionId }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(409);
expect(store.createTask).not.toHaveBeenCalled();
});
it.each([
{
sessionSource: "live",

View File

@@ -168,8 +168,9 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
return row;
}
async reconcilePlanningTaskCreation(sessionId: string, taskId: string): Promise<AiSessionRow | null> {
const row = await reconcilePlanningSessionTaskCreation(this.dbAsync, sessionId, taskId) as AiSessionRow | null;
// FNXC:PlanningMultiTask 2026-07-24-01:40: expectedTaskCreationEpoch guards reconcile against a concurrent epoch rotation — see core reconcilePlanningSessionTaskCreation.
async reconcilePlanningTaskCreation(sessionId: string, taskId: string, expectedTaskCreationEpoch?: number): Promise<AiSessionRow | null> {
const row = await reconcilePlanningSessionTaskCreation(this.dbAsync, sessionId, taskId, expectedTaskCreationEpoch) as AiSessionRow | null;
if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt));
return row;
}

View File

@@ -307,6 +307,8 @@ export interface DraftInputPayload {
createClaimStatus?: "none" | "creating" | "created";
claimOwnerToken?: string;
claimStartedAt?: string;
taskCreationEpoch?: number;
createdTaskIds?: string[];
}
/** Session TTL in milliseconds (7 days) */
@@ -395,6 +397,16 @@ interface Session {
createClaimStatus?: "none" | "creating" | "created";
claimOwnerToken?: string;
claimStartedAt?: string;
/*
FNXC:PlanningMultiTask 2026-07-24-00:20:
One plan may produce multiple tasks. Each creation attempt belongs to an epoch: epoch 0 uses
the legacy `planning-session:{id}` proposalClaimId, epoch N uses `planning-session:{id}#N`.
Replaying Proceed without editing stays idempotent within the current epoch (same task,
alreadyCreated); editing the plan after a task exists ROTATES the epoch so the next Proceed
creates a fresh task. createdTaskIds records every task created from this plan.
*/
taskCreationEpoch?: number;
createdTaskIds?: string[];
/** Whether the current generation must end at plan review rather than a question. */
generationPurpose?: "initial_plan" | "plan_update" | "question";
/** Durable start time for the active turn so each concurrent session owns its elapsed clock. */
@@ -734,6 +746,8 @@ function persistSession(session: Session, status: "generating" | "awaiting_input
...(session.createClaimStatus ? { createClaimStatus: session.createClaimStatus } : {}),
...(session.claimOwnerToken ? { claimOwnerToken: session.claimOwnerToken } : {}),
...(session.claimStartedAt ? { claimStartedAt: session.claimStartedAt } : {}),
...(session.taskCreationEpoch ? { taskCreationEpoch: session.taskCreationEpoch } : {}),
...(session.createdTaskIds?.length ? { createdTaskIds: session.createdTaskIds } : {}),
...(typeof session.clarificationEnabled === "boolean"
? { clarificationEnabled: session.clarificationEnabled }
: {}),
@@ -909,6 +923,12 @@ function buildSessionFromRow(row: AiSessionRow): Session {
validated: payload.validated === true,
createdTaskId: typeof payload.createdTaskId === "string" ? payload.createdTaskId : undefined,
createClaimStatus: payload.createClaimStatus,
taskCreationEpoch: typeof payload.taskCreationEpoch === "number" && Number.isInteger(payload.taskCreationEpoch) && payload.taskCreationEpoch > 0
? payload.taskCreationEpoch
: undefined,
createdTaskIds: Array.isArray(payload.createdTaskIds)
? payload.createdTaskIds.filter((id): id is string => typeof id === "string")
: undefined,
claimOwnerToken: typeof payload.claimOwnerToken === "string" ? payload.claimOwnerToken : undefined,
claimStartedAt: typeof payload.claimStartedAt === "string" ? payload.claimStartedAt : undefined,
thinkingOutput: row.thinkingOutput,
@@ -3152,20 +3172,6 @@ export async function submitResponse(
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
}
/*
FNXC:PlanningReopenAfterValidate 2026-07-23-23:30:
A validated plan must never be a read-only dead end: the operator can keep refining,
commenting, or answering, and create the task whenever they choose. A new turn on a
validated session REOPENS it (clears the terminal marker; the turn's own
persistSession("generating") durably writes validated:false and moves the row out of
"complete"), rather than rejecting with "already been validated". validateSession remains
the only terminalizer, and a session whose task already exists keeps its one-task claim
(proposalClaimId is never rotated), so Proceed after re-editing returns the linked task.
*/
if (session.validated) {
session.validated = false;
}
// Stash store/rootDir on the session so subsequent ensureSessionAgent calls
// (after the agent is disposed for retry/rewind) can rebuild without the
// caller having to thread context through every API.
@@ -3181,6 +3187,28 @@ export async function submitResponse(
}
const releaseTurn = reservePlanningTurn(session.id);
/*
FNXC:PlanningReopenAfterValidate 2026-07-23-23:30:
A validated plan must never be a read-only dead end: the operator can keep refining,
commenting, or answering, and create the task whenever they choose. A new turn on a
validated session REOPENS it (clears the terminal marker; the turn's own
persistSession("generating") durably writes validated:false and moves the row out of
"complete"), rather than rejecting with "already been validated". validateSession remains
the only terminalizer.
FNXC:PlanningMultiTask 2026-07-24-01:40:
Reopen (validated flip) and epoch rotation run only AFTER turn admission succeeds. Review
finding (3 reviewers): mutating the shared in-memory session before the admission guards
meant a rejected request (GenerationInProgressError, duplicate submit) burned a phantom
rotation that was never persisted — in-memory epoch N+1 vs durable N — and a later Proceed
could derive the wrong claim key. Past admission, every branch reaches
persistSession("generating"), so the rotation always lands durably with its turn.
*/
if (session.validated) {
session.validated = false;
}
rotateTaskCreationEpochOnReopen(session);
/*
FNXC:PlanningRetry 2026-07-14-00:00:
Reported bug: planning got stuck cycling retry/regeneration after the user had already answered.
@@ -3458,11 +3486,6 @@ export async function rewindSession(
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
}
// FNXC:PlanningReopenAfterValidate 2026-07-23-23:30: editing an earlier answer reopens a validated plan — see submitResponse.
if (session.validated) {
session.validated = false;
}
if (store && !session.store) session.store = store;
if (rootDir && !session.rootDir) session.rootDir = rootDir;
@@ -3497,6 +3520,15 @@ export async function rewindSession(
}
const releaseTurn = reservePlanningTurn(session.id);
/*
FNXC:PlanningReopenAfterValidate 2026-07-23-23:30: editing an earlier answer reopens a validated plan — see submitResponse.
FNXC:PlanningMultiTask 2026-07-24-01:40: reopen + rotation only after admission and the empty-history precondition, so a rejected rewind never mutates claim state (see submitResponse).
*/
if (session.validated) {
session.validated = false;
}
rotateTaskCreationEpochOnReopen(session);
try {
/*
FNXC:PlanningTurnAdmission 2026-07-23-10:10:
@@ -3876,6 +3908,48 @@ function restoreClaimSession(row: import("./ai-session-store.js").AiSessionRow):
return restored;
}
/*
FNXC:PlanningMultiTask 2026-07-24-00:20:
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
Proceed without editing dedupes to the same task (alreadyCreated), while editing the plan
after a task exists rotates to a new epoch/key so the next Proceed creates a fresh task.
Epoch 0 keeps the legacy un-suffixed key so pre-existing linked sessions stay reconciled.
*/
export function planningProposalClaimId(sessionId: string, taskCreationEpoch?: number): string {
const epoch = taskCreationEpoch ?? 0;
return epoch > 0 ? `planning-session:${sessionId}#${epoch}` : `planning-session:${sessionId}`;
}
/*
Reopen-time rotation: a plan edited after its current epoch created a task starts a new epoch.
FNXC:PlanningMultiTask 2026-07-24-01:40:
Rotation is rotate-on-intent: it commits with the edit turn's persistSession("generating"),
so an edit turn that later FAILS still leaves the epoch advanced — a subsequent Proceed on
the unchanged plan then creates a second task with identical content. Accepted trade-off
(review finding): the alternative (rotate only on turn success) would let a failed edit
silently re-link the edited plan to the pre-edit task, which is worse. The guard below also
deliberately requires finalize's "created" status or a set createdTaskId, so a crash between
claim ("creating") and finalize can never rotate away from the epoch whose task row needs
reconciliation.
*/
function rotateTaskCreationEpochOnReopen(session: Session): void {
const currentEpochHasTask = Boolean(session.createdTaskId) || session.createClaimStatus === "created";
if (!currentEpochHasTask) return;
if (session.createdTaskId) {
const history = session.createdTaskIds ?? [];
if (!history.includes(session.createdTaskId)) {
session.createdTaskIds = [...history, session.createdTaskId];
}
}
session.taskCreationEpoch = (session.taskCreationEpoch ?? 0) + 1;
session.createdTaskId = undefined;
session.createClaimStatus = "none";
session.claimOwnerToken = undefined;
session.claimStartedAt = undefined;
}
/** 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);
@@ -3906,13 +3980,16 @@ export async function finalizePlanningTaskCreation(sessionId: string, ownerToken
return row ? restoreClaimSession(row) : undefined;
}
export async function reconcilePlanningTaskCreation(sessionId: string, taskId: string): Promise<Session | undefined> {
// FNXC:PlanningMultiTask 2026-07-24-01:40: expectedTaskCreationEpoch makes reconcile a no-op when the plan was edited (epoch rotated) since the task's claim key was derived — never re-link an archived task to the new epoch.
export async function reconcilePlanningTaskCreation(sessionId: string, taskId: string, expectedTaskCreationEpoch?: number): Promise<Session | undefined> {
if (!_aiSessionStore || typeof (_aiSessionStore as unknown as { reconcilePlanningTaskCreation?: unknown }).reconcilePlanningTaskCreation !== "function") {
const session = await getSession(sessionId);
if (session) Object.assign(session, { createClaimStatus: "created", createdTaskId: taskId, claimOwnerToken: undefined, claimStartedAt: undefined });
if (session && (expectedTaskCreationEpoch === undefined || (session.taskCreationEpoch ?? 0) === expectedTaskCreationEpoch)) {
Object.assign(session, { createClaimStatus: "created", createdTaskId: taskId, claimOwnerToken: undefined, claimStartedAt: undefined });
}
return session;
}
const row = await _aiSessionStore.reconcilePlanningTaskCreation(sessionId, taskId);
const row = await _aiSessionStore.reconcilePlanningTaskCreation(sessionId, taskId, expectedTaskCreationEpoch);
return row ? restoreClaimSession(row) : undefined;
}

View File

@@ -1188,6 +1188,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
reconcilePlanningTaskCreation,
releasePlanningTaskCreation,
validateSession,
planningProposalClaimId,
} = await import("../planning.js");
let session = await getSession(sessionId);
@@ -1245,27 +1246,75 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
}
releaseCreateLock = await acquirePlanningCreateLock(sessionId);
// Re-read after the local single-flight queue: an earlier caller may have finalized while we waited.
session = await getSession(sessionId);
/*
FNXC:PlanningMultiTask 2026-07-24-01:40:
Creating a task while a planning turn is still generating raced the turn's full-row
persistSession against finalize: the turn-completion write could clobber the fresh
createdTaskId linkage, which then disabled the next epoch rotation (review finding).
The durable status is the cross-process signal, so a generating session gets a clean
409 instead of a torn linkage; the client retries after the turn settles.
*/
if (aiSessionStore) {
// `await` tolerates sync-returning adapter stores; a failed read must not block creation.
let liveRow: { type?: string; status?: string } | null = null;
try {
liveRow = (await aiSessionStore.get(sessionId)) as { type?: string; status?: string } | null;
} catch {
liveRow = null;
}
if (liveRow?.type === "planning" && liveRow.status === "generating") {
throw conflict("Plan is still generating — wait for the current turn to finish, then create the task.");
}
}
// Re-read after the local single-flight queue: an earlier caller may have finalized while
// we waited. Durable-first so another process's epoch rotation (plan edited after a task
// was created) is honored when deriving this attempt's claim key; fall back to the
// in-memory read for adapters/rows the strict durable restore rejects.
try {
session = (await getDurablePlanningSession(sessionId)) ?? await getSession(sessionId);
} catch (durableReadError) {
/*
FNXC:PlanningMultiTask 2026-07-24-01:40:
The fallback must be loud: deriving the claim key from a stale in-memory epoch after a
silent durable-read failure can replay a prior epoch's task as alreadyCreated (bounded
degradation — never a fork, since rotation implies that epoch's task row exists).
*/
logPlanningCreateWarning(
"Planning create-task durable session read failed; falling back to in-memory session for claim-key derivation",
durableReadError,
{ sessionId },
);
session = await getSession(sessionId);
}
/*
FNXC:PlanningMode 2026-07-20-15:45:
FN-8442 derives the never-rotated key `planning-session:${sessionId}` at task creation.
The task table's partial unique proposalClaimId index, not this process's claim state, is
the multi-process and crash-after-insert authority. A session linkage is a durable cache
reconciled from that key; a missing linked task fails closed rather than silently forking.
FN-8442: the task table's partial unique proposalClaimId index, not this process's claim
state, is the multi-process and crash-after-insert authority. A session linkage is a
durable cache reconciled from that key; a missing linked task fails closed rather than
silently forking.
FNXC:PlanningMultiTask 2026-07-24-00:20:
The key is now per creation EPOCH (`planning-session:{id}` for epoch 0, `…#N` after the
plan is edited past a created task), so one plan can produce multiple tasks while
Proceed replays inside an epoch still dedupe to that epoch's task.
*/
const proposalClaimId = `planning-session:${sessionId}`;
const claimEpoch = session?.taskCreationEpoch ?? 0;
const proposalClaimId = planningProposalClaimId(sessionId, claimEpoch);
const findCreatedTask = async () =>
(await scopedStore.listTasks({ includeArchived: true })).find((candidate) => candidate.proposalClaimId === proposalClaimId);
/*
FNXC:PlanningMode 2026-07-23-12:10:
A planning session whose task exists is done: the claim model allows exactly one task per
session, so after creation the session must stop advertising awaiting_input in the session
list/banner. The Proceed-with-plan flow calls this route without the legacy /validate step,
so terminalize here through validateSession (the sole terminal transition) on every path
that ends with a created task, including alreadyCreated reconciliation. Best-effort: a
failure to terminalize must not fail the task creation itself.
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
multiple tasks across epochs (rotation happens when the plan is edited past a created
task). After each creation the session must stop advertising awaiting_input in the
session list/banner, so terminalize here through 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
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
the un-suffixed key and replay epoch 0's task instead of creating a new one (bounded
degradation, no duplicate).
*/
const markSessionComplete = () =>
runPlanningCreateSideEffect(
@@ -1288,7 +1337,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
// A task row is the crash-window authority. Reconcile it before trying to claim.
const existingTask = await findCreatedTask();
if (existingTask) {
await reconcilePlanningTaskCreation(sessionId, existingTask.id);
await reconcilePlanningTaskCreation(sessionId, existingTask.id, claimEpoch);
await markSessionComplete();
res.status(200).json({ task: existingTask, alreadyCreated: true });
return;
@@ -1315,7 +1364,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
session = await getDurablePlanningSession(sessionId) ?? session;
const recoveredTask = await findCreatedTask();
if (recoveredTask) {
await reconcilePlanningTaskCreation(sessionId, recoveredTask.id);
await reconcilePlanningTaskCreation(sessionId, recoveredTask.id, claimEpoch);
await markSessionComplete();
res.status(200).json({ task: recoveredTask, alreadyCreated: true });
return;