fix: protect live session sync caches
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user