fix: protect live session sync caches

This commit is contained in:
Bohdan Triapitsyn
2026-05-07 12:30:30 +03:00
parent 07edd6e9b9
commit ff830d3812
5 changed files with 89 additions and 5 deletions
@@ -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)
})
})
+34
View File
@@ -18,6 +18,40 @@ type SessionCache = {
question: Record<string, QuestionRequest[] | undefined>
}
export function getProtectedSessionCacheIds(store: SessionCache): Set<string> {
const protectedIds = new Set<string>()
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<string>) {
const stale = new Set(Array.from(sessionIDs).filter(Boolean))
if (stale.size === 0) return
+9 -1
View File
@@ -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,
}
})
}
+1 -1
View File
@@ -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: "",
+4 -2
View File
@@ -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