diff --git a/packages/app/src/context/directory-sync-error.test.ts b/packages/app/src/context/directory-sync-error.test.ts new file mode 100644 index 000000000000..303599291be2 --- /dev/null +++ b/packages/app/src/context/directory-sync-error.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test" +import { isSessionNotFoundError, recoverSessionNotFound } from "./directory-sync-error" + +describe("isSessionNotFoundError", () => { + test("matches SDK-wrapped session not found errors", () => { + const error = new Error("Session not found: ses_1", { + cause: { + body: { + name: "NotFoundError", + data: { + message: "Session not found: ses_1", + }, + }, + status: 404, + }, + }) + + expect(isSessionNotFoundError(error)).toBe(true) + }) + + test("matches tagged session not found errors", () => { + const error = new Error("Session not found", { + cause: { + body: { + name: "SessionNotFoundError", + }, + status: 404, + }, + }) + + expect(isSessionNotFoundError(error)).toBe(true) + }) + + test("does not match unrelated not found errors", () => { + const error = new Error("Provider not found", { + cause: { + body: { + name: "NotFoundError", + data: { + message: "Provider not found: openai", + }, + }, + status: 404, + }, + }) + + expect(isSessionNotFoundError(error)).toBe(false) + }) + + test("does not match non-404 session errors", () => { + const error = new Error("Session not found: ses_1", { + cause: { + body: { + name: "NotFoundError", + data: { + message: "Session not found: ses_1", + }, + }, + status: 500, + }, + }) + + expect(isSessionNotFoundError(error)).toBe(false) + }) + + test("recovers session not found rejections", async () => { + let recovered = false + + await recoverSessionNotFound( + Promise.reject( + new Error("Session not found: ses_1", { + cause: { + body: { + name: "NotFoundError", + data: { + message: "Session not found: ses_1", + }, + }, + status: 404, + }, + }), + ), + () => { + recovered = true + }, + ) + + expect(recovered).toBe(true) + }) + + test("rethrows unrelated rejections", async () => { + const error = new Error("Provider not found", { + cause: { + body: { + name: "NotFoundError", + data: { + message: "Provider not found: openai", + }, + }, + status: 404, + }, + }) + let result: unknown + + try { + await recoverSessionNotFound(Promise.reject(error), () => { + result = "recovered" + }) + } catch (caught) { + result = caught + } + + expect(result).toBe(error) + }) +}) diff --git a/packages/app/src/context/directory-sync-error.ts b/packages/app/src/context/directory-sync-error.ts new file mode 100644 index 000000000000..449716f7bd93 --- /dev/null +++ b/packages/app/src/context/directory-sync-error.ts @@ -0,0 +1,26 @@ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +export function isSessionNotFoundError(error: unknown) { + if (!(error instanceof Error)) return false + if (!isRecord(error.cause)) return false + if (error.cause.status !== 404) return false + if (!isRecord(error.cause.body)) return false + + if (error.cause.body.name === "SessionNotFoundError") return true + if (error.cause.body._tag === "SessionNotFoundError") return true + if (error.cause.body.name !== "NotFoundError") return false + + const data = isRecord(error.cause.body.data) ? error.cause.body.data : undefined + return typeof data?.message === "string" && data.message.startsWith("Session not found:") +} + +export async function recoverSessionNotFound(promise: Promise, recover: () => void) { + try { + return await promise + } catch (error) { + if (!isSessionNotFoundError(error)) throw error + recover() + } +} diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index a54701f0db84..6e6579b315db 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -13,6 +13,7 @@ import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client" import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache" import { diffs as list, message as clean } from "@/utils/diffs" import { useServerSDK } from "./server-sdk" +import { recoverSessionNotFound } from "./directory-sync-error" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) @@ -279,6 +280,18 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType { + seenFor(directory).delete(sessionID) + evict(directory, setStore, [sessionID]) + setStore( + "session", + produce((draft) => { + const match = Binary.search(draft, sessionID, (s) => s.id) + if (match.found) draft.splice(match.index, 1) + }), + ) + } + const touch = (directory: string, setStore: Setter, sessionID: string) => { const stale = pickSessionCacheEvictions({ seen: seenFor(directory), @@ -435,59 +448,62 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType { - const pending = getSessionPrefetchPromise(directory, sessionID) - if (pending) { - await pending - const seeded = getSessionPrefetch(directory, sessionID) - if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) { - batch(() => { - setMeta("limit", key, seeded.limit) - setMeta("cursor", key, seeded.cursor) - setMeta("complete", key, seeded.complete) - setMeta("loading", key, false) - }) - } - } - - const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found - const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined - if (cached && hasSession && !opts?.force) return - - const limit = meta.limit[key] ?? initialMessagePageSize - const sessionReq = - hasSession && !opts?.force - ? Promise.resolve() - : retry(() => client.session.get({ sessionID })).then((session) => { - if (!tracked(directory, sessionID)) return - const data = session.data - if (!data) return - setStore( - "session", - produce((draft) => { - const match = Binary.search(draft, sessionID, (s) => s.id) - if (match.found) { - draft[match.index] = data - return - } - draft.splice(match.index, 0, data) - }), - ) - }) - - const messagesReq = - cached && !opts?.force - ? Promise.resolve() - : loadMessages({ - directory, - client, - setStore, - sessionID, - limit, + return recoverSessionNotFound( + runInflight(inflight, key, async () => { + const pending = getSessionPrefetchPromise(directory, sessionID) + if (pending) { + await pending + const seeded = getSessionPrefetch(directory, sessionID) + if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) { + batch(() => { + setMeta("limit", key, seeded.limit) + setMeta("cursor", key, seeded.cursor) + setMeta("complete", key, seeded.complete) + setMeta("loading", key, false) }) + } + } - await Promise.all([sessionReq, messagesReq]) - }) + const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found + const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined + if (cached && hasSession && !opts?.force) return + + const limit = meta.limit[key] ?? initialMessagePageSize + const sessionReq = + hasSession && !opts?.force + ? Promise.resolve() + : retry(() => client.session.get({ sessionID })).then((session) => { + if (!tracked(directory, sessionID)) return + const data = session.data + if (!data) return + setStore( + "session", + produce((draft) => { + const match = Binary.search(draft, sessionID, (s) => s.id) + if (match.found) { + draft[match.index] = data + return + } + draft.splice(match.index, 0, data) + }), + ) + }) + + const messagesReq = + cached && !opts?.force + ? Promise.resolve() + : loadMessages({ + directory, + client, + setStore, + sessionID, + limit, + }) + + await Promise.all([sessionReq, messagesReq]) + }), + () => forgetMissingSession(directory, setStore, sessionID), + ) }, async diff(sessionID: string, opts?: { force?: boolean }) { const [store, setStore] = serverSync.child(directory) @@ -495,11 +511,14 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType - retry(() => client.session.diff({ sessionID })).then((diff) => { - if (!tracked(directory, sessionID)) return - setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" })) - }), + return recoverSessionNotFound( + runInflight(inflightDiff, key, () => + retry(() => client.session.diff({ sessionID })).then((diff) => { + if (!tracked(directory, sessionID)) return + setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" })) + }), + ), + () => forgetMissingSession(directory, setStore, sessionID), ) }, async todo(sessionID: string, opts?: { force?: boolean }) { @@ -519,13 +538,16 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType - retry(() => client.session.todo({ sessionID })).then((todo) => { - if (!tracked(directory, sessionID)) return - const list = todo.data ?? [] - setStore("todo", sessionID, reconcile(list, { key: "id" })) - serverSync.todo.set(sessionID, list) - }), + return recoverSessionNotFound( + runInflight(inflightTodo, key, () => + retry(() => client.session.todo({ sessionID })).then((todo) => { + if (!tracked(directory, sessionID)) return + const list = todo.data ?? [] + setStore("todo", sessionID, reconcile(list, { key: "id" })) + serverSync.todo.set(sessionID, list) + }), + ), + () => forgetMissingSession(directory, setStore, sessionID), ) }, history: { @@ -551,15 +573,18 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType forgetMissingSession(directory, setStore, sessionID), + ) }, }, evict(sessionID: string, _directory = directory) {