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:
7
.changeset/planning-multi-task-per-plan.md
Normal file
7
.changeset/planning-multi-task-per-plan.md
Normal 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.
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))!;
|
||||
|
||||
@@ -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>() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user