fix: recover parent sessions during bootstrap
Preserves referenced parents when child sessions arrive before roots Avoids publishing orphan-only bootstrap snapshots Adds tests for bootstrap session merging
This commit is contained in:
@@ -61,6 +61,10 @@ Examples:
|
||||
- per-directory session/message bootstrap
|
||||
- session/message/part SSE updates
|
||||
|
||||
Directory bootstrap must publish a closed session hierarchy: when a child is
|
||||
returned before the roots query catches up during cold startup, retain or
|
||||
recover its referenced parent instead of exposing an orphan-only snapshot.
|
||||
|
||||
### Global session list
|
||||
|
||||
Use `useGlobalSessionsStore` when the UI needs a **shared global session cache**.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part, SessionStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { getReconnectCandidateSessionIds } from "./reconnect-recovery"
|
||||
import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./reconnect-recovery"
|
||||
|
||||
function createSession(id: string, overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
@@ -88,3 +88,26 @@ describe("getReconnectCandidateSessionIds", () => {
|
||||
}).sort()).not.toContain("active")
|
||||
})
|
||||
})
|
||||
|
||||
describe("mergeBootstrapSessions", () => {
|
||||
test("recovers a referenced parent when the roots response is temporarily empty", () => {
|
||||
const parent = createSession("parent")
|
||||
const child = createSession("child", { parentID: "parent" })
|
||||
|
||||
expect(mergeBootstrapSessions([], [child], [parent])).toEqual({
|
||||
sessions: [child, parent],
|
||||
rootCount: 1,
|
||||
})
|
||||
})
|
||||
|
||||
test("recovers referenced parents from the broader response without retaining stale roots", () => {
|
||||
const parent = createSession("parent")
|
||||
const stale = createSession("stale")
|
||||
const child = createSession("child", { parentID: "parent" })
|
||||
|
||||
expect(mergeBootstrapSessions([], [parent, child], [stale])).toEqual({
|
||||
sessions: [child, parent],
|
||||
rootCount: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,6 +19,46 @@ type ReconnectCandidateOptions = {
|
||||
viewedSession?: ViewedSessionMaterializationTarget | null
|
||||
}
|
||||
|
||||
export function mergeBootstrapSessions(
|
||||
rootSessions: Session[],
|
||||
allSessions: Session[],
|
||||
existingSessions: Session[],
|
||||
): { sessions: Session[]; rootCount: number } {
|
||||
const rootIds = new Set(rootSessions.map((session) => session.id))
|
||||
const sessionsById = new Map(existingSessions.map((session) => [session.id, session]))
|
||||
for (const session of allSessions) sessionsById.set(session.id, session)
|
||||
for (const session of rootSessions) sessionsById.set(session.id, session)
|
||||
|
||||
const includedIds = new Set(rootIds)
|
||||
const pendingParentIds: string[] = []
|
||||
for (const session of allSessions) {
|
||||
const parentId = (session as Session & { parentID?: string | null }).parentID
|
||||
if (!parentId) continue
|
||||
includedIds.add(session.id)
|
||||
pendingParentIds.push(parentId)
|
||||
}
|
||||
|
||||
while (pendingParentIds.length > 0) {
|
||||
const parentId = pendingParentIds.pop()
|
||||
if (!parentId || includedIds.has(parentId)) continue
|
||||
const parent = sessionsById.get(parentId)
|
||||
if (!parent) continue
|
||||
includedIds.add(parentId)
|
||||
const ancestorId = (parent as Session & { parentID?: string | null }).parentID
|
||||
if (ancestorId) pendingParentIds.push(ancestorId)
|
||||
}
|
||||
|
||||
const sessions = [...includedIds]
|
||||
.map((id) => sessionsById.get(id))
|
||||
.filter((session): session is Session => Boolean(session))
|
||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
const rootCount = sessions.reduce((count, session) => (
|
||||
(session as Session & { parentID?: string | null }).parentID ? count : count + 1
|
||||
), 0)
|
||||
|
||||
return { sessions, rootCount }
|
||||
}
|
||||
|
||||
export function getReconnectCandidateSessionIds(state: ReconnectMaterializationState, options?: ReconnectCandidateOptions) {
|
||||
const ids = new Set<string>()
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import { setSyncRefs, getAllSyncSessions } from "./sync-refs"
|
||||
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
|
||||
import { applySessionEventToGlobalSessions } from "./session-event-router"
|
||||
import { syncDebug } from "./debug"
|
||||
import { getReconnectCandidateSessionIds } from "./reconnect-recovery"
|
||||
import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./reconnect-recovery"
|
||||
import { opencodeClient } from "@/lib/opencode/client"
|
||||
import { usePermissionStore } from "@/stores/permissionStore"
|
||||
import { processVSCodePermissionAutoAccept } from "./vscode-permission-auto-accept"
|
||||
@@ -1780,24 +1780,22 @@ export function SyncProvider(props: {
|
||||
// Child load is best-effort; fall back to roots only.
|
||||
}
|
||||
|
||||
// Merge: keep root sessions from the first query (for accurate
|
||||
// sessionTotal), plus any child sessions from the broader query.
|
||||
const rootIds = new Set(rootSessions.map((s: { id: string }) => s.id))
|
||||
const childSessions = allSessions.filter((s: { id: string; parentID?: string | null }) => s?.id && !rootIds.has(s.id) && s.parentID)
|
||||
|
||||
const sessions = rootSessions.concat(childSessions)
|
||||
// A cold OpenCode process can briefly return children before its
|
||||
// roots query catches up. Recover referenced parents from the
|
||||
// broader response or cache instead of publishing orphan rows.
|
||||
const currentSessions = store.getState().session
|
||||
const { sessions, rootCount } = mergeBootstrapSessions(rootSessions, allSessions, currentSessions)
|
||||
// Race guard: if the list came back empty but event pipeline
|
||||
// already populated the store, don't clobber. OpenCode can
|
||||
// answer HTTP with empty sessions while WS delivers session
|
||||
// events for the same data (disk warmup race on app launch).
|
||||
const currentSessions = store.getState().session
|
||||
if (sessions.length === 0 && currentSessions.length > 0) {
|
||||
console.warn(
|
||||
`[bootstrap] experimental.session.list returned empty for ${dir}; preserving ${currentSessions.length} existing sessions`,
|
||||
)
|
||||
return
|
||||
}
|
||||
store.setState({ session: sessions, sessionTotal: rootSessions.length, limit: Math.max(sessions.length, 50) })
|
||||
store.setState({ session: sessions, sessionTotal: rootCount, limit: Math.max(sessions.length, 50) })
|
||||
ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState())
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user