fix(app): keep live sessions during list refresh
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
This commit is contained in:
parent
74e7a378bb
commit
a35328aab1
3 changed files with 132 additions and 13 deletions
|
|
@ -1,4 +1,7 @@
|
|||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { sessionUpdatedAt, trimSessions } from "./session-trim"
|
||||
import type { RootLoadArgs } from "./types"
|
||||
import { directoryKey } from "./utils"
|
||||
|
||||
export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
|
||||
try {
|
||||
|
|
@ -23,3 +26,40 @@ export function estimateRootSessionTotal(input: { count: number; limit: number;
|
|||
if (input.count < input.limit) return input.count
|
||||
return input.count + 1
|
||||
}
|
||||
|
||||
export function mergeRootSessionLoad(input: {
|
||||
directory: string
|
||||
loadedAt: number
|
||||
listed: Session[]
|
||||
current: Session[]
|
||||
limit: number
|
||||
permission: Record<string, PermissionRequest[]>
|
||||
}) {
|
||||
const directory = directoryKey(input.directory)
|
||||
const currentRoots = input.current.filter(
|
||||
(session) => !session.parentID && !session.time?.archived && directoryKey(session.directory) === directory,
|
||||
)
|
||||
const byID = new Map(
|
||||
input.listed
|
||||
.filter((session) => !!session?.id)
|
||||
.filter((session) => !session.time?.archived)
|
||||
.map((session) => [session.id, session] as const),
|
||||
)
|
||||
|
||||
// A list response can resolve after live create/update events have already seeded this store.
|
||||
for (const session of currentRoots) {
|
||||
const listed = byID.get(session.id)
|
||||
if (!listed) {
|
||||
if (sessionUpdatedAt(session) >= input.loadedAt) byID.set(session.id, session)
|
||||
continue
|
||||
}
|
||||
if (sessionUpdatedAt(session) >= input.loadedAt && sessionUpdatedAt(session) > sessionUpdatedAt(listed)) {
|
||||
byID.set(session.id, session)
|
||||
}
|
||||
}
|
||||
|
||||
return trimSessions([...byID.values(), ...input.current.filter((session) => !!session.parentID)], {
|
||||
limit: input.limit,
|
||||
permission: input.permission,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,35 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
estimateRootSessionTotal,
|
||||
loadRootSessionsWithFallback,
|
||||
mergeRootSessionLoad,
|
||||
} from "./global-sync/session-load"
|
||||
|
||||
function session(input: {
|
||||
id: string
|
||||
directory?: string
|
||||
created?: number
|
||||
updated?: number
|
||||
parentID?: string
|
||||
archived?: number
|
||||
}) {
|
||||
return {
|
||||
id: input.id,
|
||||
directory: input.directory ?? "/repo",
|
||||
projectID: "project",
|
||||
title: input.id,
|
||||
parentID: input.parentID,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: {
|
||||
created: input.created ?? 1_000,
|
||||
updated: input.updated ?? input.created ?? 1_000,
|
||||
archived: input.archived,
|
||||
},
|
||||
} as Session
|
||||
}
|
||||
|
||||
describe("pickDirectoriesToEvict", () => {
|
||||
test("keeps pinned stores and evicts idle stores", () => {
|
||||
|
|
@ -63,6 +92,48 @@ describe("loadRootSessionsWithFallback", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("mergeRootSessionLoad", () => {
|
||||
test("keeps sessions created while a stale list request was in flight", () => {
|
||||
const result = mergeRootSessionLoad({
|
||||
directory: "/repo",
|
||||
loadedAt: 2_000,
|
||||
listed: [],
|
||||
current: [session({ id: "new", created: 2_100 })],
|
||||
limit: 5,
|
||||
permission: {},
|
||||
})
|
||||
|
||||
expect(result.map((item) => item.id)).toEqual(["new"])
|
||||
})
|
||||
|
||||
test("keeps newer live session data over stale list responses", () => {
|
||||
const result = mergeRootSessionLoad({
|
||||
directory: "/repo",
|
||||
loadedAt: 2_000,
|
||||
listed: [session({ id: "same", updated: 1_500 })],
|
||||
current: [{ ...session({ id: "same", updated: 2_100 }), title: "new title" }],
|
||||
limit: 5,
|
||||
permission: {},
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.title).toBe("new title")
|
||||
})
|
||||
|
||||
test("allows authoritative loads to remove old missing roots", () => {
|
||||
const result = mergeRootSessionLoad({
|
||||
directory: "/repo",
|
||||
loadedAt: 2_000,
|
||||
listed: [],
|
||||
current: [session({ id: "old", updated: 1_500 })],
|
||||
limit: 5,
|
||||
permission: {},
|
||||
})
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("estimateRootSessionTotal", () => {
|
||||
test("keeps exact total for full fetches", () => {
|
||||
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,11 @@ import {
|
|||
} from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
import {
|
||||
estimateRootSessionTotal,
|
||||
loadRootSessionsWithFallback,
|
||||
mergeRootSessionLoad,
|
||||
} from "./global-sync/session-load"
|
||||
import { trimSessions } from "./global-sync/session-trim"
|
||||
import type { ProjectMeta } from "./global-sync/types"
|
||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||
|
|
@ -273,6 +277,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||
}
|
||||
|
||||
const limit = Math.max(retainedLimit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
|
||||
const loadedAt = Date.now()
|
||||
const promise = queryClient
|
||||
.fetchQuery({
|
||||
...queryOptionsApi.sessions(key),
|
||||
|
|
@ -283,13 +288,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||
list: (query) => serverSDK.client.session.list(query),
|
||||
})
|
||||
.then((x) => {
|
||||
const nonArchived = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.filter((s) => !s.time?.archived)
|
||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
const listed = (x.data ?? []).filter((s) => !!s?.id).filter((s) => !s.time?.archived)
|
||||
const limit = Math.max(store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0)
|
||||
const childSessions = store.session.filter((s) => !!s.parentID)
|
||||
const next = trimSessions([...nonArchived, ...childSessions], {
|
||||
const next = mergeRootSessionLoad({
|
||||
directory,
|
||||
loadedAt,
|
||||
listed,
|
||||
current: store.session,
|
||||
limit,
|
||||
permission: session.data.permission,
|
||||
})
|
||||
|
|
@ -297,11 +302,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||
next.forEach(session.remember)
|
||||
setStore(
|
||||
"sessionTotal",
|
||||
estimateRootSessionTotal({
|
||||
count: nonArchived.length,
|
||||
limit: x.limit,
|
||||
limited: x.limited,
|
||||
}),
|
||||
Math.max(
|
||||
estimateRootSessionTotal({
|
||||
count: listed.length,
|
||||
limit: x.limit,
|
||||
limited: x.limited,
|
||||
}),
|
||||
next.filter((item) => !item.parentID).length,
|
||||
),
|
||||
)
|
||||
setStore("session", reconcile(next, { key: "id" }))
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue