Skip to content

Commit a139e92

Browse files
authored
fix: prune and evict stale app session caches (#16584)
1 parent 050f99e commit a139e92

8 files changed

Lines changed: 365 additions & 41 deletions

File tree

packages/app/src/context/global-sync.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import type { InitError } from "../pages/error"
2727
import { useGlobalSDK } from "./global-sdk"
2828
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
2929
import { createChildStoreManager } from "./global-sync/child-store"
30-
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
30+
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
3131
import { createRefreshQueue } from "./global-sync/queue"
3232
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
3333
import { trimSessions } from "./global-sync/session-trim"
@@ -189,6 +189,7 @@ function createGlobalSync() {
189189
})
190190
if (next.length !== store.session.length) {
191191
setStore("session", reconcile(next, { key: "id" }))
192+
cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
192193
}
193194
children.unpin(directory)
194195
return
@@ -220,6 +221,7 @@ function createGlobalSync() {
220221
}),
221222
)
222223
setStore("session", reconcile(sessions, { key: "id" }))
224+
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
223225
sessionMeta.set(directory, { limit })
224226
})
225227
.catch((err) => {

packages/app/src/context/global-sync/event-reducer.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
22
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
33
import { createStore } from "solid-js/store"
44
import type { State } from "./types"
5-
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
5+
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
66

77
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
88
({
@@ -248,6 +248,62 @@ describe("applyDirectoryEvent", () => {
248248
}
249249
})
250250

251+
test("cleans caches for trimmed sessions on session.created", () => {
252+
const dropped = rootSession({ id: "ses_b" })
253+
const kept = rootSession({ id: "ses_a" })
254+
const message = userMessage("msg_1", dropped.id)
255+
const todos: string[] = []
256+
const [store, setStore] = createStore(
257+
baseState({
258+
limit: 1,
259+
session: [dropped],
260+
message: { [dropped.id]: [message] },
261+
part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] },
262+
session_diff: { [dropped.id]: [] },
263+
todo: { [dropped.id]: [] },
264+
permission: { [dropped.id]: [] },
265+
question: { [dropped.id]: [] },
266+
session_status: { [dropped.id]: { type: "busy" } },
267+
}),
268+
)
269+
270+
applyDirectoryEvent({
271+
event: { type: "session.created", properties: { info: kept } },
272+
store,
273+
setStore,
274+
push() {},
275+
directory: "/tmp",
276+
loadLsp() {},
277+
setSessionTodo(sessionID, value) {
278+
if (value !== undefined) return
279+
todos.push(sessionID)
280+
},
281+
})
282+
283+
expect(store.session.map((x) => x.id)).toEqual([kept.id])
284+
expect(store.message[dropped.id]).toBeUndefined()
285+
expect(store.part[message.id]).toBeUndefined()
286+
expect(store.session_diff[dropped.id]).toBeUndefined()
287+
expect(store.todo[dropped.id]).toBeUndefined()
288+
expect(store.permission[dropped.id]).toBeUndefined()
289+
expect(store.question[dropped.id]).toBeUndefined()
290+
expect(store.session_status[dropped.id]).toBeUndefined()
291+
expect(todos).toEqual([dropped.id])
292+
})
293+
294+
test("cleanupDroppedSessionCaches clears part-only orphan state", () => {
295+
const [store, setStore] = createStore(
296+
baseState({
297+
session: [rootSession({ id: "ses_keep" })],
298+
part: { msg_1: [textPart("prt_1", "ses_drop", "msg_1")] },
299+
}),
300+
)
301+
302+
cleanupDroppedSessionCaches(store, setStore, store.session)
303+
304+
expect(store.part.msg_1).toBeUndefined()
305+
})
306+
251307
test("upserts and removes messages while clearing orphaned parts", () => {
252308
const sessionID = "ses_1"
253309
const [store, setStore] = createStore(

packages/app/src/context/global-sync/event-reducer.ts

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
} from "@opencode-ai/sdk/v2/client"
1414
import type { State, VcsCache } from "./types"
1515
import { trimSessions } from "./session-trim"
16+
import { dropSessionCaches } from "./session-cache"
1617

1718
export function applyGlobalEvent(input: {
1819
event: { type: string; properties?: unknown }
@@ -40,37 +41,44 @@ export function applyGlobalEvent(input: {
4041
}
4142

4243
function cleanupSessionCaches(
43-
store: Store<State>,
4444
setStore: SetStoreFunction<State>,
4545
sessionID: string,
4646
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
4747
) {
4848
if (!sessionID) return
49-
const hasAny =
50-
store.message[sessionID] !== undefined ||
51-
store.session_diff[sessionID] !== undefined ||
52-
store.todo[sessionID] !== undefined ||
53-
store.permission[sessionID] !== undefined ||
54-
store.question[sessionID] !== undefined ||
55-
store.session_status[sessionID] !== undefined
5649
setSessionTodo?.(sessionID, undefined)
57-
if (!hasAny) return
5850
setStore(
5951
produce((draft) => {
60-
const messages = draft.message[sessionID]
61-
if (messages) {
62-
for (const message of messages) {
63-
const id = message?.id
64-
if (!id) continue
65-
delete draft.part[id]
66-
}
67-
}
68-
delete draft.message[sessionID]
69-
delete draft.session_diff[sessionID]
70-
delete draft.todo[sessionID]
71-
delete draft.permission[sessionID]
72-
delete draft.question[sessionID]
73-
delete draft.session_status[sessionID]
52+
dropSessionCaches(draft, [sessionID])
53+
}),
54+
)
55+
}
56+
57+
export function cleanupDroppedSessionCaches(
58+
store: Store<State>,
59+
setStore: SetStoreFunction<State>,
60+
next: Session[],
61+
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
62+
) {
63+
const keep = new Set(next.map((item) => item.id))
64+
const stale = [
65+
...Object.keys(store.message),
66+
...Object.keys(store.session_diff),
67+
...Object.keys(store.todo),
68+
...Object.keys(store.permission),
69+
...Object.keys(store.question),
70+
...Object.keys(store.session_status),
71+
...Object.values(store.part)
72+
.map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID)
73+
.filter((sessionID): sessionID is string => !!sessionID),
74+
].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
75+
if (stale.length === 0) return
76+
for (const sessionID of stale) {
77+
setSessionTodo?.(sessionID, undefined)
78+
}
79+
setStore(
80+
produce((draft) => {
81+
dropSessionCaches(draft, stale)
7482
}),
7583
)
7684
}
@@ -102,6 +110,7 @@ export function applyDirectoryEvent(input: {
102110
next.splice(result.index, 0, info)
103111
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
104112
input.setStore("session", reconcile(trimmed, { key: "id" }))
113+
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
105114
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
106115
break
107116
}
@@ -117,7 +126,7 @@ export function applyDirectoryEvent(input: {
117126
}),
118127
)
119128
}
120-
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
129+
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
121130
if (info.parentID) break
122131
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
123132
break
@@ -130,6 +139,7 @@ export function applyDirectoryEvent(input: {
130139
next.splice(result.index, 0, info)
131140
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
132141
input.setStore("session", reconcile(trimmed, { key: "id" }))
142+
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
133143
break
134144
}
135145
case "session.deleted": {
@@ -143,7 +153,7 @@ export function applyDirectoryEvent(input: {
143153
}),
144154
)
145155
}
146-
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
156+
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
147157
if (info.parentID) break
148158
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
149159
break
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { describe, expect, test } from "bun:test"
2+
import type {
3+
FileDiff,
4+
Message,
5+
Part,
6+
PermissionRequest,
7+
QuestionRequest,
8+
SessionStatus,
9+
Todo,
10+
} from "@opencode-ai/sdk/v2/client"
11+
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
12+
13+
const msg = (id: string, sessionID: string) =>
14+
({
15+
id,
16+
sessionID,
17+
role: "user",
18+
time: { created: 1 },
19+
agent: "assistant",
20+
model: { providerID: "openai", modelID: "gpt" },
21+
}) as Message
22+
23+
const part = (id: string, sessionID: string, messageID: string) =>
24+
({
25+
id,
26+
sessionID,
27+
messageID,
28+
type: "text",
29+
text: id,
30+
}) as Part
31+
32+
describe("app session cache", () => {
33+
test("dropSessionCaches clears orphaned parts without message rows", () => {
34+
const store: {
35+
session_status: Record<string, SessionStatus | undefined>
36+
session_diff: Record<string, FileDiff[] | undefined>
37+
todo: Record<string, Todo[] | undefined>
38+
message: Record<string, Message[] | undefined>
39+
part: Record<string, Part[] | undefined>
40+
permission: Record<string, PermissionRequest[] | undefined>
41+
question: Record<string, QuestionRequest[] | undefined>
42+
} = {
43+
session_status: { ses_1: { type: "busy" } as SessionStatus },
44+
session_diff: { ses_1: [] },
45+
todo: { ses_1: [] as Todo[] },
46+
message: {},
47+
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
48+
permission: { ses_1: [] as PermissionRequest[] },
49+
question: { ses_1: [] as QuestionRequest[] },
50+
}
51+
52+
dropSessionCaches(store, ["ses_1"])
53+
54+
expect(store.message.ses_1).toBeUndefined()
55+
expect(store.part.msg_1).toBeUndefined()
56+
expect(store.todo.ses_1).toBeUndefined()
57+
expect(store.session_diff.ses_1).toBeUndefined()
58+
expect(store.session_status.ses_1).toBeUndefined()
59+
expect(store.permission.ses_1).toBeUndefined()
60+
expect(store.question.ses_1).toBeUndefined()
61+
})
62+
63+
test("dropSessionCaches clears message-backed parts", () => {
64+
const m = msg("msg_1", "ses_1")
65+
const store: {
66+
session_status: Record<string, SessionStatus | undefined>
67+
session_diff: Record<string, FileDiff[] | undefined>
68+
todo: Record<string, Todo[] | undefined>
69+
message: Record<string, Message[] | undefined>
70+
part: Record<string, Part[] | undefined>
71+
permission: Record<string, PermissionRequest[] | undefined>
72+
question: Record<string, QuestionRequest[] | undefined>
73+
} = {
74+
session_status: {},
75+
session_diff: {},
76+
todo: {},
77+
message: { ses_1: [m] },
78+
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
79+
permission: {},
80+
question: {},
81+
}
82+
83+
dropSessionCaches(store, ["ses_1"])
84+
85+
expect(store.message.ses_1).toBeUndefined()
86+
expect(store.part[m.id]).toBeUndefined()
87+
})
88+
89+
test("pickSessionCacheEvictions preserves requested sessions", () => {
90+
const seen = new Set(["ses_1", "ses_2", "ses_3"])
91+
92+
const stale = pickSessionCacheEvictions({
93+
seen,
94+
keep: "ses_4",
95+
limit: 2,
96+
preserve: ["ses_1"],
97+
})
98+
99+
expect(stale).toEqual(["ses_2", "ses_3"])
100+
expect([...seen]).toEqual(["ses_1", "ses_4"])
101+
})
102+
})
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type {
2+
FileDiff,
3+
Message,
4+
Part,
5+
PermissionRequest,
6+
QuestionRequest,
7+
SessionStatus,
8+
Todo,
9+
} from "@opencode-ai/sdk/v2/client"
10+
11+
export const SESSION_CACHE_LIMIT = 40
12+
13+
type SessionCache = {
14+
session_status: Record<string, SessionStatus | undefined>
15+
session_diff: Record<string, FileDiff[] | undefined>
16+
todo: Record<string, Todo[] | undefined>
17+
message: Record<string, Message[] | undefined>
18+
part: Record<string, Part[] | undefined>
19+
permission: Record<string, PermissionRequest[] | undefined>
20+
question: Record<string, QuestionRequest[] | undefined>
21+
}
22+
23+
export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) {
24+
const stale = new Set(Array.from(sessionIDs).filter(Boolean))
25+
if (stale.size === 0) return
26+
27+
for (const key of Object.keys(store.part)) {
28+
const parts = store.part[key]
29+
if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue
30+
delete store.part[key]
31+
}
32+
33+
for (const sessionID of stale) {
34+
delete store.message[sessionID]
35+
delete store.todo[sessionID]
36+
delete store.session_diff[sessionID]
37+
delete store.session_status[sessionID]
38+
delete store.permission[sessionID]
39+
delete store.question[sessionID]
40+
}
41+
}
42+
43+
export function pickSessionCacheEvictions(input: {
44+
seen: Set<string>
45+
keep: string
46+
limit: number
47+
preserve?: Iterable<string>
48+
}) {
49+
const stale: string[] = []
50+
const keep = new Set([input.keep, ...Array.from(input.preserve ?? [])])
51+
if (input.seen.has(input.keep)) input.seen.delete(input.keep)
52+
input.seen.add(input.keep)
53+
for (const id of input.seen) {
54+
if (input.seen.size - stale.length <= input.limit) break
55+
if (keep.has(id)) continue
56+
stale.push(id)
57+
}
58+
for (const id of stale) {
59+
input.seen.delete(id)
60+
}
61+
return stale
62+
}

0 commit comments

Comments
 (0)