fix: prune and evict stale app session caches (#16584)
This commit is contained in:
@@ -27,7 +27,7 @@ import type { InitError } from "../pages/error"
|
|||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
||||||
import { createChildStoreManager } from "./global-sync/child-store"
|
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 { createRefreshQueue } from "./global-sync/queue"
|
||||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||||
import { trimSessions } from "./global-sync/session-trim"
|
import { trimSessions } from "./global-sync/session-trim"
|
||||||
@@ -189,6 +189,7 @@ function createGlobalSync() {
|
|||||||
})
|
})
|
||||||
if (next.length !== store.session.length) {
|
if (next.length !== store.session.length) {
|
||||||
setStore("session", reconcile(next, { key: "id" }))
|
setStore("session", reconcile(next, { key: "id" }))
|
||||||
|
cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
|
||||||
}
|
}
|
||||||
children.unpin(directory)
|
children.unpin(directory)
|
||||||
return
|
return
|
||||||
@@ -220,6 +221,7 @@ function createGlobalSync() {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
setStore("session", reconcile(sessions, { key: "id" }))
|
setStore("session", reconcile(sessions, { key: "id" }))
|
||||||
|
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
|
||||||
sessionMeta.set(directory, { limit })
|
sessionMeta.set(directory, { limit })
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|||||||
@@ -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 type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { State } from "./types"
|
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 }) =>
|
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", () => {
|
test("upserts and removes messages while clearing orphaned parts", () => {
|
||||||
const sessionID = "ses_1"
|
const sessionID = "ses_1"
|
||||||
const [store, setStore] = createStore(
|
const [store, setStore] = createStore(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import { trimSessions } from "./session-trim"
|
import { trimSessions } from "./session-trim"
|
||||||
|
import { dropSessionCaches } from "./session-cache"
|
||||||
|
|
||||||
export function applyGlobalEvent(input: {
|
export function applyGlobalEvent(input: {
|
||||||
event: { type: string; properties?: unknown }
|
event: { type: string; properties?: unknown }
|
||||||
@@ -40,37 +41,44 @@ export function applyGlobalEvent(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cleanupSessionCaches(
|
function cleanupSessionCaches(
|
||||||
store: Store<State>,
|
|
||||||
setStore: SetStoreFunction<State>,
|
setStore: SetStoreFunction<State>,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||||
) {
|
) {
|
||||||
if (!sessionID) return
|
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)
|
setSessionTodo?.(sessionID, undefined)
|
||||||
if (!hasAny) return
|
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const messages = draft.message[sessionID]
|
dropSessionCaches(draft, [sessionID])
|
||||||
if (messages) {
|
}),
|
||||||
for (const message of messages) {
|
)
|
||||||
const id = message?.id
|
}
|
||||||
if (!id) continue
|
|
||||||
delete draft.part[id]
|
export function cleanupDroppedSessionCaches(
|
||||||
}
|
store: Store<State>,
|
||||||
}
|
setStore: SetStoreFunction<State>,
|
||||||
delete draft.message[sessionID]
|
next: Session[],
|
||||||
delete draft.session_diff[sessionID]
|
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||||
delete draft.todo[sessionID]
|
) {
|
||||||
delete draft.permission[sessionID]
|
const keep = new Set(next.map((item) => item.id))
|
||||||
delete draft.question[sessionID]
|
const stale = [
|
||||||
delete draft.session_status[sessionID]
|
...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)
|
next.splice(result.index, 0, info)
|
||||||
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
||||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||||
|
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||||
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
|
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
|
||||||
break
|
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
|
if (info.parentID) break
|
||||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
break
|
break
|
||||||
@@ -130,6 +139,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
next.splice(result.index, 0, info)
|
next.splice(result.index, 0, info)
|
||||||
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
||||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||||
|
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.deleted": {
|
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
|
if (info.parentID) break
|
||||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
|||||||
import { useGlobalSync } from "./global-sync"
|
import { useGlobalSync } from "./global-sync"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
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[]) {
|
function sortParts(parts: Part[]) {
|
||||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
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 inflight = new Map<string, Promise<void>>()
|
||||||
const inflightDiff = new Map<string, Promise<void>>()
|
const inflightDiff = new Map<string, Promise<void>>()
|
||||||
const inflightTodo = 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({
|
const [meta, setMeta] = createStore({
|
||||||
limit: {} as Record<string, number>,
|
limit: {} as Record<string, number>,
|
||||||
complete: {} as Record<string, boolean>,
|
complete: {} as Record<string, boolean>,
|
||||||
@@ -121,6 +124,62 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
return undefined
|
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 fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
|
||||||
const messages = await retry(() =>
|
const messages = await retry(() =>
|
||||||
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
|
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: {
|
const loadMessages = async (input: {
|
||||||
directory: string
|
directory: string
|
||||||
client: typeof sdk.client
|
client: typeof sdk.client
|
||||||
@@ -148,6 +209,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
setMeta("loading", key, true)
|
setMeta("loading", key, true)
|
||||||
await fetchMessages(input)
|
await fetchMessages(input)
|
||||||
.then((next) => {
|
.then((next) => {
|
||||||
|
if (!tracked(input.directory, input.sessionID)) return
|
||||||
batch(() => {
|
batch(() => {
|
||||||
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
|
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
|
||||||
for (const p of next.part) {
|
for (const p of next.part) {
|
||||||
@@ -158,6 +220,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
if (!tracked(input.directory, input.sessionID)) return
|
||||||
setMeta("loading", key, false)
|
setMeta("loading", key, false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -224,11 +287,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
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 limit = meta.limit[key] ?? messagePageSize
|
||||||
|
|
||||||
const sessionReq = hasSession
|
const sessionReq = hasSession
|
||||||
? Promise.resolve()
|
? Promise.resolve()
|
||||||
: retry(() => client.session.get({ sessionID })).then((session) => {
|
: retry(() => client.session.get({ sessionID })).then((session) => {
|
||||||
|
if (!tracked(directory, sessionID)) return
|
||||||
const data = session.data
|
const data = session.data
|
||||||
if (!data) return
|
if (!data) return
|
||||||
setStore(
|
setStore(
|
||||||
@@ -258,11 +326,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
const [store, setStore] = globalSync.child(directory)
|
const [store, setStore] = globalSync.child(directory)
|
||||||
|
touch(directory, setStore, sessionID)
|
||||||
if (store.session_diff[sessionID] !== undefined) return
|
if (store.session_diff[sessionID] !== undefined) return
|
||||||
|
|
||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
return runInflight(inflightDiff, key, () =>
|
return runInflight(inflightDiff, key, () =>
|
||||||
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
||||||
|
if (!tracked(directory, sessionID)) return
|
||||||
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
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 directory = sdk.directory
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
const [store, setStore] = globalSync.child(directory)
|
const [store, setStore] = globalSync.child(directory)
|
||||||
|
touch(directory, setStore, sessionID)
|
||||||
const existing = store.todo[sessionID]
|
const existing = store.todo[sessionID]
|
||||||
const cached = globalSync.data.session_todo[sessionID]
|
const cached = globalSync.data.session_todo[sessionID]
|
||||||
if (existing !== undefined) {
|
if (existing !== undefined) {
|
||||||
@@ -287,6 +358,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
return runInflight(inflightTodo, key, () =>
|
return runInflight(inflightTodo, key, () =>
|
||||||
retry(() => client.session.todo({ sessionID })).then((todo) => {
|
retry(() => client.session.todo({ sessionID })).then((todo) => {
|
||||||
|
if (!tracked(directory, sessionID)) return
|
||||||
const list = todo.data ?? []
|
const list = todo.data ?? []
|
||||||
setStore("todo", sessionID, reconcile(list, { key: "id" }))
|
setStore("todo", sessionID, reconcile(list, { key: "id" }))
|
||||||
globalSync.todo.set(sessionID, list)
|
globalSync.todo.set(sessionID, list)
|
||||||
@@ -310,6 +382,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
const [, setStore] = globalSync.child(directory)
|
const [, setStore] = globalSync.child(directory)
|
||||||
|
touch(directory, setStore, sessionID)
|
||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
const step = count ?? messagePageSize
|
const step = count ?? messagePageSize
|
||||||
if (meta.loading[key]) return
|
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) => {
|
fetch: async (count = 10) => {
|
||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { useProviders } from "@/hooks/use-providers"
|
|||||||
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { clearWorkspaceTerminals } from "@/context/terminal"
|
import { clearWorkspaceTerminals } from "@/context/terminal"
|
||||||
|
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
|
||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
@@ -657,25 +658,24 @@ export default function Layout(props: ParentProps) {
|
|||||||
const prefetchQueues = new Map<string, PrefetchQueue>()
|
const prefetchQueues = new Map<string, PrefetchQueue>()
|
||||||
|
|
||||||
const PREFETCH_MAX_SESSIONS_PER_DIR = 10
|
const PREFETCH_MAX_SESSIONS_PER_DIR = 10
|
||||||
const prefetchedByDir = new Map<string, Map<string, true>>()
|
const prefetchedByDir = new Map<string, Set<string>>()
|
||||||
|
|
||||||
const lruFor = (directory: string) => {
|
const lruFor = (directory: string) => {
|
||||||
const existing = prefetchedByDir.get(directory)
|
const existing = prefetchedByDir.get(directory)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
const created = new Map<string, true>()
|
const created = new Set<string>()
|
||||||
prefetchedByDir.set(directory, created)
|
prefetchedByDir.set(directory, created)
|
||||||
return created
|
return created
|
||||||
}
|
}
|
||||||
|
|
||||||
const markPrefetched = (directory: string, sessionID: string) => {
|
const markPrefetched = (directory: string, sessionID: string) => {
|
||||||
const lru = lruFor(directory)
|
const lru = lruFor(directory)
|
||||||
if (lru.has(sessionID)) lru.delete(sessionID)
|
return pickSessionCacheEvictions({
|
||||||
lru.set(sessionID, true)
|
seen: lru,
|
||||||
while (lru.size > PREFETCH_MAX_SESSIONS_PER_DIR) {
|
keep: sessionID,
|
||||||
const oldest = lru.keys().next().value as string | undefined
|
limit: PREFETCH_MAX_SESSIONS_PER_DIR,
|
||||||
if (!oldest) return
|
preserve: directory === params.dir && params.id ? [params.id] : undefined,
|
||||||
lru.delete(oldest)
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -724,6 +724,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||||
.then((messages) => {
|
.then((messages) => {
|
||||||
if (prefetchToken.value !== token) return
|
if (prefetchToken.value !== token) return
|
||||||
|
if (!lruFor(directory).has(sessionID)) return
|
||||||
|
|
||||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||||
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
|
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
|
||||||
@@ -787,7 +788,18 @@ export default function Layout(props: ParentProps) {
|
|||||||
const lru = lruFor(directory)
|
const lru = lruFor(directory)
|
||||||
const known = lru.has(session.id)
|
const known = lru.has(session.id)
|
||||||
if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return
|
if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return
|
||||||
markPrefetched(directory, session.id)
|
const stale = markPrefetched(directory, session.id)
|
||||||
|
if (stale.length > 0) {
|
||||||
|
const [, setStore] = globalSync.child(directory, { bootstrap: false })
|
||||||
|
for (const id of stale) {
|
||||||
|
globalSync.todo.set(id, undefined)
|
||||||
|
}
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
dropSessionCaches(draft, stale)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (priority === "high") q.pending.unshift(session.id)
|
if (priority === "high") q.pending.unshift(session.id)
|
||||||
if (priority !== "high") q.pending.push(session.id)
|
if (priority !== "high") q.pending.push(session.id)
|
||||||
|
|||||||
@@ -426,10 +426,12 @@ export default function Page() {
|
|||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => params.id,
|
() => ({ dir: params.dir, id: params.id }),
|
||||||
(id, prev) => {
|
(next, prev) => {
|
||||||
if (id || !prev) return
|
if (!prev) return
|
||||||
resetSessionModel(local)
|
if (next.dir === prev.dir && next.id === prev.id) return
|
||||||
|
if (prev.id) sync.session.evict(prev.id, prev.dir)
|
||||||
|
if (!next.id) resetSessionModel(local)
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user