summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/context
diff options
context:
space:
mode:
authorShoubhit Dash <[email protected]>2026-03-08 17:40:00 +0530
committerGitHub <[email protected]>2026-03-08 07:10:00 -0500
commita139e9297d2a269308c66efbc7ed2b7a53a59a16 (patch)
tree7c538e97a5456c4cc2c204a6b29fb0b61ec8c2db /packages/app/src/context
parent050f99ec54db39eb2bfaab17c5deb522c25fa306 (diff)
downloadopencode-a139e9297d2a269308c66efbc7ed2b7a53a59a16.tar.gz
opencode-a139e9297d2a269308c66efbc7ed2b7a53a59a16.zip
fix: prune and evict stale app session caches (#16584)
Diffstat (limited to 'packages/app/src/context')
-rw-r--r--packages/app/src/context/global-sync.tsx4
-rw-r--r--packages/app/src/context/global-sync/event-reducer.test.ts58
-rw-r--r--packages/app/src/context/global-sync/event-reducer.ts60
-rw-r--r--packages/app/src/context/global-sync/session-cache.test.ts102
-rw-r--r--packages/app/src/context/global-sync/session-cache.ts62
-rw-r--r--packages/app/src/context/sync.tsx78
6 files changed, 337 insertions, 27 deletions
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index b3a351382..4090699a8 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -27,7 +27,7 @@ import type { InitError } from "../pages/error"
import { useGlobalSDK } from "./global-sdk"
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
-import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
+import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
import { createRefreshQueue } from "./global-sync/queue"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim"
@@ -189,6 +189,7 @@ function createGlobalSync() {
})
if (next.length !== store.session.length) {
setStore("session", reconcile(next, { key: "id" }))
+ cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
}
children.unpin(directory)
return
@@ -220,6 +221,7 @@ function createGlobalSync() {
}),
)
setStore("session", reconcile(sessions, { key: "id" }))
+ cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
sessionMeta.set(directory, { limit })
})
.catch((err) => {
diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts
index ab7f99cef..cf2da135c 100644
--- a/packages/app/src/context/global-sync/event-reducer.test.ts
+++ b/packages/app/src/context/global-sync/event-reducer.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { createStore } from "solid-js/store"
import type { State } from "./types"
-import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
+import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
({
@@ -248,6 +248,62 @@ describe("applyDirectoryEvent", () => {
}
})
+ test("cleans caches for trimmed sessions on session.created", () => {
+ const dropped = rootSession({ id: "ses_b" })
+ const kept = rootSession({ id: "ses_a" })
+ const message = userMessage("msg_1", dropped.id)
+ const todos: string[] = []
+ const [store, setStore] = createStore(
+ baseState({
+ limit: 1,
+ session: [dropped],
+ message: { [dropped.id]: [message] },
+ part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] },
+ session_diff: { [dropped.id]: [] },
+ todo: { [dropped.id]: [] },
+ permission: { [dropped.id]: [] },
+ question: { [dropped.id]: [] },
+ session_status: { [dropped.id]: { type: "busy" } },
+ }),
+ )
+
+ applyDirectoryEvent({
+ event: { type: "session.created", properties: { info: kept } },
+ store,
+ setStore,
+ push() {},
+ directory: "/tmp",
+ loadLsp() {},
+ setSessionTodo(sessionID, value) {
+ if (value !== undefined) return
+ todos.push(sessionID)
+ },
+ })
+
+ expect(store.session.map((x) => x.id)).toEqual([kept.id])
+ expect(store.message[dropped.id]).toBeUndefined()
+ expect(store.part[message.id]).toBeUndefined()
+ expect(store.session_diff[dropped.id]).toBeUndefined()
+ expect(store.todo[dropped.id]).toBeUndefined()
+ expect(store.permission[dropped.id]).toBeUndefined()
+ expect(store.question[dropped.id]).toBeUndefined()
+ expect(store.session_status[dropped.id]).toBeUndefined()
+ expect(todos).toEqual([dropped.id])
+ })
+
+ test("cleanupDroppedSessionCaches clears part-only orphan state", () => {
+ const [store, setStore] = createStore(
+ baseState({
+ session: [rootSession({ id: "ses_keep" })],
+ part: { msg_1: [textPart("prt_1", "ses_drop", "msg_1")] },
+ }),
+ )
+
+ cleanupDroppedSessionCaches(store, setStore, store.session)
+
+ expect(store.part.msg_1).toBeUndefined()
+ })
+
test("upserts and removes messages while clearing orphaned parts", () => {
const sessionID = "ses_1"
const [store, setStore] = createStore(
diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts
index 241dfb14d..b8eda0573 100644
--- a/packages/app/src/context/global-sync/event-reducer.ts
+++ b/packages/app/src/context/global-sync/event-reducer.ts
@@ -13,6 +13,7 @@ import type {
} from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
+import { dropSessionCaches } from "./session-cache"
export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown }
@@ -40,37 +41,44 @@ export function applyGlobalEvent(input: {
}
function cleanupSessionCaches(
- store: Store<State>,
setStore: SetStoreFunction<State>,
sessionID: string,
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
if (!sessionID) return
- const hasAny =
- store.message[sessionID] !== undefined ||
- store.session_diff[sessionID] !== undefined ||
- store.todo[sessionID] !== undefined ||
- store.permission[sessionID] !== undefined ||
- store.question[sessionID] !== undefined ||
- store.session_status[sessionID] !== undefined
setSessionTodo?.(sessionID, undefined)
- if (!hasAny) return
setStore(
produce((draft) => {
- const messages = draft.message[sessionID]
- if (messages) {
- for (const message of messages) {
- const id = message?.id
- if (!id) continue
- delete draft.part[id]
- }
- }
- delete draft.message[sessionID]
- delete draft.session_diff[sessionID]
- delete draft.todo[sessionID]
- delete draft.permission[sessionID]
- delete draft.question[sessionID]
- delete draft.session_status[sessionID]
+ dropSessionCaches(draft, [sessionID])
+ }),
+ )
+}
+
+export function cleanupDroppedSessionCaches(
+ store: Store<State>,
+ setStore: SetStoreFunction<State>,
+ next: Session[],
+ setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
+) {
+ const keep = new Set(next.map((item) => item.id))
+ const stale = [
+ ...Object.keys(store.message),
+ ...Object.keys(store.session_diff),
+ ...Object.keys(store.todo),
+ ...Object.keys(store.permission),
+ ...Object.keys(store.question),
+ ...Object.keys(store.session_status),
+ ...Object.values(store.part)
+ .map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID)
+ .filter((sessionID): sessionID is string => !!sessionID),
+ ].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
+ if (stale.length === 0) return
+ for (const sessionID of stale) {
+ setSessionTodo?.(sessionID, undefined)
+ }
+ setStore(
+ produce((draft) => {
+ dropSessionCaches(draft, stale)
}),
)
}
@@ -102,6 +110,7 @@ export function applyDirectoryEvent(input: {
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
+ cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
break
}
@@ -117,7 +126,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
- cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
+ cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -130,6 +139,7 @@ export function applyDirectoryEvent(input: {
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
+ cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
break
}
case "session.deleted": {
@@ -143,7 +153,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
- cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
+ cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts
new file mode 100644
index 000000000..8e11110e3
--- /dev/null
+++ b/packages/app/src/context/global-sync/session-cache.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, test } from "bun:test"
+import type {
+ FileDiff,
+ Message,
+ Part,
+ PermissionRequest,
+ QuestionRequest,
+ SessionStatus,
+ Todo,
+} from "@opencode-ai/sdk/v2/client"
+import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
+
+const msg = (id: string, sessionID: string) =>
+ ({
+ id,
+ sessionID,
+ role: "user",
+ time: { created: 1 },
+ agent: "assistant",
+ model: { providerID: "openai", modelID: "gpt" },
+ }) as Message
+
+const part = (id: string, sessionID: string, messageID: string) =>
+ ({
+ id,
+ sessionID,
+ messageID,
+ type: "text",
+ text: id,
+ }) as Part
+
+describe("app session cache", () => {
+ test("dropSessionCaches clears orphaned parts without message rows", () => {
+ const store: {
+ session_status: Record<string, SessionStatus | undefined>
+ session_diff: Record<string, FileDiff[] | undefined>
+ todo: Record<string, Todo[] | undefined>
+ message: Record<string, Message[] | undefined>
+ part: Record<string, Part[] | undefined>
+ permission: Record<string, PermissionRequest[] | undefined>
+ question: Record<string, QuestionRequest[] | undefined>
+ } = {
+ session_status: { ses_1: { type: "busy" } as SessionStatus },
+ session_diff: { ses_1: [] },
+ todo: { ses_1: [] as Todo[] },
+ message: {},
+ part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
+ permission: { ses_1: [] as PermissionRequest[] },
+ question: { ses_1: [] as QuestionRequest[] },
+ }
+
+ dropSessionCaches(store, ["ses_1"])
+
+ expect(store.message.ses_1).toBeUndefined()
+ expect(store.part.msg_1).toBeUndefined()
+ expect(store.todo.ses_1).toBeUndefined()
+ expect(store.session_diff.ses_1).toBeUndefined()
+ expect(store.session_status.ses_1).toBeUndefined()
+ expect(store.permission.ses_1).toBeUndefined()
+ expect(store.question.ses_1).toBeUndefined()
+ })
+
+ test("dropSessionCaches clears message-backed parts", () => {
+ const m = msg("msg_1", "ses_1")
+ const store: {
+ session_status: Record<string, SessionStatus | undefined>
+ session_diff: Record<string, FileDiff[] | undefined>
+ todo: Record<string, Todo[] | undefined>
+ message: Record<string, Message[] | undefined>
+ part: Record<string, Part[] | undefined>
+ permission: Record<string, PermissionRequest[] | undefined>
+ question: Record<string, QuestionRequest[] | undefined>
+ } = {
+ session_status: {},
+ session_diff: {},
+ todo: {},
+ message: { ses_1: [m] },
+ part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
+ permission: {},
+ question: {},
+ }
+
+ dropSessionCaches(store, ["ses_1"])
+
+ expect(store.message.ses_1).toBeUndefined()
+ expect(store.part[m.id]).toBeUndefined()
+ })
+
+ test("pickSessionCacheEvictions preserves requested sessions", () => {
+ const seen = new Set(["ses_1", "ses_2", "ses_3"])
+
+ const stale = pickSessionCacheEvictions({
+ seen,
+ keep: "ses_4",
+ limit: 2,
+ preserve: ["ses_1"],
+ })
+
+ expect(stale).toEqual(["ses_2", "ses_3"])
+ expect([...seen]).toEqual(["ses_1", "ses_4"])
+ })
+})
diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts
new file mode 100644
index 000000000..0177ebbe1
--- /dev/null
+++ b/packages/app/src/context/global-sync/session-cache.ts
@@ -0,0 +1,62 @@
+import type {
+ FileDiff,
+ Message,
+ Part,
+ PermissionRequest,
+ QuestionRequest,
+ SessionStatus,
+ Todo,
+} from "@opencode-ai/sdk/v2/client"
+
+export const SESSION_CACHE_LIMIT = 40
+
+type SessionCache = {
+ session_status: Record<string, SessionStatus | undefined>
+ session_diff: Record<string, FileDiff[] | undefined>
+ todo: Record<string, Todo[] | undefined>
+ message: Record<string, Message[] | undefined>
+ part: Record<string, Part[] | undefined>
+ permission: Record<string, PermissionRequest[] | undefined>
+ question: Record<string, QuestionRequest[] | undefined>
+}
+
+export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) {
+ const stale = new Set(Array.from(sessionIDs).filter(Boolean))
+ if (stale.size === 0) return
+
+ for (const key of Object.keys(store.part)) {
+ const parts = store.part[key]
+ if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue
+ delete store.part[key]
+ }
+
+ for (const sessionID of stale) {
+ delete store.message[sessionID]
+ delete store.todo[sessionID]
+ delete store.session_diff[sessionID]
+ delete store.session_status[sessionID]
+ delete store.permission[sessionID]
+ delete store.question[sessionID]
+ }
+}
+
+export function pickSessionCacheEvictions(input: {
+ seen: Set<string>
+ keep: string
+ limit: number
+ preserve?: Iterable<string>
+}) {
+ const stale: string[] = []
+ const keep = new Set([input.keep, ...Array.from(input.preserve ?? [])])
+ if (input.seen.has(input.keep)) input.seen.delete(input.keep)
+ input.seen.add(input.keep)
+ for (const id of input.seen) {
+ if (input.seen.size - stale.length <= input.limit) break
+ if (keep.has(id)) continue
+ stale.push(id)
+ }
+ for (const id of stale) {
+ input.seen.delete(id)
+ }
+ return stale
+}
diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx
index 562a2d19c..5623a2c7c 100644
--- a/packages/app/src/context/sync.tsx
+++ b/packages/app/src/context/sync.tsx
@@ -6,6 +6,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
+import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
@@ -108,6 +109,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
+ const maxDirs = 30
+ const seen = new Map<string, Set<string>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
complete: {} as Record<string, boolean>,
@@ -121,6 +124,62 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return undefined
}
+ const seenFor = (directory: string) => {
+ const existing = seen.get(directory)
+ if (existing) {
+ seen.delete(directory)
+ seen.set(directory, existing)
+ return existing
+ }
+ const created = new Set<string>()
+ seen.set(directory, created)
+ while (seen.size > maxDirs) {
+ const first = seen.keys().next().value
+ if (!first) break
+ const stale = [...(seen.get(first) ?? [])]
+ seen.delete(first)
+ const [, setStore] = globalSync.child(first, { bootstrap: false })
+ evict(first, setStore, stale)
+ }
+ return created
+ }
+
+ const clearMeta = (directory: string, sessionIDs: string[]) => {
+ if (sessionIDs.length === 0) return
+ setMeta(
+ produce((draft) => {
+ for (const sessionID of sessionIDs) {
+ const key = keyFor(directory, sessionID)
+ delete draft.limit[key]
+ delete draft.complete[key]
+ delete draft.loading[key]
+ }
+ }),
+ )
+ }
+
+ const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
+ if (sessionIDs.length === 0) return
+ for (const sessionID of sessionIDs) {
+ globalSync.todo.set(sessionID, undefined)
+ }
+ setStore(
+ produce((draft) => {
+ dropSessionCaches(draft, sessionIDs)
+ }),
+ )
+ clearMeta(directory, sessionIDs)
+ }
+
+ const touch = (directory: string, setStore: Setter, sessionID: string) => {
+ const stale = pickSessionCacheEvictions({
+ seen: seenFor(directory),
+ keep: sessionID,
+ limit: SESSION_CACHE_LIMIT,
+ })
+ evict(directory, setStore, stale)
+ }
+
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
@@ -135,6 +194,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
}
+ const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
+
const loadMessages = async (input: {
directory: string
client: typeof sdk.client
@@ -148,6 +209,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
setMeta("loading", key, true)
await fetchMessages(input)
.then((next) => {
+ if (!tracked(input.directory, input.sessionID)) return
batch(() => {
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
for (const p of next.part) {
@@ -158,6 +220,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
})
.finally(() => {
+ if (!tracked(input.directory, input.sessionID)) return
setMeta("loading", key, false)
})
}
@@ -224,11 +287,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const key = keyFor(directory, sessionID)
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
+ touch(directory, setStore, sessionID)
+
+ if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return
+
const limit = meta.limit[key] ?? messagePageSize
const sessionReq = hasSession
? Promise.resolve()
: retry(() => client.session.get({ sessionID })).then((session) => {
+ if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
@@ -258,11 +326,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
+ touch(directory, setStore, sessionID)
if (store.session_diff[sessionID] !== undefined) return
const key = keyFor(directory, sessionID)
return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => {
+ if (!tracked(directory, sessionID)) return
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
}),
)
@@ -271,6 +341,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
+ touch(directory, setStore, sessionID)
const existing = store.todo[sessionID]
const cached = globalSync.data.session_todo[sessionID]
if (existing !== undefined) {
@@ -287,6 +358,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const key = keyFor(directory, sessionID)
return 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" }))
globalSync.todo.set(sessionID, list)
@@ -310,6 +382,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const directory = sdk.directory
const client = sdk.client
const [, setStore] = globalSync.child(directory)
+ touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? messagePageSize
if (meta.loading[key]) return
@@ -325,6 +398,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
},
},
+ evict(sessionID: string, directory = sdk.directory) {
+ const [, setStore] = globalSync.child(directory)
+ seenFor(directory).delete(sessionID)
+ evict(directory, setStore, [sessionID])
+ },
fetch: async (count = 10) => {
const directory = sdk.directory
const client = sdk.client