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:
Bohdan Triapitsyn
2026-07-18 21:52:11 +03:00
parent 3eb5ccd291
commit fee6a55bab
4 changed files with 75 additions and 10 deletions
+4
View File
@@ -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>()
+7 -9
View File
@@ -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())
}),
})