From adba54e1ff0d4fc6f82b90eeb692caf2572c0ff1 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Mon, 25 May 2026 14:50:06 +0200 Subject: [PATCH] fix(sessions): stop fork-create retry loop on first user message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed the SessionWrapper $effect that retroactively committed the session's workspace from the in-memory chat history. When opening a session whose previous commit attempt had failed (or whose response was lost) the effect ran in a tight retry loop, flooding the user with `workspace_pkey` violations from `create_workspace_fork`. The send path already commits through `AIChatManager.beforeSend` → `commitSessionWorkspace`, which is the deterministic moment-of-action. The $effect was a redundant reactive bridge that turned every backend failure into an infinite retry. Also hardens `materializeFork`/`commitSessionWorkspace` so the most common cause of the duplicate-key error self-heals: - `materializeFork` short-circuits when `fork.id` is already in `$userWorkspaces` (the previous create actually succeeded, we just lost the response). On a `workspace_pkey` catch, refresh the workspace list and adopt the existing row instead of toasting an error. - On a real `materializeFork` failure, `commitSessionWorkspace` now drops `pending_fork` so the session falls through to the workspace-pick fallback instead of looping on the same broken intent. --- .../components/sessions/SessionWrapper.svelte | 24 +++++-------------- .../sessions/sessionState.svelte.ts | 24 +++++++++++++++++-- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index c364246323..32e82c2c35 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -31,7 +31,6 @@ import SessionWorkspaceBar from './SessionWorkspaceBar.svelte' import SessionForkBar from './SessionForkBar.svelte' import { - commitSessionWorkspace, createSession, getEffectiveWorkspaceId, moveSessionToNewFork, @@ -163,27 +162,16 @@ } // Workspace bar is shown only before the session sends its first user - // message — after that the session's workspace is immutable. + // message — after that the session's workspace is immutable. The + // commit itself happens in `AIChatManager.beforeSend` (wired in + // `createRuntime`) so it fires exactly once at send-time. A reactive + // commit here would retry forever on backend failures (e.g. fork-id + // collision after a previously-successful create whose response was + // dropped) — restoration self-heal lives in `initRuntime` instead. const hasFirstUserMessage = $derived( runtime?.manager.displayMessages.some((m) => m.role === 'user') ?? false ) - // Commit pending workspace pick (or current active workspace as - // fallback) into `workspace_id` exactly once, when the first user - // message lands. This is the only path that defines workspace_id. - // When a pending fork is staged, this is also where the fork is - // materialised via the API — no orphan forks for abandoned drafts. - let committing = $state(false) - $effect(() => { - if (!session || !hasFirstUserMessage || session.workspace_id || committing) return - committing = true - commitSessionWorkspace(session.id, $workspaceStore ?? undefined) - .catch((e) => console.error('Failed to commit session workspace', e)) - .finally(() => { - committing = false - }) - }) - // Effective workspace for routing editor views — committed if set, // otherwise the pending pick, otherwise the current active workspace. const effectiveWorkspaceId = $derived( diff --git a/frontend/src/lib/components/sessions/sessionState.svelte.ts b/frontend/src/lib/components/sessions/sessionState.svelte.ts index 24b6165a23..9a954400cf 100644 --- a/frontend/src/lib/components/sessions/sessionState.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionState.svelte.ts @@ -308,7 +308,15 @@ export async function commitSessionWorkspace( if (s.pending_fork) { const fork = s.pending_fork const newId = await materializeFork(fork) - if (!newId) return undefined + if (!newId) { + // Real failure (not a recovered duplicate). Drop the pending + // fork so the session falls through to the workspace-pick + // fallback on the next call and the unavailable-banner UX + // can take over instead of looping on the same broken intent. + s.pending_fork = undefined + persistSessions() + return undefined + } if (get(workspaceStore) !== newId) switchWorkspace(newId) s.workspace_id = newId s.pending_fork = undefined @@ -360,7 +368,14 @@ export function renameSession(id: string, newSummary: string) { // path (commitSessionWorkspace) and the move-session-to-a-new-fork path // in the unavailable-session banner. Returns undefined on failure (a // user-facing toast is already emitted). +// +// Self-heal: if `fork.id` is already present in the user-workspaces +// store, the previous create succeeded (whose response we apparently +// lost). Adopt it silently instead of re-POSTing — the API would +// otherwise reject with workspace_pkey. Likewise, if the API returns a +// duplicate-key error we refresh the store and adopt the existing row. export async function materializeFork(fork: PendingFork): Promise { + if (get(userWorkspaces).some((w) => w.id === fork.id)) return fork.id try { await WorkspaceService.createWorkspaceFork({ workspace: fork.parent_workspace_id, @@ -370,7 +385,12 @@ export async function materializeFork(fork: PendingFork): Promise w.id === fork.id)) return fork.id + } + sendUserToast(`Could not create fork: ${msg}`, true) return undefined } }