fix(sessions): stop fork-create retry loop on first user message

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.
This commit is contained in:
Guilhem Lemouel
2026-05-25 14:50:06 +02:00
parent db6c66f2a6
commit adba54e1ff
2 changed files with 28 additions and 20 deletions
@@ -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(
@@ -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<string | undefined> {
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<string | undef
sendUserToast(`Created fork ${fork.name}`)
return fork.id
} catch (e: any) {
sendUserToast(`Could not create fork: ${e?.body ?? e?.message ?? e}`, true)
const msg = String(e?.body ?? e?.message ?? e)
if (/workspace_pkey|duplicate key/i.test(msg)) {
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
if (get(userWorkspaces).some((w) => w.id === fork.id)) return fork.id
}
sendUserToast(`Could not create fork: ${msg}`, true)
return undefined
}
}