fix: eliminate parent-child session desync across reconnect and navigation (#985)

* fix(pipeline): distinguish transport switch from real disconnect

WS_FALLBACK errors (e.g. ready timeout → SSE fallback) are transport
switches, not disconnections. No events are lost because lastEventId is
preserved across the switch.

Previously, every WS timeout triggered onDisconnect → onReconnect with a
full resyncDirectoryAfterReconnect, which:
- Missed idle parent sessions in candidate selection (root cause 1)
- Could overwrite in-flight SSE state with stale fetch data (root cause 4)
- Caused isConnected to flash false→true

Now: WS_FALLBACK fires onTransportSwitch (sets isConnected only).
Real disconnections (heartbeat timeout, network error) still fire the
full onDisconnect → onReconnect → resync cycle.

* fix(sync): relationship-aware reconnect with merge-not-replace

Two changes to resyncDirectoryAfterReconnect:

1. Candidate selection now also includes parent sessions of any child
   sessions in the directory. Previously, if a child completed during
   the disconnect gap (busy→idle), neither child nor parent was selected
   because both appeared idle. The parent's task tool part would remain
   permanently stale.

2. Parent resync merges parts instead of replacing. Previously, the
   resync deleted parts for messages not in the fetch snapshot, which
   could erase parts delivered by SSE events that arrived between the
   fetch and the setState. Now only parts for messages in the snapshot
   are overwritten; everything else is preserved.

* fix(sync): demand-load child session messages on access

Bootstrap only loads session metadata — messages are populated
exclusively by SSE events. When a user navigates to an old session
that spawned subagents, child session messages were never in the store.

Add useEnsureSessionMessages hook that detects this gap (session exists
in state.session but state.message[sessionID] is absent) and triggers a
background API fetch to load messages and parts.

ToolPart already calls useSessionMessageRecords(taskSessionId) which
returns empty when not loaded. Now it also calls useEnsureSessionMessages
to populate the store on first access.

* fix(sync): unmount-safe parent resync when child session goes idle

When a child session transitions to idle (completes), the sync layer
now schedules a targeted parts repair for the parent session's task
tool part. Previously this only happened when the ToolPart component
was mounted and had observed the child being active (taskChildSeenActive).

This covers:
- User navigated away while child was running
- App restarted with active subagent sessions
- SSE reconnect where child completed during disconnect

Uses the existing repairSessionParts mechanism with its 5s cooldown
to avoid redundant fetches.

* fix: type-check fixes for sync-layer parent resync

Fix TypeScript errors in Fix 5 implementation:
- Convert currentSessionId from null to undefined for resolveFallbackTaskSessionId
- Add default empty string for dir parameter in getScopedSdkClient
- Use explicit sessionID parameter for scopedClient.session.messages

All type-checks now pass.

* fix(sync): address PR review feedback on deduplication

- Use enqueuePartsRepair for session.idle parent resync instead of
direct repairSessionParts call. enqueuePartsRepair already has a 5s
cooldown to prevent redundant parallel API calls when multiple child
sessions go idle concurrently.

- Move useEnsureSessionMessages loading guard from component-scoped
React.useRef to a module-level Set keyed by directory:sessionID.
Prevents parallel fetches when multiple ToolPart instances mount
for the same child session.

* fix(sync): add missing semicolon on useEnsureSessionMessages call

Address Greptile P2 review comment on ToolPart.tsx:1931.
This commit is contained in:
jwcrystal
2026-04-22 20:10:21 +03:00
committed by GitHub
parent 16f1d1b353
commit 17dd526731
4 changed files with 128 additions and 10 deletions
@@ -12,7 +12,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useDirectorySync, useSessionMessageRecords } from '@/sync/sync-context';
import { useDirectorySync, useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context';
import { getSyncChildStores } from '@/sync/sync-refs';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionActivity } from '@/hooks/useSessionActivity';
@@ -1928,6 +1928,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
const taskSessionId = explicitTaskSessionId ?? fallbackTaskSessionId;
const childSessionMessages = useSessionMessageRecords(taskSessionId ?? '', currentDirectory);
useEnsureSessionMessages(taskSessionId ?? '', currentDirectory);
const metadataTaskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
if (!isTaskTool) {
+9 -1
View File
@@ -35,6 +35,8 @@ export type EventPipelineInput = {
onReconnect?: () => void
/** Called when the stream disconnects (heartbeat timeout, network error, or transport failure). */
onDisconnect?: (reason: string) => void
/** Called when transport switches (e.g. WS timeout → SSE fallback) without actual disconnection. */
onTransportSwitch?: () => void
transport?: "auto" | "ws" | "sse"
}
@@ -144,7 +146,7 @@ type DirectoryQueue = {
}
export function createEventPipeline(input: EventPipelineInput) {
const { sdk, onEvent, onReconnect, onDisconnect, routeDirectory, transport = "auto" } = input
const { sdk, onEvent, onReconnect, onDisconnect, onTransportSwitch, routeDirectory, transport = "auto" } = input
const abort = new AbortController()
let disconnected = false
let lastEventId: string | undefined
@@ -514,6 +516,12 @@ export function createEventPipeline(input: EventPipelineInput) {
const code = typeof error === "object" && error !== null ? (error as { code?: unknown }).code : undefined
if (currentTransport === "ws" && code === "WS_FALLBACK") {
retryDelayMs = 0
// Transport switch (WS → SSE fallback), not a real disconnection.
// No events were lost — the next attempt will use SSE and carry
// lastEventId for gapless replay. Notify consumer so it can set
// isConnected, but do NOT treat this as a disconnection requiring
// a full directory resync.
onTransportSwitch?.()
} else if (!isAbortError(error)) {
if (!streamErrorLogged) {
streamErrorLogged = true
+1
View File
@@ -70,6 +70,7 @@ export {
useSyncDirectory,
useChildStoreManager,
useSessionMessageRecords,
useEnsureSessionMessages,
useSessionTextMessages,
useUserMessageHistory,
buildSessionMessageRecordsSnapshot,
+116 -8
View File
@@ -291,6 +291,21 @@ function getReconnectCandidateSessionIds(state: State) {
}
}
// Ensure parent sessions of any child sessions are also resynced.
// A child may have completed during the disconnect gap without the
// store knowing (SSE events lost). The parent's task tool part needs
// to reflect the child's final state.
const parentIds = new Set<string>()
for (const session of state.session) {
const parentId = (session as Session & { parentID?: string | null }).parentID
if (parentId) {
parentIds.add(parentId)
}
}
for (const pid of parentIds) {
ids.add(pid)
}
return Array.from(ids)
}
@@ -735,7 +750,6 @@ async function resyncDirectoryAfterReconnect(
.filter((record) => !!record?.info?.id)
.map((record) => stripMessageDiffSnapshots(record.info))
.sort((a, b) => cmp(a.id, b.id))
const nextMessageIds = new Set(nextMessages.map((message) => message.id))
store.setState((state: DirectoryStore) => {
const sessions = [...state.session]
@@ -755,13 +769,10 @@ async function resyncDirectoryAfterReconnect(
sessionChanged = true
}
// Merge parts: overwrite only messages present in the fetch snapshot.
// Do NOT delete parts for messages that may have been added by SSE
// events arriving between the fetch and the setState — those are more recent.
const nextPartState = { ...state.part }
const previousMessages = state.message[sessionId] ?? []
for (const message of previousMessages) {
if (!nextMessageIds.has(message.id)) {
delete nextPartState[message.id]
}
}
for (const record of records) {
const messageId = record?.info?.id
if (!messageId) continue
@@ -1104,7 +1115,25 @@ function handleEvent(
}
}
// Read live state, create targeted draft cloning ONLY fields the event
// Sync-layer parent resync: when a child session goes idle, schedule
// a targeted parts repair for the parent session. This ensures the
// parent's task tool part reflects the child's completion even when
// no ToolPart component is mounted.
if (payload.type === "session.idle") {
const idleSessionId = getSessionIdFromPayload(payload)
if (idleSessionId && resolvedDirectory && resolvedDirectory !== "global") {
const sessionState = store.getState()
const idleSession = sessionState.session.find((s) => s.id === idleSessionId)
const parentID = idleSession
? (idleSession as Session & { parentID?: string | null }).parentID
: null
if (parentID) {
enqueuePartsRepair(resolvedDirectory, parentID, childStores)
}
}
}
// Read live state, create targeted draft cloning ONLY fields that event
// type will mutate. This preserves reference identity for untouched slices
// so Zustand selectors skip re-renders for unrelated subscribers.
const current = store.getState()
@@ -1365,6 +1394,12 @@ export function SyncProvider(props: {
onDisconnect: (reason) => {
useConfigStore.setState({ isConnected: false, lastDisconnectReason: reason })
},
onTransportSwitch: () => {
// Transport switched (e.g. WS timeout → SSE fallback) without
// actual disconnection. No events lost — just update connection
// state without triggering a full directory resync.
useConfigStore.setState({ isConnected: true })
},
})
return cleanup
}, [props.sdk, childStores, routingIndex, messageStreamTransport])
@@ -1848,6 +1883,79 @@ export function useSessionMessageRecords(
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot)
}
/**
* Ensures a session's messages are loaded into the sync store.
* If the session exists in state.session but messages haven't been fetched
* (state.message[sessionID] is absent), triggers a background API fetch.
*
* This covers the case where a user navigates to an old parent session
* whose child session messages were never loaded — bootstrap only loads
* session metadata, not messages.
*/
// Module-level in-flight tracking for useEnsureSessionMessages.
// Prevents redundant parallel fetches when multiple component instances
// (e.g. multiple ToolParts) request the same session's messages.
const _ensureMessagesLoading = new Set<string>()
export function useEnsureSessionMessages(sessionID: string, directory?: string) {
const store = useDirectoryStore(directory)
React.useEffect(() => {
if (!sessionID) return
const state = store.getState()
// Already loaded — nothing to do
if (Object.prototype.hasOwnProperty.call(state.message, sessionID)) return
// Session doesn't exist — nothing to load
if (!state.session.some((s) => s.id === sessionID)) return
const dir = directory ?? opencodeClient.getDirectory()
const loadingKey = `${dir ?? ""}:${sessionID}`
// Already loading this session for this directory
if (_ensureMessagesLoading.has(loadingKey)) return
_ensureMessagesLoading.add(loadingKey)
void (async () => {
try {
const scopedClient = opencodeClient.getScopedSdkClient(dir ?? "")
const response = await scopedClient.session.messages({
sessionID: sessionID,
limit: RECONNECT_MESSAGE_LIMIT,
})
const records = (response.data ?? []).filter(
(record: { info?: { id?: string } }) => !!record?.info?.id,
)
if (records.length === 0) return
const nextMessages = records
.map((record: { info: Message }) => stripMessageDiffSnapshots(record.info))
.filter((m: Message | null): m is Message => m !== null)
.sort((a: Message, b: Message) => cmp(a.id, b.id))
const nextPartState: Record<string, Part[]> = {}
for (const record of records) {
const messageId = record?.info?.id
if (!messageId) continue
nextPartState[messageId] = (record.parts ?? [])
.filter((part: Part) => !!part?.id && !RECONNECT_SKIP_PARTS.has(part.type))
.sort((a: Part, b: Part) => cmp(a.id, b.id))
}
store.setState((state: DirectoryStore) => ({
message: { ...state.message, [sessionID]: nextMessages },
part: { ...state.part, ...nextPartState },
}))
} catch {
// Transient failure — next navigation or reconnect will retry
} finally {
_ensureMessagesLoading.delete(loadingKey)
}
})()
}, [sessionID, store, directory])
}
/**
* Determines if a session is actively working.
* Checks session_status and only falls back to incomplete assistant messages