From ff830d3812afafd8e19fef8ceaa433dc0a164ba9 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 7 May 2026 12:30:30 +0300 Subject: [PATCH] fix: protect live session sync caches --- .../ui/src/sync/__tests__/eviction.test.ts | 42 ++++++++++++++++++- packages/ui/src/sync/session-cache.ts | 34 +++++++++++++++ packages/ui/src/sync/sync-context.tsx | 10 ++++- packages/ui/src/sync/types.ts | 2 +- packages/ui/src/sync/use-sync.ts | 6 ++- 5 files changed, 89 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/sync/__tests__/eviction.test.ts b/packages/ui/src/sync/__tests__/eviction.test.ts index 8dea1b6c..03a2fad4 100644 --- a/packages/ui/src/sync/__tests__/eviction.test.ts +++ b/packages/ui/src/sync/__tests__/eviction.test.ts @@ -1,10 +1,11 @@ import { describe, expect, test } from "bun:test" -import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2/client" +import type { Message, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2/client" import { canDisposeDirectory, hasPendingBlockingRequests, pickDirectoriesToEvict, } from "../eviction" +import { getProtectedSessionCacheIds, pickSessionCacheEvictions } from "../session-cache" import { INITIAL_STATE, type DirState, type State } from "../types" const DAY_MS = 24 * 60 * 60 * 1000 @@ -133,3 +134,42 @@ describe("canDisposeDirectory", () => { expect(canDisposeDirectory(baseInput)).toBe(true) }) }) + +describe("session cache eviction", () => { + test("protects live and blocking sessions from per-directory cache eviction", () => { + const protectedIds = getProtectedSessionCacheIds({ + session_status: { + ses_busy: { type: "busy" }, + ses_idle: { type: "idle" }, + }, + session_diff: {}, + todo: {}, + message: { + ses_streaming: [{ id: "msg_1", role: "assistant", time: { created: 1 } } as Message], + }, + part: {}, + permission: { + ses_permission: [buildPermission({ sessionID: "ses_permission" })], + }, + question: { + ses_question: [buildQuestion({ sessionID: "ses_question" })], + }, + }) + + expect(protectedIds).toEqual(new Set(["ses_busy", "ses_streaming", "ses_permission", "ses_question"])) + + const seen = new Set(["ses_old", "ses_busy", "ses_permission", "ses_question", "ses_streaming", "ses_current"]) + const evicted = pickSessionCacheEvictions({ + seen, + keep: "ses_current", + preserve: protectedIds, + limit: 2, + }) + + expect(evicted).toEqual(["ses_old"]) + expect(seen.has("ses_busy")).toBe(true) + expect(seen.has("ses_permission")).toBe(true) + expect(seen.has("ses_question")).toBe(true) + expect(seen.has("ses_streaming")).toBe(true) + }) +}) diff --git a/packages/ui/src/sync/session-cache.ts b/packages/ui/src/sync/session-cache.ts index 1ae6532d..297eb62d 100644 --- a/packages/ui/src/sync/session-cache.ts +++ b/packages/ui/src/sync/session-cache.ts @@ -18,6 +18,40 @@ type SessionCache = { question: Record } +export function getProtectedSessionCacheIds(store: SessionCache): Set { + const protectedIds = new Set() + + for (const [sessionID, status] of Object.entries(store.session_status ?? {})) { + if (status && status.type !== "idle") { + protectedIds.add(sessionID) + } + } + + for (const [sessionID, permissions] of Object.entries(store.permission ?? {})) { + if ((permissions?.length ?? 0) > 0) { + protectedIds.add(sessionID) + } + } + + for (const [sessionID, questions] of Object.entries(store.question ?? {})) { + if ((questions?.length ?? 0) > 0) { + protectedIds.add(sessionID) + } + } + + for (const [sessionID, messages] of Object.entries(store.message ?? {})) { + const lastMessage = messages?.[messages.length - 1] + if ( + lastMessage?.role === "assistant" + && typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== "number" + ) { + protectedIds.add(sessionID) + } + } + + return protectedIds +} + export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable) { const stale = new Set(Array.from(sessionIDs).filter(Boolean)) if (stale.size === 0) return diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 5b2e7452..fc9159cc 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -223,6 +223,11 @@ async function repairSessionParts( if (records.length === 0) return store.setState((state: DirectoryStore) => { + const nextMessages = records + .map((record: { info: Message }) => stripMessageDiffSnapshots(record.info)) + .sort((a, b) => cmp(a.id, b.id)) + const currentMessages = state.message[sessionID] ?? [] + const mergedMessages = mergeMessages(currentMessages, nextMessages) const nextPartState = { ...state.part } for (const record of records) { const messageId = record?.info?.id @@ -237,7 +242,10 @@ async function repairSessionParts( nextPartState[messageId] = newParts } } - return { part: nextPartState } + return { + message: mergedMessages !== currentMessages ? { ...state.message, [sessionID]: mergedMessages } : state.message, + part: nextPartState, + } }) } diff --git a/packages/ui/src/sync/types.ts b/packages/ui/src/sync/types.ts index 17c1d5ec..c516f1b8 100644 --- a/packages/ui/src/sync/types.ts +++ b/packages/ui/src/sync/types.ts @@ -113,7 +113,7 @@ export const MAX_DIR_STORES = 30 export const DIR_IDLE_TTL_MS = 20 * 60 * 1000 export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000 export const SESSION_RECENT_LIMIT = 50 -export const SESSION_CACHE_LIMIT = 8 +export const SESSION_CACHE_LIMIT = 40 export const INITIAL_STATE: State = { project: "", diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts index 70d31154..f4f04ecb 100644 --- a/packages/ui/src/sync/use-sync.ts +++ b/packages/ui/src/sync/use-sync.ts @@ -10,7 +10,7 @@ import { type OptimisticItem, } from "./optimistic" import { useDirectoryStore, useSyncSDK, useSyncDirectory, useChildStoreManager } from "./sync-context" -import { dropSessionCaches } from "./session-cache" +import { dropSessionCaches, getProtectedSessionCacheIds } from "./session-cache" import { stripMessageDiffSnapshots } from "./sanitize" import { shouldSkipSessionPrefetch, @@ -135,14 +135,16 @@ export function useSync() { const touch = useCallback( (sessionID: string) => { const s = seenFor() + const protectedIds = getProtectedSessionCacheIds(store.getState()) const stale = pickSessionCacheEvictions({ seen: s, keep: sessionID, limit: SESSION_CACHE_LIMIT, + preserve: protectedIds, }) evict(directory, stale) }, - [directory, seenFor, evict], + [directory, seenFor, evict, store], ) // Optimistic operations