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
|
- per-directory session/message bootstrap
|
||||||
- session/message/part SSE updates
|
- 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
|
### Global session list
|
||||||
|
|
||||||
Use `useGlobalSessionsStore` when the UI needs a **shared global session cache**.
|
Use `useGlobalSessionsStore` when the UI needs a **shared global session cache**.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Message, Part, SessionStatus } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part, SessionStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { Session } from "@opencode-ai/sdk/v2"
|
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 {
|
function createSession(id: string, overrides: Partial<Session> = {}): Session {
|
||||||
return {
|
return {
|
||||||
@@ -88,3 +88,26 @@ describe("getReconnectCandidateSessionIds", () => {
|
|||||||
}).sort()).not.toContain("active")
|
}).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
|
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) {
|
export function getReconnectCandidateSessionIds(state: ReconnectMaterializationState, options?: ReconnectCandidateOptions) {
|
||||||
const ids = new Set<string>()
|
const ids = new Set<string>()
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { setSyncRefs, getAllSyncSessions } from "./sync-refs"
|
|||||||
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
|
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
|
||||||
import { applySessionEventToGlobalSessions } from "./session-event-router"
|
import { applySessionEventToGlobalSessions } from "./session-event-router"
|
||||||
import { syncDebug } from "./debug"
|
import { syncDebug } from "./debug"
|
||||||
import { getReconnectCandidateSessionIds } from "./reconnect-recovery"
|
import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./reconnect-recovery"
|
||||||
import { opencodeClient } from "@/lib/opencode/client"
|
import { opencodeClient } from "@/lib/opencode/client"
|
||||||
import { usePermissionStore } from "@/stores/permissionStore"
|
import { usePermissionStore } from "@/stores/permissionStore"
|
||||||
import { processVSCodePermissionAutoAccept } from "./vscode-permission-auto-accept"
|
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.
|
// Child load is best-effort; fall back to roots only.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge: keep root sessions from the first query (for accurate
|
// A cold OpenCode process can briefly return children before its
|
||||||
// sessionTotal), plus any child sessions from the broader query.
|
// roots query catches up. Recover referenced parents from the
|
||||||
const rootIds = new Set(rootSessions.map((s: { id: string }) => s.id))
|
// broader response or cache instead of publishing orphan rows.
|
||||||
const childSessions = allSessions.filter((s: { id: string; parentID?: string | null }) => s?.id && !rootIds.has(s.id) && s.parentID)
|
const currentSessions = store.getState().session
|
||||||
|
const { sessions, rootCount } = mergeBootstrapSessions(rootSessions, allSessions, currentSessions)
|
||||||
const sessions = rootSessions.concat(childSessions)
|
|
||||||
// Race guard: if the list came back empty but event pipeline
|
// Race guard: if the list came back empty but event pipeline
|
||||||
// already populated the store, don't clobber. OpenCode can
|
// already populated the store, don't clobber. OpenCode can
|
||||||
// answer HTTP with empty sessions while WS delivers session
|
// answer HTTP with empty sessions while WS delivers session
|
||||||
// events for the same data (disk warmup race on app launch).
|
// events for the same data (disk warmup race on app launch).
|
||||||
const currentSessions = store.getState().session
|
|
||||||
if (sessions.length === 0 && currentSessions.length > 0) {
|
if (sessions.length === 0 && currentSessions.length > 0) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[bootstrap] experimental.session.list returned empty for ${dir}; preserving ${currentSessions.length} existing sessions`,
|
`[bootstrap] experimental.session.list returned empty for ${dir}; preserving ${currentSessions.length} existing sessions`,
|
||||||
)
|
)
|
||||||
return
|
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())
|
ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState())
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user