diff --git a/packages/ui/src/sync/retry.ts b/packages/ui/src/sync/retry.ts index 7d580407..8c7aafea 100644 --- a/packages/ui/src/sync/retry.ts +++ b/packages/ui/src/sync/retry.ts @@ -24,9 +24,10 @@ function isTransientError(error: unknown): boolean { if (!error) return false const message = String(error instanceof Error ? error.message : error).toLowerCase() if (TRANSIENT_MESSAGES.some((m) => message.includes(m))) return true - // SDK errors from HTTP 502/503 responses (VS Code bridge returns these before OpenCode is ready) + // Any HTTP 5xx is considered transient — server-side issues during warmup + // (OpenCode reading sessions from disk, bridge not ready, etc.) are retryable. const status = (error as { status?: number })?.status - if (status === 502 || status === 503) return true + if (typeof status === "number" && status >= 500 && status < 600) return true return false } diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 7a586fa7..ac94031d 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -1255,13 +1255,34 @@ export function SyncProvider(props: { limit: 50, }) // SDK returns { error } instead of { data } on non-ok responses (503). - // Throw so retry() retries and allSettled marks it as rejected. - if ((result as { error?: unknown }).error) { - throw new Error("session.list failed: " + String((result as { error?: unknown }).error)) + // Preserve HTTP status so retry()'s transient detection works. + const rawError = (result as { error?: unknown }).error + if (rawError) { + const response = (result as { response?: { status?: number } }).response + const status = response?.status + const message = typeof rawError === "object" && rawError !== null && "message" in rawError + ? String((rawError as { message?: unknown }).message) + : String(rawError) + const wrapped = new Error(`session.list failed${status ? ` (${status})` : ""}: ${message}`) + if (status !== undefined) { + ;(wrapped as Error & { status?: number }).status = status + } + throw wrapped } const sessions = (result.data ?? []) .filter((s) => !!s?.id) .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + // Race guard: if the list came back empty but event pipeline + // already populated the store, don't clobber. OpenCode can + // answer HTTP with empty sessions while WS delivers session + // events for the same data (disk warmup race on app launch). + const currentSessions = store.getState().session + if (sessions.length === 0 && currentSessions.length > 0) { + console.warn( + `[bootstrap] session.list returned empty for ${dir}; preserving ${currentSessions.length} existing sessions`, + ) + return + } store.setState({ session: sessions, sessionTotal: sessions.length, limit: Math.max(sessions.length, 50) }) ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState()) }), @@ -1271,9 +1292,12 @@ export function SyncProvider(props: { // wasn't ready yet (bridge returned 503). Retry a few times. const state = store.getState() if (state.session.length === 0 && attempt < 5) { + console.warn(`[bootstrap] sessions empty for ${directory} after attempt ${attempt + 1}; retrying in 2s`) await new Promise((r) => setTimeout(r, 2000)) store.setState({ status: "loading" as const }) await runBootstrap(attempt + 1) + } else if (state.session.length === 0) { + console.warn(`[bootstrap] sessions empty for ${directory} after ${attempt + 1} attempts; giving up`) } }