fix(dashboard): stop Planning Mode duplicate generations, 'AI returned no valid JSON' errors, and context-overflow failures (#2417)
## Symptom Users reported Planning Mode regularly failing with **"AI returned no valid JSON.. Retry this planning session or start a new one."**, and that leaving and returning to the interface mid-generation **duplicates the generation — infinitely**. Every app-tab switch unmounts the Planning view, so the leave/return path is the *normal* path, not an edge case. ## Root causes 1. **Check-then-act turn admission.** `activeGenerations.has()` was checked at turn entry, but the record was only created inside `runGenerationWithTimeout`, after several awaits. Overlapping turn entries (remounted view re-submitting, racing auto-retries, duplicate start of an existing session) both passed the guard; the second displaced the first, and the displaced teardown disposed the **session-shared agent the surviving turn was actively prompting** — which then read an empty assistant message and failed parse with "AI returned no valid JSON". 2. **Per-mount auto-retry budget.** The client reset its 3-attempt auto-retry budget on every mount, so each return to an errored session re-ran a full-turn regeneration (agent rebuild + complete history replay) — forever. 3. **SSE replay appended onto existing output.** Fresh stream connections replay buffered thinking; the client pre-seeded from persisted `thinkingOutput` (or kept prior output on silent reconnect) and then appended the replay — visibly doubling the generation on every reconnect. The 100-event buffer only held a suffix of a turn, forcing that pre-seed. 4. **Raw `session.prompt()` at context limits.** A long interview that overflowed the model's context window errored terminally, and auto-retry replayed the full history into a fresh agent — overflowing again, unrecoverably. ## Fix - Synchronous per-session **turn reservation** shared by `submitResponse`, `retrySession`, `startExistingSession`, and the initial turn; losers get `GenerationInProgressError` instead of displacing the winner. Duplicate starts of a generating session are no-ops. Rewind aborts an active generation through its own teardown first. - Client auto-retry budget is **module-scoped per session** (survives remounts); exhausted budget shows the error view instead of a stuck spinner. Retry rejections for "already in progress" rejoin the live run. - Fresh SSE connections **clear streamed output before the buffered replay**; buffer deepened to a full turn (2000 events); rejoin paths reconnect cleanly instead of seeding persisted thinking. - All six planning prompt sites route through the engine's **`promptWithFallback`**, recovering context-window overflows via prompt/memory compaction and `session.compact()`. - Cosmetic: no more doubled period in the retryable parse error message. ## Symptom Verification - **Original symptom:** "AI returned no valid JSON" after answering questions; generations duplicating on leave/return. - **Exact reproduction:** concurrent turn entries on one session (submit×2, retry×2, start-while-generating) — previously displaced/disposed the live agent mid-prompt. - **Assertion it is gone:** `planning-turn-admission.test.ts` asserts exactly one turn is admitted per race, the winner completes with a question and no session error, and the shared agent is never disposed; `planning-context-compaction.test.ts` asserts every planning prompt routes through `promptWithFallback` (signal forwarded) and that a recovered context overflow leaves the turn healthy. ## Verification - `vitest run` on all 7 planning server test files + the 2 new regression files: **40/40 pass**. - `routes-planning*` failures at main tip are pre-existing (identical 103/126 + 3/6 counts with and without this diff; main is mid-refactor on route wiring). `planning-answered-question-reemit` 3 timeouts also reproduce on clean main. - `pnpm verify:fast` green; dashboard `tsconfig.json` + `tsconfig.app.json` typechecks clean; eslint clean on touched files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Prevented Planning Mode from duplicating generations and triggering “AI returned no valid JSON” errors when leaving and re-entering mid-run. * Made planning turn handling concurrency-safe and idempotent across submit, retry, rewind, and duplicate start actions. * Improved SSE reconnect recovery: clearer replay after reconnect, no duplicated “thinking” output, and preserved auto-retry limits across remounts. * Improved long-context recovery via fallback prompting and cleaned up retry error formatting. * **Tests** * Expanded coverage for concurrent Planning actions, reconnect replay, context compaction, rewind behavior, and retry formatting; improved parallel test-harness reliability. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/planning-turn-admission.md
Normal file
7
.changeset/planning-turn-admission.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix Planning Mode duplicating generations and "AI returned no valid JSON" errors after leaving and returning mid-run.
|
||||||
|
category: fix
|
||||||
|
dev: Planning turns are admitted through a synchronous per-session reservation across submitResponse/retrySession/startExistingSession and the initial turn, so a racing entry is rejected instead of displacing the in-flight generation and disposing its agent mid-prompt. Duplicate starts of a generating session are no-ops, the client auto-retry budget survives view remounts (module-scoped per-session map), and SSE reconnects rebuild thinking output from a full-turn replay buffer (2000 events) instead of appending onto existing output. Planning prompts also route through the engine's promptWithFallback so context-window overflows recover via compaction instead of erroring the session.
|
||||||
@@ -79,6 +79,16 @@ const PLANNING_SIDEBAR_MAX_WIDTH = 560;
|
|||||||
const PLANNING_SIDEBAR_STORAGE_KEY = "fusion:planning-sidebar-width";
|
const PLANNING_SIDEBAR_STORAGE_KEY = "fusion:planning-sidebar-width";
|
||||||
|
|
||||||
const MAX_PLANNING_AUTO_RETRIES = 3;
|
const MAX_PLANNING_AUTO_RETRIES = 3;
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningRetry 2026-07-22-21:00:
|
||||||
|
The auto-retry budget must survive remounts. Every app-tab switch unmounts the Planning view,
|
||||||
|
and a ref-scoped budget was re-granted on each return, so a session stuck in a persisted error
|
||||||
|
state regenerated its full turn (agent rebuild + history replay) on every visit — the reported
|
||||||
|
"leave and come back duplicates the generation infinitely". Attempts are tracked per session in
|
||||||
|
module scope; success paths (question/summary/new session) still clear the entry.
|
||||||
|
*/
|
||||||
|
const planningAutoRetryAttemptsBySession = new Map<string, number>();
|
||||||
const MAX_PLANNING_CREATE_CLAIM_RETRIES = 20;
|
const MAX_PLANNING_CREATE_CLAIM_RETRIES = 20;
|
||||||
|
|
||||||
function isPlanningCreateClaimConflict(error: unknown): boolean {
|
function isPlanningCreateClaimConflict(error: unknown): boolean {
|
||||||
@@ -516,6 +526,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
|
|
||||||
const resetPlanningAutoRetryAttempts = useCallback(() => {
|
const resetPlanningAutoRetryAttempts = useCallback(() => {
|
||||||
planningAutoRetryAttemptRef.current = 0;
|
planningAutoRetryAttemptRef.current = 0;
|
||||||
|
// Clear the remount-durable budget too, so successful progress re-arms auto-retry.
|
||||||
|
const sessionId = currentSessionIdRef.current;
|
||||||
|
if (sessionId) planningAutoRetryAttemptsBySession.delete(sessionId);
|
||||||
setAutoRetryAttempt(0);
|
setAutoRetryAttempt(0);
|
||||||
setIsAutoRetrying(false);
|
setIsAutoRetrying(false);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1054,6 +1067,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
(sessionId: string) => {
|
(sessionId: string) => {
|
||||||
const streamEpoch = ++streamConnectionEpochRef.current;
|
const streamEpoch = ++streamConnectionEpochRef.current;
|
||||||
streamConnectionRef.current?.close();
|
streamConnectionRef.current?.close();
|
||||||
|
/*
|
||||||
|
FNXC:PlanningStreamCatchup 2026-07-22-21:00:
|
||||||
|
A brand-new stream connection replays the session's buffered thinking from the start
|
||||||
|
(the buffer is sized to hold a full turn). Whatever is currently on screen is a subset
|
||||||
|
of that replay, so it must be cleared first — appending the replay onto existing output
|
||||||
|
duplicated the visible generation on every reconnect (mobile tab switches unmount this
|
||||||
|
view, so this happened constantly).
|
||||||
|
*/
|
||||||
|
setStreamingOutput("");
|
||||||
|
streamingOutputRef.current = "";
|
||||||
// Guard handlers against late events from a connection the user has
|
// Guard handlers against late events from a connection the user has
|
||||||
// already navigated away from (e.g. clicked "New Session" while the
|
// already navigated away from (e.g. clicked "New Session" while the
|
||||||
// previous SSE flushed a buffered question). currentSessionIdRef is
|
// previous SSE flushed a buffered question). currentSessionIdRef is
|
||||||
@@ -1243,7 +1266,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
let retryError: unknown = err;
|
let retryError: unknown = err;
|
||||||
const retryErrorMessage = getErrorMessage(err) || "";
|
const retryErrorMessage = getErrorMessage(err) || "";
|
||||||
|
|
||||||
if (retryErrorMessage.includes("not in an error state")) {
|
// FNXC:PlanningTurnAdmission 2026-07-22-21:00: a retry rejected because another turn
|
||||||
|
// already holds the session's turn slot means work is in progress — rejoin it via the
|
||||||
|
// same session-refresh path instead of surfacing a terminal error.
|
||||||
|
if (retryErrorMessage.includes("not in an error state") || retryErrorMessage.includes("already in progress")) {
|
||||||
try {
|
try {
|
||||||
const session = await fetchAiSession(retryTarget.sessionId);
|
const session = await fetchAiSession(retryTarget.sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -1254,7 +1280,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
currentSessionIdRef.current = session.id;
|
currentSessionIdRef.current = session.id;
|
||||||
|
|
||||||
if (session.status === "generating") {
|
if (session.status === "generating") {
|
||||||
setStreamingOutput(session.thinkingOutput ?? "");
|
// FNXC:PlanningStreamCatchup 2026-07-22-21:00: rejoin through a clean stream
|
||||||
|
// (clear + buffered replay) instead of seeding persisted thinking alongside the
|
||||||
|
// in-flight replay, which raced and duplicated the visible output.
|
||||||
|
connectToPlanningStream(session.id);
|
||||||
setView({ type: "loading" });
|
setView({ type: "loading" });
|
||||||
} else if (session.status === "awaiting_input") {
|
} else if (session.status === "awaiting_input") {
|
||||||
if (!session.currentQuestion) {
|
if (!session.currentQuestion) {
|
||||||
@@ -1322,7 +1351,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
if (!retryStillOwnsSession()) return;
|
if (!retryStillOwnsSession()) return;
|
||||||
streamConnectionRef.current?.close();
|
streamConnectionRef.current?.close();
|
||||||
streamConnectionRef.current = null;
|
streamConnectionRef.current = null;
|
||||||
if (options.auto && planningAutoRetryAttemptRef.current < MAX_PLANNING_AUTO_RETRIES) {
|
if (options.auto && (planningAutoRetryAttemptsBySession.get(retryTarget.sessionId) ?? 0) < MAX_PLANNING_AUTO_RETRIES) {
|
||||||
viewRef.current = { type: "loading" };
|
viewRef.current = { type: "loading" };
|
||||||
setView({ type: "loading" });
|
setView({ type: "loading" });
|
||||||
setIsAutoRetrying(true);
|
setIsAutoRetrying(true);
|
||||||
@@ -1359,13 +1388,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (viewRef.current.type === "error") return false;
|
if (viewRef.current.type === "error") return false;
|
||||||
if (planningAutoRetryAttemptRef.current >= MAX_PLANNING_AUTO_RETRIES) {
|
// FNXC:PlanningRetry 2026-07-22-21:00: budget is per-session and survives remounts.
|
||||||
|
const priorAttempts = planningAutoRetryAttemptsBySession.get(sessionId) ?? 0;
|
||||||
|
if (priorAttempts >= MAX_PLANNING_AUTO_RETRIES) {
|
||||||
setIsAutoRetrying(false);
|
setIsAutoRetrying(false);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const attempt = planningAutoRetryAttemptRef.current + 1;
|
const attempt = priorAttempts + 1;
|
||||||
const retryToken = Symbol(`planning-auto-retry:${sessionId}:${attempt}`);
|
const retryToken = Symbol(`planning-auto-retry:${sessionId}:${attempt}`);
|
||||||
|
planningAutoRetryAttemptsBySession.set(sessionId, attempt);
|
||||||
planningAutoRetryAttemptRef.current = attempt;
|
planningAutoRetryAttemptRef.current = attempt;
|
||||||
planningAutoRetryOwnerRef.current = { sessionId, token: retryToken };
|
planningAutoRetryOwnerRef.current = { sessionId, token: retryToken };
|
||||||
setAutoRetryAttempt(attempt);
|
setAutoRetryAttempt(attempt);
|
||||||
@@ -1622,10 +1654,26 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
through the same generation retry path used by live stream failures while preserving the
|
through the same generation retry path used by live stream failures while preserving the
|
||||||
hydrated running plan and the existing bounded single-flight protection.
|
hydrated running plan and the existing bounded single-flight protection.
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
FNXC:PlanningRetry 2026-07-22-21:00:
|
||||||
|
Do NOT reset the auto-retry budget here. Loading an errored session happens on every
|
||||||
|
remount (each app-tab switch unmounts this view), and a per-mount reset turned the
|
||||||
|
bounded three-attempt budget into an unbounded regeneration loop. The module-scoped
|
||||||
|
per-session budget carries across remounts; only real progress clears it.
|
||||||
|
*/
|
||||||
if (planningAutoRetryOwnerRef.current?.sessionId !== sessionId) {
|
if (planningAutoRetryOwnerRef.current?.sessionId !== sessionId) {
|
||||||
resetPlanningAutoRetryAttempts();
|
planningAutoRetryAttemptRef.current = planningAutoRetryAttemptsBySession.get(sessionId) ?? 0;
|
||||||
|
setAutoRetryAttempt(planningAutoRetryAttemptRef.current);
|
||||||
|
}
|
||||||
|
const autoRetryStarted = await startPlanningAutoRetry(sessionId);
|
||||||
|
if (!autoRetryStarted) {
|
||||||
|
// Budget exhausted: surface the persisted error with a manual Retry affordance.
|
||||||
|
setView({
|
||||||
|
type: "error",
|
||||||
|
session: { sessionId, currentQuestion: null, summary: persistedRunningSummary },
|
||||||
|
errorMessage: session.error || t("planning.sessionFailed", "Session failed while contacting the AI."),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await startPlanningAutoRetry(sessionId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1708,7 +1756,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
}
|
}
|
||||||
} else if (session.status === "generating") {
|
} else if (session.status === "generating") {
|
||||||
setView({ type: "loading" });
|
setView({ type: "loading" });
|
||||||
if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput);
|
// FNXC:PlanningStreamCatchup 2026-07-22-21:00: no pre-seed from persisted
|
||||||
|
// thinkingOutput — the stream replay reconstructs the loading view exactly once;
|
||||||
|
// seeding here and then replaying doubled the visible output on every reload.
|
||||||
connectToPlanningStream(sessionId);
|
connectToPlanningStream(sessionId);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningContextCompaction 2026-07-22-22:40:
|
||||||
|
Planning prompts must route through the engine's promptWithFallback so context-window
|
||||||
|
overflows recover via compaction instead of surfacing "prompt is too long" as a terminal
|
||||||
|
session error (whose auto-retry replays the FULL history and overflows again). These tests
|
||||||
|
pin the invariant: when the engine exposes promptWithFallback, every planning agent prompt —
|
||||||
|
initial turn, answer turns, reformat retries — goes through it, with the generation
|
||||||
|
AbortSignal forwarded; and a context-limit error recovered inside promptWithFallback leaves
|
||||||
|
the planning turn healthy.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import type { TaskStore } from "@fusion/core";
|
||||||
|
|
||||||
|
const promptWithFallbackCalls: Array<{ prompt: string; options?: { signal?: AbortSignal } }> = [];
|
||||||
|
let simulateContextRecoveryOnce = false;
|
||||||
|
|
||||||
|
vi.mock("@fusion/engine", () => ({
|
||||||
|
listCliAdapterDescriptors: () => [],
|
||||||
|
resolveMcpServersForStore: async () => ({ servers: [] }),
|
||||||
|
buildSessionSkillContextSync: () => ({
|
||||||
|
skillSelectionContext: undefined,
|
||||||
|
resolvedSkillNames: ["fusion"],
|
||||||
|
skillSource: "role-fallback" as const,
|
||||||
|
}),
|
||||||
|
createFnAgent: vi.fn(),
|
||||||
|
createWorkflowAuthoringTools: () => [],
|
||||||
|
createChatTaskDocumentTools: () => [],
|
||||||
|
createChatTaskLogsReadTool: () => ({}),
|
||||||
|
promptWithFallback: vi.fn(async (
|
||||||
|
agentSession: { prompt: (input: string, options?: { signal?: AbortSignal }) => Promise<void> },
|
||||||
|
prompt: string,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
|
) => {
|
||||||
|
promptWithFallbackCalls.push({ prompt, options });
|
||||||
|
if (simulateContextRecoveryOnce) {
|
||||||
|
// Mirror the engine contract: the raw prompt overflows, compaction recovers,
|
||||||
|
// and the retried prompt succeeds — the caller sees one successful await.
|
||||||
|
simulateContextRecoveryOnce = false;
|
||||||
|
try {
|
||||||
|
throw new Error("prompt is too long: 210000 tokens > 200000 maximum");
|
||||||
|
} catch {
|
||||||
|
// compacted; fall through to the retried prompt below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await agentSession.prompt(prompt, options);
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
__resetPlanningState,
|
||||||
|
__setCreateFnAgent,
|
||||||
|
createSessionWithAgent,
|
||||||
|
getSession,
|
||||||
|
planningStreamManager,
|
||||||
|
setAiSessionStore,
|
||||||
|
submitResponse,
|
||||||
|
} from "../planning.js";
|
||||||
|
|
||||||
|
const MOCK_TASK_STORE = {
|
||||||
|
listTasks: vi.fn(async () => []),
|
||||||
|
getSettings: vi.fn(async () => ({})),
|
||||||
|
getTask: vi.fn(async () => {
|
||||||
|
throw new Error("not found");
|
||||||
|
}),
|
||||||
|
} as unknown as TaskStore;
|
||||||
|
|
||||||
|
const QUESTION_JSON = JSON.stringify({
|
||||||
|
type: "question",
|
||||||
|
data: { id: "q-next", type: "single_select", question: "What next?" },
|
||||||
|
});
|
||||||
|
|
||||||
|
function createFakeAgent() {
|
||||||
|
const messages: Array<{ role: string; content: string }> = [];
|
||||||
|
const prompt = vi.fn(async () => {
|
||||||
|
messages.push({ role: "assistant", content: QUESTION_JSON });
|
||||||
|
});
|
||||||
|
return { session: { state: { messages }, prompt, dispose: vi.fn() } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(predicate: () => Promise<boolean> | boolean, attempts = 50): Promise<void> {
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
|
if (await predicate()) return;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
}
|
||||||
|
throw new Error("condition not reached");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("planning context-window compaction routing", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetPlanningState();
|
||||||
|
promptWithFallbackCalls.length = 0;
|
||||||
|
simulateContextRecoveryOnce = false;
|
||||||
|
setAiSessionStore(Object.assign(new EventEmitter(), {
|
||||||
|
upsert: vi.fn(async () => {}),
|
||||||
|
get: vi.fn(async () => null),
|
||||||
|
updateThinking: vi.fn(),
|
||||||
|
}) as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes every planning prompt through promptWithFallback and forwards the abort signal", async () => {
|
||||||
|
const agent = createFakeAgent();
|
||||||
|
__setCreateFnAgent(vi.fn(async () => agent) as never);
|
||||||
|
|
||||||
|
const sessionId = await createSessionWithAgent(
|
||||||
|
"10.0.7.7",
|
||||||
|
"Plan a long feature",
|
||||||
|
"/tmp/project",
|
||||||
|
MOCK_TASK_STORE,
|
||||||
|
);
|
||||||
|
planningStreamManager.consumeInitialTurn(sessionId)?.();
|
||||||
|
await waitFor(async () => Boolean((await getSession(sessionId))?.currentQuestion));
|
||||||
|
|
||||||
|
expect(promptWithFallbackCalls.length).toBeGreaterThan(0);
|
||||||
|
const initialCall = promptWithFallbackCalls[0];
|
||||||
|
expect(initialCall.options?.signal).toBeInstanceOf(AbortSignal);
|
||||||
|
|
||||||
|
const question = (await getSession(sessionId))!.currentQuestion!;
|
||||||
|
const beforeTurn = promptWithFallbackCalls.length;
|
||||||
|
await submitResponse(sessionId, { [question.id]: "option-1" });
|
||||||
|
|
||||||
|
// The answer turn also went through the context-limit-aware path, signal included.
|
||||||
|
expect(promptWithFallbackCalls.length).toBeGreaterThan(beforeTurn);
|
||||||
|
const turnCall = promptWithFallbackCalls[beforeTurn];
|
||||||
|
expect(turnCall.options?.signal).toBeInstanceOf(AbortSignal);
|
||||||
|
// Planning never bypasses the wrapper: every raw prompt was issued by the wrapper itself.
|
||||||
|
expect(agent.session.prompt).toHaveBeenCalledTimes(promptWithFallbackCalls.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the turn healthy when promptWithFallback recovers from a context overflow", async () => {
|
||||||
|
const agent = createFakeAgent();
|
||||||
|
__setCreateFnAgent(vi.fn(async () => agent) as never);
|
||||||
|
|
||||||
|
const sessionId = await createSessionWithAgent(
|
||||||
|
"10.0.7.7",
|
||||||
|
"Plan a long feature",
|
||||||
|
"/tmp/project",
|
||||||
|
MOCK_TASK_STORE,
|
||||||
|
);
|
||||||
|
planningStreamManager.consumeInitialTurn(sessionId)?.();
|
||||||
|
await waitFor(async () => Boolean((await getSession(sessionId))?.currentQuestion));
|
||||||
|
|
||||||
|
const question = (await getSession(sessionId))!.currentQuestion!;
|
||||||
|
simulateContextRecoveryOnce = true;
|
||||||
|
const response = await submitResponse(sessionId, { [question.id]: "option-1" });
|
||||||
|
|
||||||
|
expect(response.type).toBe("question");
|
||||||
|
const session = await getSession(sessionId);
|
||||||
|
expect(session?.error).toBeUndefined();
|
||||||
|
expect(session?.currentQuestion).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
328
packages/dashboard/src/__tests__/planning-turn-admission.test.ts
Normal file
328
packages/dashboard/src/__tests__/planning-turn-admission.test.ts
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningTurnAdmission 2026-07-22-21:00:
|
||||||
|
Regression tests for the single-turn admission invariant. Reported bug: leaving and
|
||||||
|
re-entering Planning Mode mid-generation (every app-tab switch unmounts the view) raced a
|
||||||
|
second turn entry against the in-flight one; the loser displaced the winner and disposed the
|
||||||
|
session-shared agent mid-prompt, surfacing "AI returned no valid JSON" and visibly duplicated
|
||||||
|
generations. The invariant: at most one turn per session is ever admitted, across ALL entry
|
||||||
|
points (submitResponse, retrySession, startExistingSession/initial turn), and the losing entry
|
||||||
|
is rejected or ignored without touching the winner's agent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import type { TaskStore } from "@fusion/core";
|
||||||
|
|
||||||
|
vi.mock("@fusion/engine", () => ({
|
||||||
|
listCliAdapterDescriptors: () => [],
|
||||||
|
resolveMcpServersForStore: async () => ({ servers: [] }),
|
||||||
|
buildSessionSkillContextSync: () => ({
|
||||||
|
skillSelectionContext: undefined,
|
||||||
|
resolvedSkillNames: ["fusion"],
|
||||||
|
skillSource: "role-fallback" as const,
|
||||||
|
}),
|
||||||
|
createFnAgent: vi.fn(),
|
||||||
|
createWorkflowAuthoringTools: () => [],
|
||||||
|
createChatTaskDocumentTools: () => [],
|
||||||
|
createChatTaskLogsReadTool: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
__resetPlanningState,
|
||||||
|
__setCreateFnAgent,
|
||||||
|
createSessionWithAgent,
|
||||||
|
GenerationInProgressError,
|
||||||
|
getSession,
|
||||||
|
planningStreamManager,
|
||||||
|
retrySession,
|
||||||
|
rewindSession,
|
||||||
|
setAiSessionStore,
|
||||||
|
startExistingSession,
|
||||||
|
submitResponse,
|
||||||
|
} from "../planning.js";
|
||||||
|
|
||||||
|
const MOCK_TASK_STORE = {
|
||||||
|
listTasks: vi.fn(async () => []),
|
||||||
|
getSettings: vi.fn(async () => ({})),
|
||||||
|
getTask: vi.fn(async () => {
|
||||||
|
throw new Error("not found");
|
||||||
|
}),
|
||||||
|
} as unknown as TaskStore;
|
||||||
|
|
||||||
|
const QUESTION_JSON = JSON.stringify({
|
||||||
|
type: "question",
|
||||||
|
data: { id: "q-next", type: "single_select", question: "What next?" },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ScriptedAgent {
|
||||||
|
agent: { session: { state: { messages: Array<{ role: string; content: string }> }; prompt: ReturnType<typeof vi.fn>; dispose: ReturnType<typeof vi.fn> } };
|
||||||
|
holdNextPrompt: () => void;
|
||||||
|
releasePrompt: () => void;
|
||||||
|
setResponder: (fn: () => string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createScriptedAgent(): ScriptedAgent {
|
||||||
|
const messages: Array<{ role: string; content: string }> = [];
|
||||||
|
let gate: Promise<void> | null = null;
|
||||||
|
let releaseGate: (() => void) | null = null;
|
||||||
|
let responder: () => string = () => QUESTION_JSON;
|
||||||
|
|
||||||
|
const prompt = vi.fn(async () => {
|
||||||
|
if (gate) {
|
||||||
|
const pending = gate;
|
||||||
|
gate = null;
|
||||||
|
await pending;
|
||||||
|
}
|
||||||
|
messages.push({ role: "assistant", content: responder() });
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
agent: { session: { state: { messages }, prompt, dispose: vi.fn() } },
|
||||||
|
holdNextPrompt: () => {
|
||||||
|
gate = new Promise<void>((resolve) => {
|
||||||
|
releaseGate = resolve;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
releasePrompt: () => {
|
||||||
|
releaseGate?.();
|
||||||
|
releaseGate = null;
|
||||||
|
},
|
||||||
|
setResponder: (fn: () => string) => {
|
||||||
|
responder = fn;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(predicate: () => Promise<boolean> | boolean, attempts = 50): Promise<void> {
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
|
if (await predicate()) return;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
}
|
||||||
|
throw new Error("condition not reached");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSessionAwaitingInput(scripted: ScriptedAgent): Promise<string> {
|
||||||
|
__setCreateFnAgent(vi.fn(async () => scripted.agent) as never);
|
||||||
|
const sessionId = await createSessionWithAgent(
|
||||||
|
"10.0.9.9",
|
||||||
|
"Plan something small",
|
||||||
|
"/tmp/project",
|
||||||
|
MOCK_TASK_STORE,
|
||||||
|
);
|
||||||
|
planningStreamManager.consumeInitialTurn(sessionId)?.();
|
||||||
|
await waitFor(async () => Boolean((await getSession(sessionId))?.currentQuestion));
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("planning single-turn admission", () => {
|
||||||
|
let storeGet: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetPlanningState();
|
||||||
|
storeGet = vi.fn(async () => null);
|
||||||
|
setAiSessionStore(Object.assign(new EventEmitter(), {
|
||||||
|
upsert: vi.fn(async () => {}),
|
||||||
|
get: storeGet,
|
||||||
|
updateThinking: vi.fn(),
|
||||||
|
}) as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a concurrent submitResponse instead of displacing the in-flight turn", async () => {
|
||||||
|
const scripted = createScriptedAgent();
|
||||||
|
const sessionId = await startSessionAwaitingInput(scripted);
|
||||||
|
const question = (await getSession(sessionId))!.currentQuestion!;
|
||||||
|
|
||||||
|
scripted.holdNextPrompt();
|
||||||
|
const first = submitResponse(sessionId, { [question.id]: "option-1" });
|
||||||
|
const second = submitResponse(sessionId, { [question.id]: "option-2" });
|
||||||
|
|
||||||
|
const [firstResult, secondResult] = await Promise.allSettled([
|
||||||
|
first,
|
||||||
|
second.finally(() => scripted.releasePrompt()),
|
||||||
|
]);
|
||||||
|
scripted.releasePrompt();
|
||||||
|
|
||||||
|
expect(secondResult.status).toBe("rejected");
|
||||||
|
expect((secondResult as PromiseRejectedResult).reason).toBeInstanceOf(GenerationInProgressError);
|
||||||
|
|
||||||
|
// The admitted turn must complete cleanly: its agent was never disposed mid-prompt and
|
||||||
|
// it produced the next question rather than "AI returned no valid JSON".
|
||||||
|
expect(firstResult.status).toBe("fulfilled");
|
||||||
|
expect((firstResult as PromiseFulfilledResult<{ type: string }>).value.type).toBe("question");
|
||||||
|
const session = await getSession(sessionId);
|
||||||
|
expect(session?.error).toBeUndefined();
|
||||||
|
expect(session?.currentQuestion).toBeDefined();
|
||||||
|
expect(scripted.agent.session.dispose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admits exactly one of two racing retries", async () => {
|
||||||
|
const scripted = createScriptedAgent();
|
||||||
|
const sessionId = await startSessionAwaitingInput(scripted);
|
||||||
|
const session = (await getSession(sessionId))!;
|
||||||
|
session.error = "AI returned no valid JSON. Retry this planning session or start a new one.";
|
||||||
|
// Both retries must pass the persisted error-state check so the synchronous turn
|
||||||
|
// reservation — not the winner's error-clearing side effect — is what rejects the loser.
|
||||||
|
storeGet.mockImplementation(async () => ({ id: sessionId, type: "planning", status: "error" }));
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
retrySession(sessionId, "/tmp/project", undefined, MOCK_TASK_STORE),
|
||||||
|
retrySession(sessionId, "/tmp/project", undefined, MOCK_TASK_STORE),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rejected = results.filter((result) => result.status === "rejected");
|
||||||
|
expect(rejected).toHaveLength(1);
|
||||||
|
expect((rejected[0] as PromiseRejectedResult).reason).toBeInstanceOf(GenerationInProgressError);
|
||||||
|
|
||||||
|
const after = await getSession(sessionId);
|
||||||
|
expect(after?.error).toBeUndefined();
|
||||||
|
expect(after?.currentQuestion).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a duplicate start of a generating session as a no-op", async () => {
|
||||||
|
const scripted = createScriptedAgent();
|
||||||
|
__setCreateFnAgent(vi.fn(async () => scripted.agent) as never);
|
||||||
|
|
||||||
|
scripted.holdNextPrompt();
|
||||||
|
const sessionId = await createSessionWithAgent(
|
||||||
|
"10.0.9.9",
|
||||||
|
"Plan something small",
|
||||||
|
"/tmp/project",
|
||||||
|
MOCK_TASK_STORE,
|
||||||
|
);
|
||||||
|
planningStreamManager.consumeInitialTurn(sessionId)?.();
|
||||||
|
await waitFor(() => scripted.agent.session.prompt.mock.calls.length > 0);
|
||||||
|
|
||||||
|
// A remounted client re-issuing start-streaming must not displace the live generation.
|
||||||
|
await startExistingSession(sessionId, "/tmp/project", MOCK_TASK_STORE);
|
||||||
|
expect(planningStreamManager.hasPendingInitialTurn(sessionId)).toBe(false);
|
||||||
|
expect(scripted.agent.session.dispose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
scripted.releasePrompt();
|
||||||
|
await waitFor(async () => Boolean((await getSession(sessionId))?.currentQuestion));
|
||||||
|
const session = await getSession(sessionId);
|
||||||
|
expect(session?.error).toBeUndefined();
|
||||||
|
expect(scripted.agent.session.dispose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves two concurrent duplicate starts without an initial-turn registration error", async () => {
|
||||||
|
const scripted = createScriptedAgent();
|
||||||
|
__setCreateFnAgent(vi.fn(async () => scripted.agent) as never);
|
||||||
|
|
||||||
|
scripted.holdNextPrompt();
|
||||||
|
const sessionId = await createSessionWithAgent(
|
||||||
|
"10.0.9.9",
|
||||||
|
"Plan something small",
|
||||||
|
"/tmp/project",
|
||||||
|
MOCK_TASK_STORE,
|
||||||
|
);
|
||||||
|
planningStreamManager.consumeInitialTurn(sessionId)?.();
|
||||||
|
await waitFor(() => scripted.agent.session.prompt.mock.calls.length > 0);
|
||||||
|
|
||||||
|
// FNXC:PlanningTurnAdmission 2026-07-23-10:10:
|
||||||
|
// Two racing duplicate starts must BOTH be quiet no-ops — the pre-fix code let the
|
||||||
|
// second reach registerInitialTurn and throw "Initial planning turn already registered".
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
startExistingSession(sessionId, "/tmp/project", MOCK_TASK_STORE),
|
||||||
|
startExistingSession(sessionId, "/tmp/project", MOCK_TASK_STORE),
|
||||||
|
]);
|
||||||
|
expect(results.every((result) => result.status === "fulfilled")).toBe(true);
|
||||||
|
expect(planningStreamManager.hasPendingInitialTurn(sessionId)).toBe(false);
|
||||||
|
|
||||||
|
scripted.releasePrompt();
|
||||||
|
await waitFor(async () => Boolean((await getSession(sessionId))?.currentQuestion));
|
||||||
|
expect((await getSession(sessionId))?.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewind waits for the cancelled turn to release before mutating state", async () => {
|
||||||
|
const scripted = createScriptedAgent();
|
||||||
|
const sessionId = await startSessionAwaitingInput(scripted);
|
||||||
|
const question = (await getSession(sessionId))!.currentQuestion!;
|
||||||
|
|
||||||
|
scripted.holdNextPrompt();
|
||||||
|
const submit = submitResponse(sessionId, { [question.id]: "option-1" });
|
||||||
|
await waitFor(() => scripted.agent.session.prompt.mock.calls.length > 1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningTurnAdmission 2026-07-23-10:10:
|
||||||
|
Rewind aborts the in-flight turn, waits for its owner to release the turn slot AND for
|
||||||
|
the cancelled operation (the held provider prompt) to settle, then republishes the
|
||||||
|
answered question as awaiting input. The prompt is released after rewind starts so the
|
||||||
|
settle-wait resolves deterministically; awaiting `submit` afterwards proves the cancelled
|
||||||
|
turn's post-prompt abort checks bail out without corrupting the rewound state.
|
||||||
|
*/
|
||||||
|
const rewindPromise = rewindSession(sessionId, undefined, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||||
|
scripted.releasePrompt();
|
||||||
|
const rewound = await rewindPromise;
|
||||||
|
expect(rewound.currentQuestion.id).toBe(question.id);
|
||||||
|
|
||||||
|
await submit;
|
||||||
|
|
||||||
|
const session = await getSession(sessionId);
|
||||||
|
expect(session?.currentQuestion?.id).toBe(question.id);
|
||||||
|
expect(session?.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores late streaming callbacks from a disposed agent after rewind", async () => {
|
||||||
|
/*
|
||||||
|
FNXC:PlanningTurnAdmission 2026-07-23-10:40:
|
||||||
|
A provider that ignores cancellation beyond rewind's bounded settle-wait can keep
|
||||||
|
streaming into its onThinking/onText callbacks after the agent was disposed. Those late
|
||||||
|
deltas must be inert — no thinkingOutput mutation, no broadcast — or they would corrupt
|
||||||
|
the state published after the rewound question (PR #2417 review finding).
|
||||||
|
*/
|
||||||
|
const scripted = createScriptedAgent();
|
||||||
|
const capturedOnThinking: Array<(delta: string) => void> = [];
|
||||||
|
__setCreateFnAgent(vi.fn(async (options: { onThinking?: (delta: string) => void }) => {
|
||||||
|
if (options?.onThinking) capturedOnThinking.push(options.onThinking);
|
||||||
|
return scripted.agent;
|
||||||
|
}) as never);
|
||||||
|
|
||||||
|
const sessionId = await createSessionWithAgent(
|
||||||
|
"10.0.9.9",
|
||||||
|
"Plan something small",
|
||||||
|
"/tmp/project",
|
||||||
|
MOCK_TASK_STORE,
|
||||||
|
);
|
||||||
|
planningStreamManager.consumeInitialTurn(sessionId)?.();
|
||||||
|
await waitFor(async () => Boolean((await getSession(sessionId))?.currentQuestion));
|
||||||
|
const question = (await getSession(sessionId))!.currentQuestion!;
|
||||||
|
|
||||||
|
scripted.holdNextPrompt();
|
||||||
|
const submit = submitResponse(sessionId, { [question.id]: "option-1" });
|
||||||
|
await waitFor(() => scripted.agent.session.prompt.mock.calls.length > 1);
|
||||||
|
|
||||||
|
const rewindPromise = rewindSession(sessionId, undefined, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||||
|
scripted.releasePrompt();
|
||||||
|
await rewindPromise;
|
||||||
|
await submit;
|
||||||
|
|
||||||
|
// The first (disposed) agent's streaming callback fires late — it must be a no-op.
|
||||||
|
const staleEvents: unknown[] = [];
|
||||||
|
const unsubscribe = planningStreamManager.subscribe(sessionId, (event) => staleEvents.push(event));
|
||||||
|
expect(capturedOnThinking.length).toBeGreaterThan(0);
|
||||||
|
capturedOnThinking[0]("stale delta after dispose");
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
const session = await getSession(sessionId);
|
||||||
|
expect(session?.thinkingOutput).toBe("");
|
||||||
|
expect(staleEvents).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits the retryable parse error without a doubled period", async () => {
|
||||||
|
const scripted = createScriptedAgent();
|
||||||
|
const sessionId = await startSessionAwaitingInput(scripted);
|
||||||
|
const question = (await getSession(sessionId))!.currentQuestion!;
|
||||||
|
|
||||||
|
// Both the original turn and the single reformat retry return JSON-free prose.
|
||||||
|
scripted.setResponder(() => "je ne peux pas produire de JSON ici");
|
||||||
|
await submitResponse(sessionId, { [question.id]: "option-1" });
|
||||||
|
|
||||||
|
const session = await getSession(sessionId);
|
||||||
|
expect(session?.error).toBe(
|
||||||
|
"AI returned no valid JSON. Retry this planning session or start a new one.",
|
||||||
|
);
|
||||||
|
expect(session?.error).not.toContain("..");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -371,6 +371,15 @@ interface Session {
|
|||||||
ip: string;
|
ip: string;
|
||||||
initialPlan: string;
|
initialPlan: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
/*
|
||||||
|
FNXC:PlanningTurnAdmission 2026-07-23-10:40:
|
||||||
|
Monotonic in-memory epoch for streaming-callback invalidation. Bumped every time the
|
||||||
|
session's agent is disposed/replaced; agent onThinking/onText closures capture the epoch at
|
||||||
|
agent creation and no-op when it has moved on. Closes the residual PR #2417 window where a
|
||||||
|
provider that ignores cancellation beyond rewind's bounded settle-wait could persist or
|
||||||
|
broadcast stale thinking deltas after the rewound state was published. Never persisted.
|
||||||
|
*/
|
||||||
|
agentCallbackEpoch?: number;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
/** Workflow selected at session start, retained for agent reconstruction. */
|
/** Workflow selected at session start, retained for agent reconstruction. */
|
||||||
workflowId?: string;
|
workflowId?: string;
|
||||||
@@ -457,6 +466,125 @@ interface ActivePlanningGeneration {
|
|||||||
/** Active planning generations keyed by session ID. */
|
/** Active planning generations keyed by session ID. */
|
||||||
const activeGenerations = new Map<string, ActivePlanningGeneration>();
|
const activeGenerations = new Map<string, ActivePlanningGeneration>();
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningTurnAdmission 2026-07-22-21:00:
|
||||||
|
Reported bug: "AI returned no valid JSON" recurred whenever the Planning UI was left and
|
||||||
|
re-entered mid-generation (mobile tab switches unmount the view), and generations visibly
|
||||||
|
duplicated. Root cause: the `activeGenerations.has()` guard is check-then-act across several
|
||||||
|
awaits (persistSession/ensureSessionAgent) — the ActivePlanningGeneration record only exists
|
||||||
|
once runGenerationWithTimeout runs. Two overlapping turn entries (re-submitted answer from a
|
||||||
|
remounted view, racing auto-retries, duplicate start of an existing session) both passed the
|
||||||
|
guard; the second displaced the first, and the displaced teardown disposed the session-shared
|
||||||
|
agent that the surviving turn was actively prompting, so the surviving turn read an empty
|
||||||
|
assistant message and failed parse with "AI returned no valid JSON".
|
||||||
|
Invariant: at most one turn may be admitted per session at any time, enforced SYNCHRONOUSLY
|
||||||
|
(no await between check and reservation). Every generation entry point (submitResponse,
|
||||||
|
retrySession, startExistingSession, initializeAgent) must hold a reservation for its full span,
|
||||||
|
so a concurrent entry is rejected with GenerationInProgressError instead of displacing a
|
||||||
|
healthy in-flight generation.
|
||||||
|
*/
|
||||||
|
interface PlanningTurnReservation {
|
||||||
|
done: Promise<void>;
|
||||||
|
resolveDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingTurnReservations = new Map<string, PlanningTurnReservation>();
|
||||||
|
|
||||||
|
function isPlanningTurnActive(sessionId: string): boolean {
|
||||||
|
return activeGenerations.has(sessionId) || pendingTurnReservations.has(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synchronously reserve the session's single turn slot; returns the release fn. */
|
||||||
|
function reservePlanningTurn(sessionId: string): () => void {
|
||||||
|
if (isPlanningTurnActive(sessionId)) {
|
||||||
|
throw new GenerationInProgressError("Generation already in progress");
|
||||||
|
}
|
||||||
|
let resolveDone!: () => void;
|
||||||
|
const done = new Promise<void>((resolve) => {
|
||||||
|
resolveDone = resolve;
|
||||||
|
});
|
||||||
|
const reservation: PlanningTurnReservation = { done, resolveDone };
|
||||||
|
pendingTurnReservations.set(sessionId, reservation);
|
||||||
|
return () => {
|
||||||
|
if (pendingTurnReservations.get(sessionId) === reservation) {
|
||||||
|
pendingTurnReservations.delete(sessionId);
|
||||||
|
}
|
||||||
|
reservation.resolveDone();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningTurnAdmission 2026-07-23-08:30:
|
||||||
|
User-takes-control actions (rewind/edit) abort the in-flight generation and must then WAIT for
|
||||||
|
that turn's owner to unwind and release its reservation before mutating session state.
|
||||||
|
Deleting the active-generation record without waiting left both admission sets empty while the
|
||||||
|
aborted turn was still unwinding, so a concurrent submit/retry could interleave with rewind's
|
||||||
|
own awaits and corrupt question/history/agent state (review finding on PR #2417).
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
FNXC:PlanningTurnAdmission 2026-07-23-10:10:
|
||||||
|
The turn reservation is released as soon as the abort wins runGenerationWithTimeout's race,
|
||||||
|
but the generation OPERATION (which holds the raw provider prompt) can still be pending if the
|
||||||
|
provider ignores the AbortSignal. User-takes-control paths (rewind) must also wait — bounded —
|
||||||
|
for that operation to settle before disposing/replacing the agent, or a slow provider callback
|
||||||
|
could still be running against the mutable session while the rewound state is published
|
||||||
|
(review finding on PR #2417). Tracked separately from reservations because settlement can
|
||||||
|
outlive the owner's release.
|
||||||
|
*/
|
||||||
|
const settlingTurnOperations = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
function trackTurnOperationSettled(sessionId: string, operationPromise: Promise<unknown>): void {
|
||||||
|
const settled = operationPromise.then(
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
settlingTurnOperations.set(sessionId, settled);
|
||||||
|
void settled.then(() => {
|
||||||
|
if (settlingTurnOperations.get(sessionId) === settled) {
|
||||||
|
settlingTurnOperations.delete(sessionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounded wait for a cancelled turn's operation (including its provider prompt) to settle.
|
||||||
|
* Returns false on timeout — callers proceed anyway: agent disposal is the backstop, the
|
||||||
|
* cancelled closure's post-prompt abort checks prevent state writes, and blocking a user's
|
||||||
|
* rewind forever on an unresponsive provider would be worse.
|
||||||
|
*/
|
||||||
|
async function waitForTurnOperationSettled(sessionId: string, timeoutMs = 2000): Promise<boolean> {
|
||||||
|
const settling = settlingTurnOperations.get(sessionId);
|
||||||
|
if (!settling) return true;
|
||||||
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
const timedOut = await Promise.race([
|
||||||
|
settling.then(() => false),
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
timer = setTimeout(() => resolve(true), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
clearTimeout(timer);
|
||||||
|
return !timedOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForPlanningTurnRelease(sessionId: string, timeoutMs = 2000): Promise<boolean> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (isPlanningTurnActive(sessionId)) {
|
||||||
|
const remaining = deadline - Date.now();
|
||||||
|
if (remaining <= 0) return false;
|
||||||
|
const reservation = pendingTurnReservations.get(sessionId);
|
||||||
|
if (reservation) {
|
||||||
|
await Promise.race([
|
||||||
|
reservation.done,
|
||||||
|
new Promise((resolve) => setTimeout(resolve, Math.min(remaining, 50))),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
// Active generation whose owner has not yet reached its release — brief yield.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ── AI Session Persistence ────────────────────────────────────────────────
|
// ── AI Session Persistence ────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Optional store for persisting session state across reloads/browsers. */
|
/** Optional store for persisting session state across reloads/browsers. */
|
||||||
@@ -953,6 +1081,11 @@ export class PlanningStreamManager extends EventEmitter {
|
|||||||
this.pendingInitialTurns.set(sessionId, start);
|
this.pendingInitialTurns.set(sessionId, start);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True while a registered initial turn has not been consumed by a stream connection yet. */
|
||||||
|
hasPendingInitialTurn(sessionId: string): boolean {
|
||||||
|
return this.pendingInitialTurns.has(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
consumeInitialTurn(sessionId: string): (() => void) | undefined {
|
consumeInitialTurn(sessionId: string): (() => void) | undefined {
|
||||||
const start = this.pendingInitialTurns.get(sessionId);
|
const start = this.pendingInitialTurns.get(sessionId);
|
||||||
if (!start) {
|
if (!start) {
|
||||||
@@ -998,7 +1131,16 @@ export class PlanningStreamManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton instance of the planning stream manager */
|
/** Singleton instance of the planning stream manager */
|
||||||
export const planningStreamManager = new PlanningStreamManager();
|
/*
|
||||||
|
FNXC:PlanningStreamCatchup 2026-07-22-21:00:
|
||||||
|
Reconnecting clients (mobile tab switches unmount the Planning view, so every return opens a
|
||||||
|
fresh SSE connection) rebuild the loading view exclusively from the buffered-event replay.
|
||||||
|
The default 100-event buffer only held a suffix of a turn's thinking deltas, which forced the
|
||||||
|
client to pre-seed from persisted thinkingOutput and then receive the replay again — the
|
||||||
|
"generation duplicates every time I come back" report. The buffer must be deep enough to hold
|
||||||
|
a full turn of thinking deltas so replay alone reconstructs the view exactly once.
|
||||||
|
*/
|
||||||
|
export const planningStreamManager = new PlanningStreamManager(2000);
|
||||||
|
|
||||||
// ── Rate Limiting ───────────────────────────────────────────────────────────
|
// ── Rate Limiting ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1181,8 +1323,8 @@ async function getFirstQuestionFromAgent(
|
|||||||
throw new InvalidSessionStateError("AI agent not initialized");
|
throw new InvalidSessionStateError("AI agent not initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send message to agent
|
// Send message to agent (context-limit aware — see promptPlanningAgent)
|
||||||
await session.agent.session.prompt(message);
|
await promptPlanningAgent(session.agent.session, message);
|
||||||
|
|
||||||
// Extract response text
|
// Extract response text
|
||||||
interface AgentMessage {
|
interface AgentMessage {
|
||||||
@@ -1246,7 +1388,8 @@ async function getFirstQuestionFromAgent(
|
|||||||
|
|
||||||
if (attempt < MAX_PARSE_RETRIES) {
|
if (attempt < MAX_PARSE_RETRIES) {
|
||||||
try {
|
try {
|
||||||
await session.agent.session.prompt(
|
await promptPlanningAgent(
|
||||||
|
session.agent.session,
|
||||||
"Your previous response could not be parsed as JSON. " +
|
"Your previous response could not be parsed as JSON. " +
|
||||||
'Please respond with ONLY a valid JSON object: {"type":"question","data":{"runningPlan":{...},...}}. ' +
|
'Please respond with ONLY a valid JSON object: {"type":"question","data":{"runningPlan":{...},...}}. ' +
|
||||||
"No markdown, no explanation, just the JSON."
|
"No markdown, no explanation, just the JSON."
|
||||||
@@ -1332,7 +1475,8 @@ async function requestMandatoryFirstPlanningQuestion(
|
|||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
): Promise<{ type: "question"; data: PlanningQuestion }> {
|
): Promise<{ type: "question"; data: PlanningQuestion }> {
|
||||||
try {
|
try {
|
||||||
await (session.agent!.session.prompt as (input: string, options?: { signal?: AbortSignal }) => Promise<void>)(
|
await promptPlanningAgent(
|
||||||
|
session.agent!.session,
|
||||||
'Before producing a plan, ask one clarifying question. Return ONLY valid JSON: {"type":"question","data":{...}}.',
|
'Before producing a plan, ask one clarifying question. Return ONLY valid JSON: {"type":"question","data":{...}}.',
|
||||||
{ signal: abortSignal },
|
{ signal: abortSignal },
|
||||||
);
|
);
|
||||||
@@ -1610,8 +1754,32 @@ export async function startExistingSession(
|
|||||||
session.clarificationEnabled = runtimeOptions.clarificationEnabled === true;
|
session.clarificationEnabled = runtimeOptions.clarificationEnabled === true;
|
||||||
session.ntfyConfig = runtimeOptions.ntfyConfig;
|
session.ntfyConfig = runtimeOptions.ntfyConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningTurnAdmission 2026-07-22-21:00:
|
||||||
|
Starting an existing session must be idempotent while its generation is in flight. A
|
||||||
|
remounted client (mobile tab switches unmount the Planning view) could re-issue
|
||||||
|
start-streaming for a session that is already generating; previously this displaced the
|
||||||
|
live generation and disposed its agent mid-prompt. Now the duplicate start is a no-op and
|
||||||
|
the client simply reconnects to the stream of the run already in progress.
|
||||||
|
*/
|
||||||
|
if (isPlanningTurnActive(sessionId) || planningStreamManager.hasPendingInitialTurn(sessionId)) {
|
||||||
|
diagnostics.warn("Ignoring duplicate start for planning session with an active generation", {
|
||||||
|
sessionId,
|
||||||
|
operation: "start-existing-duplicate",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningTurnAdmission 2026-07-23-08:30:
|
||||||
|
The duplicate-start guard and the initial-turn registration must be one synchronous block —
|
||||||
|
no await between them. With persistSession in between, two concurrent duplicate starts could
|
||||||
|
both pass the guard and the second registerInitialTurn threw "Initial planning turn already
|
||||||
|
registered" for a normal duplicate start (review finding on PR #2417). Registration IS the
|
||||||
|
claim; persistence follows it.
|
||||||
|
*/
|
||||||
beginPlanningGeneration(session, "initial_plan");
|
beginPlanningGeneration(session, "initial_plan");
|
||||||
await persistSession(session, "generating");
|
|
||||||
planningStreamManager.registerInitialTurn(sessionId, () => {
|
planningStreamManager.registerInitialTurn(sessionId, () => {
|
||||||
session.pluginRunner = pluginRunner;
|
session.pluginRunner = pluginRunner;
|
||||||
initializeAgent(session, rootDir, store, modelProvider, modelId, session.draftThinkingLevel, promptOverrides, pluginRunner).catch((err) => {
|
initializeAgent(session, rootDir, store, modelProvider, modelId, session.draftThinkingLevel, promptOverrides, pluginRunner).catch((err) => {
|
||||||
@@ -1623,6 +1791,7 @@ export async function startExistingSession(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await persistSession(session, "generating");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1732,6 +1901,25 @@ async function initializeAgent(
|
|||||||
promptOverrides?: PromptOverrideMap,
|
promptOverrides?: PromptOverrideMap,
|
||||||
pluginRunner?: SkillPluginRunner,
|
pluginRunner?: SkillPluginRunner,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
/*
|
||||||
|
FNXC:PlanningTurnAdmission 2026-07-22-21:00:
|
||||||
|
The initial turn reserves the session's single turn slot synchronously (this runs inside
|
||||||
|
consumeInitialTurn's synchronous callback). A duplicate initial turn racing an admitted
|
||||||
|
generation exits quietly instead of displacing it and disposing its agent mid-prompt.
|
||||||
|
*/
|
||||||
|
let releaseTurn: () => void;
|
||||||
|
try {
|
||||||
|
releaseTurn = reservePlanningTurn(session.id);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof GenerationInProgressError) {
|
||||||
|
diagnostics.warn("Skipping duplicate initial planning turn — generation already active", {
|
||||||
|
sessionId: session.id,
|
||||||
|
operation: "initialize-agent-duplicate",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await runGenerationWithTimeout(session, async (abortSignal) => {
|
await runGenerationWithTimeout(session, async (abortSignal) => {
|
||||||
/*
|
/*
|
||||||
@@ -1789,6 +1977,8 @@ async function initializeAgent(
|
|||||||
type: "error",
|
type: "error",
|
||||||
data: errorMessage,
|
data: errorMessage,
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
releaseTurn();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1809,6 +1999,16 @@ async function createPlanningAgent(
|
|||||||
|
|
||||||
const skillContext = buildSessionSkillContextSync(null, "executor", rootDir, pluginRunner);
|
const skillContext = buildSessionSkillContextSync(null, "executor", rootDir, pluginRunner);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningTurnAdmission 2026-07-23-10:40:
|
||||||
|
Capture the callback epoch at agent creation. A provider that ignores cancellation beyond
|
||||||
|
rewind's bounded settle-wait can keep streaming after this agent is disposed; the epoch
|
||||||
|
check makes those late deltas inert (no thinkingOutput mutation, no persist, no broadcast)
|
||||||
|
instead of letting them corrupt the state published after the agent was replaced.
|
||||||
|
*/
|
||||||
|
const callbackEpoch = session.agentCallbackEpoch ?? 0;
|
||||||
|
const callbacksInvalidated = (): boolean => (session.agentCallbackEpoch ?? 0) !== callbackEpoch;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:PlanningSkills 2026-06-17-19:33:
|
FNXC:PlanningSkills 2026-06-17-19:33:
|
||||||
Streaming planning sessions share the executor skill contract because custom planning/workflow tools can benefit from agent-declared skills and enabled plugin skills exactly like task execution.
|
Streaming planning sessions share the executor skill contract because custom planning/workflow tools can benefit from agent-declared skills and enabled plugin skills exactly like task execution.
|
||||||
@@ -1838,6 +2038,7 @@ async function createPlanningAgent(
|
|||||||
: {}),
|
: {}),
|
||||||
...(thinkingLevel ? { defaultThinkingLevel: thinkingLevel } : {}),
|
...(thinkingLevel ? { defaultThinkingLevel: thinkingLevel } : {}),
|
||||||
onThinking: (delta: string) => {
|
onThinking: (delta: string) => {
|
||||||
|
if (callbacksInvalidated()) return;
|
||||||
markPlanningGenerationProgress(session.id, delta);
|
markPlanningGenerationProgress(session.id, delta);
|
||||||
session.thinkingOutput += delta;
|
session.thinkingOutput += delta;
|
||||||
persistThinking(session.id, session.thinkingOutput);
|
persistThinking(session.id, session.thinkingOutput);
|
||||||
@@ -1850,6 +2051,7 @@ async function createPlanningAgent(
|
|||||||
// Capture AI response text — will be parsed at end of turn. Also
|
// Capture AI response text — will be parsed at end of turn. Also
|
||||||
// surface it through the same stream so non-thinking models (which
|
// surface it through the same stream so non-thinking models (which
|
||||||
// never emit thinking_delta) still show streaming output in the UI.
|
// never emit thinking_delta) still show streaming output in the UI.
|
||||||
|
if (callbacksInvalidated()) return;
|
||||||
markPlanningGenerationProgress(session.id, delta);
|
markPlanningGenerationProgress(session.id, delta);
|
||||||
session.thinkingOutput += delta;
|
session.thinkingOutput += delta;
|
||||||
persistThinking(session.id, session.thinkingOutput);
|
persistThinking(session.id, session.thinkingOutput);
|
||||||
@@ -1919,7 +2121,7 @@ async function ensureSessionAgent(
|
|||||||
if (abortSignal.aborted) {
|
if (abortSignal.aborted) {
|
||||||
throw createAbortError();
|
throw createAbortError();
|
||||||
}
|
}
|
||||||
await (session.agent!.session.prompt as (input: string, options?: { signal?: AbortSignal }) => Promise<void>)(contextMessage, {
|
await promptPlanningAgent(session.agent!.session, contextMessage, {
|
||||||
signal: abortSignal,
|
signal: abortSignal,
|
||||||
});
|
});
|
||||||
if (abortSignal.aborted) {
|
if (abortSignal.aborted) {
|
||||||
@@ -1968,8 +2170,11 @@ Planning Mode malformed AI output must either recover through the bounded reform
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function buildRetryableParseErrorMessage(error: Error | undefined): string {
|
function buildRetryableParseErrorMessage(error: Error | undefined): string {
|
||||||
|
// Strip any trailing "Please try again." suffix AND trailing periods so the appended
|
||||||
|
// call to action cannot render as "…no valid JSON.. Retry this planning session…".
|
||||||
const baseMessage = (error?.message || "Failed to parse AI response")
|
const baseMessage = (error?.message || "Failed to parse AI response")
|
||||||
.replace(/\s*Please try again\.?\s*$/i, "")
|
.replace(/\s*Please try again\.?\s*$/i, "")
|
||||||
|
.replace(/[.\s]+$/, "")
|
||||||
.trim();
|
.trim();
|
||||||
return `${baseMessage}. Retry this planning session or start a new one.`;
|
return `${baseMessage}. Retry this planning session or start a new one.`;
|
||||||
}
|
}
|
||||||
@@ -1997,6 +2202,37 @@ function createAbortError(): Error {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningContextCompaction 2026-07-22-22:40:
|
||||||
|
Long planning interviews accumulate the whole Q/A history in one agent session and can hit the
|
||||||
|
model's context window mid-interview. Planning previously called session.prompt() raw, so a
|
||||||
|
context overflow surfaced as a terminal session error — and the auto-retry then replayed the
|
||||||
|
FULL history into a fresh agent, overflowing again, unrecoverably. Every planning prompt now
|
||||||
|
routes through the engine's promptWithFallback, which classifies context-limit errors and
|
||||||
|
recovers via prompt/memory compaction and session.compact() before retrying. Test fakes that
|
||||||
|
mock @fusion/engine without promptWithFallback fall back to the raw prompt unchanged.
|
||||||
|
*/
|
||||||
|
async function promptPlanningAgent(
|
||||||
|
agentSession: { prompt: (input: string, options?: { signal?: AbortSignal }) => Promise<void> },
|
||||||
|
message: string,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
|
): Promise<void> {
|
||||||
|
let promptWithFallback: ((session: unknown, prompt: string, options?: unknown) => Promise<void>) | undefined;
|
||||||
|
try {
|
||||||
|
promptWithFallback = (engineModule as {
|
||||||
|
promptWithFallback?: (session: unknown, prompt: string, options?: unknown) => Promise<void>;
|
||||||
|
}).promptWithFallback;
|
||||||
|
} catch {
|
||||||
|
// vi.mock("@fusion/engine") proxies throw on undeclared exports; treat as unavailable.
|
||||||
|
promptWithFallback = undefined;
|
||||||
|
}
|
||||||
|
if (typeof promptWithFallback === "function") {
|
||||||
|
await promptWithFallback(agentSession, message, options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await agentSession.prompt(message, options);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeGenerationProgress(output: string): string {
|
function normalizeGenerationProgress(output: string): string {
|
||||||
return output.replace(/\s+/g, " ").trim();
|
return output.replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
@@ -2087,7 +2323,11 @@ async function runGenerationWithTimeout<T>(session: Session, operation: (abortSi
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await Promise.race([operation(abortController.signal), abortPromise]);
|
// Track the operation's own settlement: on abort the race settles immediately while the
|
||||||
|
// operation (and its provider prompt) may still be pending — rewind waits on this.
|
||||||
|
const operationPromise = operation(abortController.signal);
|
||||||
|
trackTurnOperationSettled(session.id, operationPromise);
|
||||||
|
return await Promise.race([operationPromise, abortPromise]);
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(generationRecord.timer);
|
clearTimeout(generationRecord.timer);
|
||||||
if (activeGenerations.get(session.id) === generationRecord) {
|
if (activeGenerations.get(session.id) === generationRecord) {
|
||||||
@@ -2319,7 +2559,7 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
|||||||
if (abortSignal.aborted) {
|
if (abortSignal.aborted) {
|
||||||
throw createAbortError();
|
throw createAbortError();
|
||||||
}
|
}
|
||||||
await (session.agent.session.prompt as (input: string, options?: { signal?: AbortSignal }) => Promise<void>)(message, {
|
await promptPlanningAgent(session.agent.session, message, {
|
||||||
signal: abortSignal,
|
signal: abortSignal,
|
||||||
});
|
});
|
||||||
if (abortSignal.aborted) {
|
if (abortSignal.aborted) {
|
||||||
@@ -2393,7 +2633,8 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
|||||||
if (abortSignal.aborted) {
|
if (abortSignal.aborted) {
|
||||||
throw createAbortError();
|
throw createAbortError();
|
||||||
}
|
}
|
||||||
await (session.agent.session.prompt as (input: string, options?: { signal?: AbortSignal }) => Promise<void>)(
|
await promptPlanningAgent(
|
||||||
|
session.agent.session,
|
||||||
"Your previous response could not be parsed as JSON. " +
|
"Your previous response could not be parsed as JSON. " +
|
||||||
'Please respond with ONLY a valid JSON object: {"type":"question","data":{"runningPlan":{...},...}}. ' +
|
'Please respond with ONLY a valid JSON object: {"type":"question","data":{"runningPlan":{...},...}}. ' +
|
||||||
'No markdown, no explanation, just the JSON.',
|
'No markdown, no explanation, just the JSON.',
|
||||||
@@ -2788,12 +3029,14 @@ export async function submitResponse(
|
|||||||
if (store && !session.store) session.store = store;
|
if (store && !session.store) session.store = store;
|
||||||
if (rootDir && !session.rootDir) session.rootDir = rootDir;
|
if (rootDir && !session.rootDir) session.rootDir = rootDir;
|
||||||
|
|
||||||
if (activeGenerations.has(session.id)) {
|
// FNXC:PlanningTurnAdmission 2026-07-22-21:00: synchronous single-turn admission — see reservePlanningTurn.
|
||||||
|
if (isPlanningTurnActive(session.id)) {
|
||||||
if (didSubmitSameAnswer(session, responses)) {
|
if (didSubmitSameAnswer(session, responses)) {
|
||||||
throw new GenerationInProgressError("Generation already in progress for this response");
|
throw new GenerationInProgressError("Generation already in progress for this response");
|
||||||
}
|
}
|
||||||
throw new GenerationInProgressError("Generation already in progress");
|
throw new GenerationInProgressError("Generation already in progress");
|
||||||
}
|
}
|
||||||
|
const releaseTurn = reservePlanningTurn(session.id);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:PlanningRetry 2026-07-14-00:00:
|
FNXC:PlanningRetry 2026-07-14-00:00:
|
||||||
@@ -2810,6 +3053,7 @@ export async function submitResponse(
|
|||||||
*/
|
*/
|
||||||
let answeredQuestion: PlanningQuestion | undefined;
|
let answeredQuestion: PlanningQuestion | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
if (isRefineRequest(responses) && session.summary) {
|
if (isRefineRequest(responses) && session.summary) {
|
||||||
// Refinement steers which question comes next; it is never an answer to the
|
// Refinement steers which question comes next; it is never an answer to the
|
||||||
// currently displayed question and therefore must not create a history entry.
|
// currently displayed question and therefore must not create a history entry.
|
||||||
@@ -2874,6 +3118,9 @@ export async function submitResponse(
|
|||||||
: formatResponseForAgent(currentQuestion, responses);
|
: formatResponseForAgent(currentQuestion, responses);
|
||||||
await continueAgentConversation(session, message);
|
await continueAgentConversation(session, message);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
releaseTurn();
|
||||||
|
}
|
||||||
|
|
||||||
// Return the current state (will be updated via SSE)
|
// Return the current state (will be updated via SSE)
|
||||||
if (session.currentQuestion) {
|
if (session.currentQuestion) {
|
||||||
@@ -2927,6 +3174,15 @@ export async function retrySession(
|
|||||||
throw new InvalidSessionStateError(`Planning session ${sessionId} is not in an error state`);
|
throw new InvalidSessionStateError(`Planning session ${sessionId} is not in an error state`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningTurnAdmission 2026-07-22-21:00:
|
||||||
|
Two racing retries (e.g. auto-retry from a remounted Planning view plus a second tab) could
|
||||||
|
both read status "error" before either persisted "generating"; the loser then disposed the
|
||||||
|
agent the winner was actively prompting, producing the empty-response "AI returned no valid
|
||||||
|
JSON" failure. Admission is reserved synchronously before any turn state is touched.
|
||||||
|
*/
|
||||||
|
const releaseTurn = reservePlanningTurn(session.id);
|
||||||
|
try {
|
||||||
disposeSessionAgentForRetry(session);
|
disposeSessionAgentForRetry(session);
|
||||||
|
|
||||||
session.error = undefined;
|
session.error = undefined;
|
||||||
@@ -2958,6 +3214,9 @@ export async function retrySession(
|
|||||||
coerceResponseRecord(lastEntry.question, lastEntry.response),
|
coerceResponseRecord(lastEntry.question, lastEntry.response),
|
||||||
);
|
);
|
||||||
await continueAgentConversation(session, replayMessage);
|
await continueAgentConversation(session, replayMessage);
|
||||||
|
} finally {
|
||||||
|
releaseTurn();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlanningRewindResult {
|
export interface PlanningRewindResult {
|
||||||
@@ -2988,6 +3247,52 @@ export async function rewindSession(
|
|||||||
throw new InvalidSessionStateError("Planning session has no previous question to rewind to");
|
throw new InvalidSessionStateError("Planning session has no previous question to rewind to");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningTurnAdmission 2026-07-22-21:00:
|
||||||
|
Rewind/edit is a user-takes-control action. Cancel any in-flight generation through its own
|
||||||
|
abort/teardown path (like validateSession) before disposing the agent, so the turn cannot
|
||||||
|
keep running against a disposed session and surface "AI returned no valid JSON".
|
||||||
|
|
||||||
|
FNXC:PlanningTurnAdmission 2026-07-23-08:30:
|
||||||
|
After the abort, WAIT for the cancelled turn's owner to unwind and release its reservation,
|
||||||
|
then hold the reservation for rewind's own span. Without this, both admission sets were
|
||||||
|
empty during rewind's awaits and a concurrent submit/retry could interleave and corrupt
|
||||||
|
question/history/agent state; the cancelled turn's post-prompt abort checks plus this
|
||||||
|
serialization keep a slow provider prompt from outliving the rewound state (PR #2417).
|
||||||
|
All state mutation (including history.pop) happens only after admission succeeds.
|
||||||
|
*/
|
||||||
|
const activeGeneration = activeGenerations.get(session.id);
|
||||||
|
if (activeGeneration) {
|
||||||
|
activeGeneration.abortReason = "user-stop";
|
||||||
|
clearTimeout(activeGeneration.timer);
|
||||||
|
activeGeneration.abortTeardown();
|
||||||
|
activeGeneration.abortController.abort();
|
||||||
|
activeGenerations.delete(session.id);
|
||||||
|
}
|
||||||
|
if (!(await waitForPlanningTurnRelease(session.id))) {
|
||||||
|
throw new GenerationInProgressError("Generation already in progress");
|
||||||
|
}
|
||||||
|
const releaseTurn = reservePlanningTurn(session.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
/*
|
||||||
|
FNXC:PlanningTurnAdmission 2026-07-23-10:10:
|
||||||
|
Also wait — bounded — for the cancelled turn's OPERATION (including its raw provider
|
||||||
|
prompt) to settle before disposing/replacing the agent. The reservation releases as soon
|
||||||
|
as the abort wins the race, but a provider that ignores the signal can leave its prompt
|
||||||
|
callback live; publishing the rewound state under it was the residual PR #2417 finding.
|
||||||
|
On timeout we proceed anyway: disposal is the backstop and the cancelled closure's
|
||||||
|
post-prompt abort checks prevent state writes.
|
||||||
|
*/
|
||||||
|
if (!(await waitForTurnOperationSettled(session.id))) {
|
||||||
|
diagnostics.warn("Rewinding past a provider prompt that has not settled after abort", {
|
||||||
|
sessionId: session.id,
|
||||||
|
operation: "rewind-unsettled-prompt",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the rewind target only after admission: a turn that completed while we
|
||||||
|
// waited may have appended to history, so an index computed earlier would be stale.
|
||||||
const rewindIndex = questionId
|
const rewindIndex = questionId
|
||||||
? session.history.findIndex((entry) => entry.question.id === questionId)
|
? session.history.findIndex((entry) => entry.question.id === questionId)
|
||||||
: session.history.length - 1;
|
: session.history.length - 1;
|
||||||
@@ -3020,6 +3325,9 @@ export async function rewindSession(
|
|||||||
currentQuestion: rewindEntry.question,
|
currentQuestion: rewindEntry.question,
|
||||||
history: [...session.history],
|
history: [...session.history],
|
||||||
};
|
};
|
||||||
|
} finally {
|
||||||
|
releaseTurn();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopGeneration(sessionId: string): boolean {
|
export function stopGeneration(sessionId: string): boolean {
|
||||||
@@ -3157,6 +3465,9 @@ function coerceResponseRecord(question: PlanningQuestion, response: unknown): Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
function disposeSessionAgentForRetry(session: Session): void {
|
function disposeSessionAgentForRetry(session: Session): void {
|
||||||
|
// FNXC:PlanningTurnAdmission 2026-07-23-10:40: invalidate the disposed agent's streaming
|
||||||
|
// callbacks even when the provider keeps running past dispose — see Session.agentCallbackEpoch.
|
||||||
|
session.agentCallbackEpoch = (session.agentCallbackEpoch ?? 0) + 1;
|
||||||
if (!session.agent) {
|
if (!session.agent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3570,6 +3881,8 @@ export function __resetPlanningState(): void {
|
|||||||
rateLimits.clear();
|
rateLimits.clear();
|
||||||
planningStreamManager.reset();
|
planningStreamManager.reset();
|
||||||
activeGenerations.clear();
|
activeGenerations.clear();
|
||||||
|
pendingTurnReservations.clear();
|
||||||
|
settlingTurnOperations.clear();
|
||||||
|
|
||||||
if (_aiSessionStore && _aiSessionDeletedListener) {
|
if (_aiSessionStore && _aiSessionDeletedListener) {
|
||||||
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);
|
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);
|
||||||
|
|||||||
Reference in New Issue
Block a user