fix(desktop): Path mismatches cause sessions missing + strong ID + existing data fix (#25013)

This commit is contained in:
Luke Parker 2026-04-30 08:39:19 +10:00 committed by GitHub
commit d7b7be1909
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 485 additions and 202 deletions

View file

@ -33,6 +33,7 @@ import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { formatServerError } from "@/utils/server-errors"
import { queryOptions, skipToken, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils"
type GlobalStore = {
ready: boolean
@ -169,18 +170,20 @@ function createGlobalSync() {
const queue = createRefreshQueue({
paused,
key: directoryKey,
bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }),
bootstrapInstance,
})
const sdkFor = (directory: string) => {
const cached = sdkCache.get(directory)
const key = directoryKey(directory)
const cached = sdkCache.get(key)
if (cached) return cached
const sdk = globalSDK.createClient({
directory,
throwOnError: true,
})
sdkCache.set(directory, sdk)
sdkCache.set(key, sdk)
return sdk
}
@ -192,23 +195,25 @@ function createGlobalSync() {
void bootstrapInstance(directory)
},
onDispose: (directory) => {
queue.clear(directory)
sessionMeta.delete(directory)
sdkCache.delete(directory)
clearProviderRev(directory)
clearSessionPrefetchDirectory(directory)
const key = directoryKey(directory)
queue.clear(key)
sessionMeta.delete(key)
sdkCache.delete(key)
clearProviderRev(key)
clearSessionPrefetchDirectory(key)
},
translate: language.t,
getSdk: sdkFor,
})
async function loadSessions(directory: string) {
const pending = sessionLoads.get(directory)
const key = directoryKey(directory)
const pending = sessionLoads.get(key)
if (pending) return pending
children.pin(directory)
children.pin(key)
const [store, setStore] = children.child(directory, { bootstrap: false })
const meta = sessionMeta.get(directory)
const meta = sessionMeta.get(key)
if (meta && meta.limit >= store.limit) {
const next = trimSessions(store.session, {
limit: store.limit,
@ -218,14 +223,14 @@ function createGlobalSync() {
setStore("session", reconcile(next, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
}
children.unpin(directory)
children.unpin(key)
return
}
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
const promise = queryClient
.fetchQuery({
...loadSessionsQuery(directory),
...loadSessionsQuery(key),
queryFn: () =>
loadRootSessionsWithFallback({
directory,
@ -255,7 +260,7 @@ function createGlobalSync() {
setStore("session", reconcile(sessions, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
})
sessionMeta.set(directory, { limit })
sessionMeta.set(key, { limit })
})
.catch((err) => {
console.error("Failed to load sessions", err)
@ -270,23 +275,24 @@ function createGlobalSync() {
})
.then(() => {})
sessionLoads.set(directory, promise)
sessionLoads.set(key, promise)
void promise.finally(() => {
sessionLoads.delete(directory)
children.unpin(directory)
sessionLoads.delete(key)
children.unpin(key)
})
return promise
}
async function bootstrapInstance(directory: string) {
if (!directory) return
const pending = booting.get(directory)
const key = directoryKey(directory)
if (!key) return
const pending = booting.get(key)
if (pending) return pending
children.pin(directory)
children.pin(key)
const promise = Promise.resolve().then(async () => {
const child = children.ensureChild(directory)
const cache = children.vcsCache.get(directory)
const cache = children.vcsCache.get(key)
if (!cache) return
const sdk = sdkFor(directory)
await bootstrapDirectory({
@ -307,16 +313,17 @@ function createGlobalSync() {
})
})
booting.set(directory, promise)
booting.set(key, promise)
void promise.finally(() => {
booting.delete(directory)
children.unpin(directory)
booting.delete(key)
children.unpin(key)
})
return promise
}
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const key = directoryKey(directory)
const event = e.details
const recent = bootingRoot || Date.now() - bootedAt < 1500
@ -339,9 +346,9 @@ function createGlobalSync() {
return
}
const existing = children.children[directory]
const existing = children.children[key]
if (!existing) return
children.mark(directory)
children.mark(key)
const [store, setStore] = existing
applyDirectoryEvent({
event,
@ -350,9 +357,9 @@ function createGlobalSync() {
setStore,
push: queue.push,
setSessionTodo,
vcsCache: children.vcsCache.get(directory),
vcsCache: children.vcsCache.get(key),
loadLsp: () => {
void queryClient.fetchQuery(loadLspQuery(directory, sdkFor(directory)))
void queryClient.fetchQuery(loadLspQuery(key, sdkFor(directory)))
},
})
})
@ -363,7 +370,7 @@ function createGlobalSync() {
})
onCleanup(() => {
for (const directory of Object.keys(children.children)) {
children.disposeDirectory(directory)
children.disposeDirectory(directoryKey(directory))
}
})

View file

@ -17,6 +17,7 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQueries } from "@tanstack/solid-query"
import { loadPathQuery, loadProvidersQuery } from "./bootstrap"
import { loadLspQuery, loadMcpQuery } from "../global-sync"
import { directoryKey, type DirectoryKey } from "./utils"
export function createChildStoreManager(input: {
owner: Owner
@ -36,30 +37,37 @@ export function createChildStoreManager(input: {
const ownerPins = new WeakMap<object, Set<string>>()
const disposers = new Map<string, () => void>()
const markKey = (key: DirectoryKey) => {
if (!key) return
lifecycle.set(key, { lastAccessAt: Date.now() })
runEviction(key)
}
const mark = (directory: string) => {
if (!directory) return
lifecycle.set(directory, { lastAccessAt: Date.now() })
runEviction(directory)
const key = directoryKey(directory)
markKey(key)
}
const pin = (directory: string) => {
if (!directory) return
pins.set(directory, (pins.get(directory) ?? 0) + 1)
mark(directory)
const key = directoryKey(directory)
if (!key) return
pins.set(key, (pins.get(key) ?? 0) + 1)
markKey(key)
}
const unpin = (directory: string) => {
if (!directory) return
const next = (pins.get(directory) ?? 0) - 1
const key = directoryKey(directory)
if (!key) return
const next = (pins.get(key) ?? 0) - 1
if (next > 0) {
pins.set(directory, next)
pins.set(key, next)
return
}
pins.delete(directory)
pins.delete(key)
runEviction()
}
const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0
const pinned = (directory: string) => (pins.get(directoryKey(directory)) ?? 0) > 0
const pinForOwner = (directory: string) => {
const current = getOwner()
@ -81,30 +89,31 @@ export function createChildStoreManager(input: {
})
}
function disposeDirectory(directory: string) {
function disposeDirectory(directory: DirectoryKey) {
const key = directory
if (
!canDisposeDirectory({
directory,
hasStore: !!children[directory],
pinned: pinned(directory),
booting: input.isBooting(directory),
loadingSessions: input.isLoadingSessions(directory),
directory: key,
hasStore: !!children[key],
pinned: pinned(key),
booting: input.isBooting(key),
loadingSessions: input.isLoadingSessions(key),
})
) {
return false
}
vcsCache.delete(directory)
metaCache.delete(directory)
iconCache.delete(directory)
lifecycle.delete(directory)
const dispose = disposers.get(directory)
vcsCache.delete(key)
metaCache.delete(key)
iconCache.delete(key)
lifecycle.delete(key)
const dispose = disposers.get(key)
if (dispose) {
dispose()
disposers.delete(directory)
disposers.delete(key)
}
delete children[directory]
input.onDispose(directory)
delete children[key]
input.onDispose(key)
return true
}
@ -121,13 +130,14 @@ export function createChildStoreManager(input: {
}).filter((directory) => directory !== skip)
if (list.length === 0) return
for (const directory of list) {
if (!disposeDirectory(directory)) continue
if (!disposeDirectory(directoryKey(directory))) continue
}
}
function ensureChild(directory: string) {
if (!directory) console.error("No directory provided")
if (!children[directory]) {
const key = directoryKey(directory)
if (!key) console.error("No directory provided")
if (!children[key]) {
const vcs = runWithOwner(input.owner, () =>
persisted(
Persist.workspace(directory, "vcs", ["vcs.v1"]),
@ -136,7 +146,7 @@ export function createChildStoreManager(input: {
)
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
const vcsStore = vcs[0]
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
const meta = runWithOwner(input.owner, () =>
persisted(
@ -145,7 +155,7 @@ export function createChildStoreManager(input: {
),
)
if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed"))
metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] })
const icon = runWithOwner(input.owner, () =>
persisted(
@ -154,7 +164,7 @@ export function createChildStoreManager(input: {
),
)
if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[3] })
const init = () =>
createRoot((dispose) => {
@ -165,10 +175,10 @@ export function createChildStoreManager(input: {
const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({
queries: [
loadPathQuery(directory, sdk),
loadMcpQuery(directory, sdk),
loadLspQuery(directory, sdk),
loadProvidersQuery(directory, sdk),
loadPathQuery(key, sdk),
loadMcpQuery(key, sdk),
loadLspQuery(key, sdk),
loadProvidersQuery(key, sdk),
],
}))
@ -213,13 +223,13 @@ export function createChildStoreManager(input: {
message: {},
part: {},
})
children[directory] = child
disposers.set(directory, dispose)
children[key] = child
disposers.set(key, dispose)
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return
void init.then(() => {
if (children[directory] !== child) return
if (children[key] !== child) return
run()
})
}
@ -243,15 +253,16 @@ export function createChildStoreManager(input: {
runWithOwner(input.owner, init)
}
mark(directory)
const childStore = children[directory]
markKey(key)
const childStore = children[key]
if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed"))
return childStore
}
function child(directory: string, options: ChildOptions = {}) {
const key = directoryKey(directory)
const childStore = ensureChild(directory)
pinForOwner(directory)
pinForOwner(key)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
@ -260,6 +271,7 @@ export function createChildStoreManager(input: {
}
function peek(directory: string, options: ChildOptions = {}) {
const key = directoryKey(directory)
const childStore = ensureChild(directory)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
@ -269,8 +281,9 @@ export function createChildStoreManager(input: {
}
function projectMeta(directory: string, patch: ProjectMeta) {
const key = directoryKey(directory)
const [store, setStore] = ensureChild(directory)
const cached = metaCache.get(directory)
const cached = metaCache.get(key)
if (!cached) return
const previous = store.projectMeta ?? {}
const icon = patch.icon ? { ...previous.icon, ...patch.icon } : previous.icon
@ -286,8 +299,9 @@ export function createChildStoreManager(input: {
}
function projectIcon(directory: string, value: string | undefined) {
const key = directoryKey(directory)
const [store, setStore] = ensureChild(directory)
const cached = iconCache.get(directory)
const cached = iconCache.get(key)
if (!cached) return
if (store.icon === value) return
cached.setStore("value", value)

View file

@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test"
import { createRefreshQueue } from "./queue"
import { directoryKey } from "./utils"
const tick = () => new Promise((resolve) => setTimeout(resolve, 10))
describe("createRefreshQueue", () => {
test("clears queued directories by normalized key", async () => {
const calls: string[] = []
const queue = createRefreshQueue({
paused: () => false,
key: directoryKey,
bootstrap: async () => {},
bootstrapInstance: (directory) => {
calls.push(directory)
},
})
queue.push("C:\\tmp\\demo")
queue.clear("C:/tmp/demo")
await tick()
expect(calls).toEqual([])
queue.dispose()
})
test("passes the original directory to bootstrapInstance", async () => {
const calls: string[] = []
const queue = createRefreshQueue({
paused: () => false,
key: directoryKey,
bootstrap: async () => {},
bootstrapInstance: (directory) => {
calls.push(directory)
},
})
queue.push("C:\\tmp\\demo")
await tick()
expect(calls).toEqual(["C:\\tmp\\demo"])
queue.dispose()
})
})

View file

@ -2,22 +2,25 @@ type QueueInput = {
paused: () => boolean
bootstrap: () => Promise<void>
bootstrapInstance: (directory: string) => Promise<void> | void
key?: (directory: string) => string
}
export function createRefreshQueue(input: QueueInput) {
const queued = new Set<string>()
const queued = new Map<string, string>()
let root = false
let running = false
let timer: ReturnType<typeof setTimeout> | undefined
const key = input.key ?? ((directory: string) => directory)
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
const take = (count: number) => {
if (queued.size === 0) return [] as string[]
const items: string[] = []
for (const item of queued) {
queued.delete(item)
items.push(item)
for (const [id, directory] of queued) {
queued.delete(id)
items.push(directory)
if (items.length >= count) break
}
return items
@ -33,7 +36,7 @@ export function createRefreshQueue(input: QueueInput) {
const push = (directory: string) => {
if (!directory) return
queued.add(directory)
queued.set(key(directory), directory)
if (input.paused()) return
schedule()
}
@ -73,7 +76,7 @@ export function createRefreshQueue(input: QueueInput) {
push,
refresh,
clear(directory: string) {
queued.delete(directory)
queued.delete(key(directory))
},
dispose() {
if (!timer) return

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Agent } from "@opencode-ai/sdk/v2/client"
import { normalizeAgentList } from "./utils"
import { directoryKey, normalizeAgentList } from "./utils"
const agent = (name = "build") =>
({
@ -33,3 +33,20 @@ describe("normalizeAgentList", () => {
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
})
})
describe("directoryKey", () => {
test("normalizes slashes", () => {
expect(String(directoryKey("C:\\Repos\\sst\\opencode"))).toBe("C:/Repos/sst/opencode")
expect(String(directoryKey("C:/Repos/sst/opencode"))).toBe("C:/Repos/sst/opencode")
})
test("preserves backslashes in posix paths", () => {
expect(String(directoryKey("/tmp/foo\\bar"))).toBe("/tmp/foo\\bar")
})
test("trims trailing slashes without breaking roots", () => {
expect(String(directoryKey("C:/Repos/sst/opencode/"))).toBe("C:/Repos/sst/opencode")
expect(String(directoryKey("C:/"))).toBe("C:/")
expect(String(directoryKey("/"))).toBe("/")
})
})

View file

@ -1,4 +1,5 @@
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)