fix: recover stuck live session updates
Reconnects and resyncs active sessions when live updates stall Normalizes synthetic session status events Uses authoritative status snapshots to clear stale busy states
This commit is contained in:
@@ -96,4 +96,43 @@ describe("createEventPipeline", () => {
|
|||||||
return `updated:${((event.properties as { part: { text: string } }).part).text}`
|
return `updated:${((event.properties as { part: { text: string } }).part).text}`
|
||||||
})).toEqual(["updated:a", "delta:b", "updated:ab"])
|
})).toEqual(["updated:a", "delta:b", "updated:ab"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("normalizes openchamber session status events", async () => {
|
||||||
|
let resolveStreamFinished!: () => void
|
||||||
|
const streamFinished = new Promise<void>((resolve) => {
|
||||||
|
resolveStreamFinished = resolve
|
||||||
|
})
|
||||||
|
let resolveDelivered!: (event: Event) => void
|
||||||
|
const deliveredEvent = new Promise<Event>((resolve) => {
|
||||||
|
resolveDelivered = resolve
|
||||||
|
})
|
||||||
|
const pipeline = createEventPipeline({
|
||||||
|
sdk: createSdk([
|
||||||
|
{
|
||||||
|
type: "openchamber:session-status",
|
||||||
|
properties: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
status: "idle",
|
||||||
|
},
|
||||||
|
} as unknown as Event,
|
||||||
|
], resolveStreamFinished),
|
||||||
|
onEvent: (_directory, payload) => {
|
||||||
|
resolveDelivered(payload)
|
||||||
|
},
|
||||||
|
transport: "sse",
|
||||||
|
heartbeatTimeoutMs: 1_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await streamFinished
|
||||||
|
const delivered = await Promise.race([deliveredEvent, failAfter(500)])
|
||||||
|
expect(delivered.type).toBe("session.status")
|
||||||
|
expect(delivered.properties).toEqual({
|
||||||
|
sessionID: "ses_1",
|
||||||
|
status: { type: "idle" },
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
pipeline.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* Abort controller created once at init, cleaned up via returned cleanup fn.
|
* Abort controller created once at init, cleaned up via returned cleanup fn.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import type { Event, OpencodeClient, SessionStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
import { opencodeClient } from "@/lib/opencode/client"
|
import { opencodeClient } from "@/lib/opencode/client"
|
||||||
import { syncDebug } from "./debug"
|
import { syncDebug } from "./debug"
|
||||||
|
|
||||||
@@ -59,6 +59,11 @@ export type EventPipelineInput = {
|
|||||||
wsReadyTimeoutMs?: number
|
wsReadyTimeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EventPipeline = {
|
||||||
|
cleanup: () => void
|
||||||
|
reconnect: (reason?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
type MessageStreamWsFrame = {
|
type MessageStreamWsFrame = {
|
||||||
type: "ready" | "event" | "error" | "backpressure"
|
type: "ready" | "event" | "error" | "backpressure"
|
||||||
payload?: unknown
|
payload?: unknown
|
||||||
@@ -68,7 +73,70 @@ type MessageStreamWsFrame = {
|
|||||||
scope?: "global" | "directory"
|
scope?: "global" | "directory"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeOpenChamberSessionStatus = (payload: Event): Event | null => {
|
||||||
|
const record = payload as unknown as {
|
||||||
|
id?: unknown
|
||||||
|
type?: unknown
|
||||||
|
properties?: {
|
||||||
|
sessionID?: unknown
|
||||||
|
sessionId?: unknown
|
||||||
|
status?: unknown
|
||||||
|
metadata?: {
|
||||||
|
attempt?: unknown
|
||||||
|
message?: unknown
|
||||||
|
next?: unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.type !== "openchamber:session-status") return null
|
||||||
|
|
||||||
|
const sessionID = typeof record.properties?.sessionID === "string" && record.properties.sessionID.length > 0
|
||||||
|
? record.properties.sessionID
|
||||||
|
: typeof record.properties?.sessionId === "string" && record.properties.sessionId.length > 0
|
||||||
|
? record.properties.sessionId
|
||||||
|
: ""
|
||||||
|
const rawStatus = typeof record.properties?.status === "string" ? record.properties.status : ""
|
||||||
|
if (!sessionID || !rawStatus) return null
|
||||||
|
|
||||||
|
let status: SessionStatus | null = null
|
||||||
|
if (rawStatus === "idle" || rawStatus === "busy") {
|
||||||
|
status = { type: rawStatus }
|
||||||
|
} else if (rawStatus === "retry") {
|
||||||
|
const metadata = record.properties?.metadata
|
||||||
|
if (
|
||||||
|
typeof metadata?.attempt === "number"
|
||||||
|
&& typeof metadata.message === "string"
|
||||||
|
&& typeof metadata.next === "number"
|
||||||
|
) {
|
||||||
|
status = {
|
||||||
|
type: "retry",
|
||||||
|
attempt: metadata.attempt,
|
||||||
|
message: metadata.message,
|
||||||
|
next: metadata.next,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!status) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: typeof record.id === "string" && record.id.length > 0
|
||||||
|
? record.id
|
||||||
|
: `openchamber-status-${sessionID}-${Date.now()}`,
|
||||||
|
type: "session.status",
|
||||||
|
properties: {
|
||||||
|
sessionID,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
} as Event
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeEventType = (payload: Event): Event => {
|
const normalizeEventType = (payload: Event): Event => {
|
||||||
|
const normalizedOpenChamberStatus = normalizeOpenChamberSessionStatus(payload)
|
||||||
|
if (normalizedOpenChamberStatus) {
|
||||||
|
return normalizedOpenChamberStatus
|
||||||
|
}
|
||||||
|
|
||||||
const type = (payload as { type?: unknown }).type
|
const type = (payload as { type?: unknown }).type
|
||||||
if (typeof type !== "string") {
|
if (typeof type !== "string") {
|
||||||
return payload
|
return payload
|
||||||
@@ -173,13 +241,10 @@ type DirectoryQueue = {
|
|||||||
|
|
||||||
type AttemptAbortReason =
|
type AttemptAbortReason =
|
||||||
| "pipeline_stopped"
|
| "pipeline_stopped"
|
||||||
| "ws_heartbeat_timeout"
|
| `${"ws" | "sse"}_${string}`
|
||||||
| "sse_heartbeat_timeout"
|
|
||||||
| "ws_system_resume"
|
|
||||||
| "sse_system_resume"
|
|
||||||
| null
|
| null
|
||||||
|
|
||||||
export function createEventPipeline(input: EventPipelineInput) {
|
export function createEventPipeline(input: EventPipelineInput): EventPipeline {
|
||||||
const {
|
const {
|
||||||
sdk,
|
sdk,
|
||||||
onEvent,
|
onEvent,
|
||||||
@@ -669,10 +734,8 @@ export function createEventPipeline(input: EventPipelineInput) {
|
|||||||
if (currentTransport === "ws" && code === "WS_FALLBACK") {
|
if (currentTransport === "ws" && code === "WS_FALLBACK") {
|
||||||
retryDelayMs = 0
|
retryDelayMs = 0
|
||||||
// Transport switch (WS → SSE fallback), not a real disconnection.
|
// Transport switch (WS → SSE fallback), not a real disconnection.
|
||||||
// No events were lost — the next attempt will use SSE and carry
|
// The consumer still gets a hook so it can resync authoritative
|
||||||
// lastEventId for gapless replay. Notify consumer so it can set
|
// state; real networks can lose/buffer events around transport flips.
|
||||||
// isConnected, but do NOT treat this as a disconnection requiring
|
|
||||||
// a full directory resync.
|
|
||||||
onTransportSwitch?.()
|
onTransportSwitch?.()
|
||||||
} else if (!isAbortError(error)) {
|
} else if (!isAbortError(error)) {
|
||||||
consecutiveFailures += 1
|
consecutiveFailures += 1
|
||||||
@@ -772,6 +835,11 @@ export function createEventPipeline(input: EventPipelineInput) {
|
|||||||
attempt?.abort()
|
attempt?.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reconnect = (reason = "manual") => {
|
||||||
|
attemptAbortReason = `${activeTransport}_${reason}`
|
||||||
|
attempt?.abort()
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof document !== "undefined") {
|
if (typeof document !== "undefined") {
|
||||||
document.addEventListener("visibilitychange", onVisibility)
|
document.addEventListener("visibilitychange", onVisibility)
|
||||||
window.addEventListener("pageshow", onPageShow)
|
window.addEventListener("pageshow", onPageShow)
|
||||||
@@ -799,5 +867,5 @@ export function createEventPipeline(input: EventPipelineInput) {
|
|||||||
flushAll()
|
flushAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
return { cleanup }
|
return { cleanup, reconnect }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,10 @@ const BOOT_DEBOUNCE_MS = 1500
|
|||||||
const RECONNECT_MESSAGE_LIMIT = 30
|
const RECONNECT_MESSAGE_LIMIT = 30
|
||||||
const SESSION_MATERIALIZATION_MESSAGE_LIMIT = 30
|
const SESSION_MATERIALIZATION_MESSAGE_LIMIT = 30
|
||||||
const RECONNECT_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const RECONNECT_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
|
const ACTIVE_SESSION_WATCHDOG_INTERVAL_MS = 5_000
|
||||||
|
const ACTIVE_SESSION_STATUS_POLL_INTERVAL_MS = 5_000
|
||||||
|
const ACTIVE_SESSION_STALE_EVENT_MS = 20_000
|
||||||
|
const ACTIVE_SESSION_FULL_RESYNC_COOLDOWN_MS = 15_000
|
||||||
const requestSignature = (items: Array<{ id: string }> | undefined): string => {
|
const requestSignature = (items: Array<{ id: string }> | undefined): string => {
|
||||||
if (!items || items.length === 0) return ""
|
if (!items || items.length === 0) return ""
|
||||||
return items
|
return items
|
||||||
@@ -312,6 +316,87 @@ function toSessionStatus(status: Awaited<ReturnType<typeof opencodeClient.getSes
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isStreamHeartbeatEvent(payload: Event): boolean {
|
||||||
|
const type = (payload as { type?: unknown }).type
|
||||||
|
return type === "server.heartbeat" || type === "openchamber:heartbeat"
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveSessionCandidateIds(directory: string, state: DirectoryStore): string[] {
|
||||||
|
return getReconnectCandidateSessionIds(state, {
|
||||||
|
directory,
|
||||||
|
viewedSession: getViewedSessionMaterializationTarget(directory),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRelevantSessionStatuses(
|
||||||
|
nextStatuses: Awaited<ReturnType<typeof opencodeClient.getSessionStatusForDirectory>>,
|
||||||
|
candidateSessionIds: string[],
|
||||||
|
): Record<string, SessionStatus> | null {
|
||||||
|
if (nextStatuses === null) return null
|
||||||
|
const relevantStatuses: Record<string, SessionStatus> = {}
|
||||||
|
for (const sessionId of candidateSessionIds) {
|
||||||
|
relevantStatuses[sessionId] = toSessionStatus(nextStatuses[sessionId]) ?? { type: "idle" }
|
||||||
|
}
|
||||||
|
return relevantStatuses
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySessionStatusSnapshot(
|
||||||
|
store: StoreApi<DirectoryStore>,
|
||||||
|
relevantStatuses: Record<string, SessionStatus>,
|
||||||
|
): boolean {
|
||||||
|
if (Object.keys(relevantStatuses).length === 0) return false
|
||||||
|
|
||||||
|
let changed = false
|
||||||
|
store.setState((state: DirectoryStore) => {
|
||||||
|
for (const [sessionId, nextStatus] of Object.entries(relevantStatuses)) {
|
||||||
|
if (!haveEquivalentSyncSnapshots(state.session_status?.[sessionId], nextStatus)) {
|
||||||
|
changed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
session_status: { ...state.session_status, ...relevantStatuses },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resyncDirectorySessionStatuses(
|
||||||
|
directory: string,
|
||||||
|
store: StoreApi<DirectoryStore>,
|
||||||
|
candidateSessionIds: string[],
|
||||||
|
): Promise<Record<string, SessionStatus> | null> {
|
||||||
|
const nextStatuses = await opencodeClient.getSessionStatusForDirectory(directory)
|
||||||
|
// null = fetch failed; preserve existing state. {} or populated = authoritative
|
||||||
|
// snapshot of active sessions — candidates not listed are idle now.
|
||||||
|
const relevantStatuses = buildRelevantSessionStatuses(nextStatuses, candidateSessionIds)
|
||||||
|
if (relevantStatuses === null) return null
|
||||||
|
applySessionStatusSnapshot(store, relevantStatuses)
|
||||||
|
return relevantStatuses
|
||||||
|
}
|
||||||
|
|
||||||
|
function needsSnapshotAfterStatusPoll(
|
||||||
|
state: DirectoryStore,
|
||||||
|
sessionId: string,
|
||||||
|
nextStatus: SessionStatus | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (nextStatus?.type !== "idle") return false
|
||||||
|
const currentStatus = state.session_status?.[sessionId]
|
||||||
|
if (currentStatus && currentStatus.type !== "idle") return true
|
||||||
|
|
||||||
|
const messages = state.message[sessionId]
|
||||||
|
const lastMessage = messages?.[messages.length - 1]
|
||||||
|
return !!lastMessage
|
||||||
|
&& lastMessage.role === "assistant"
|
||||||
|
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== "number"
|
||||||
|
}
|
||||||
|
|
||||||
type EventRoutingIndex = {
|
type EventRoutingIndex = {
|
||||||
sessionDirectoryById: Map<string, string>
|
sessionDirectoryById: Map<string, string>
|
||||||
messageSessionById: Map<string, string>
|
messageSessionById: Map<string, string>
|
||||||
@@ -901,41 +986,10 @@ async function resyncDirectoryAfterReconnect(
|
|||||||
routingIndex: EventRoutingIndex,
|
routingIndex: EventRoutingIndex,
|
||||||
) {
|
) {
|
||||||
const current = store.getState()
|
const current = store.getState()
|
||||||
const candidateSessionIds = getReconnectCandidateSessionIds(current, {
|
const candidateSessionIds = getActiveSessionCandidateIds(directory, current)
|
||||||
directory,
|
|
||||||
viewedSession: getViewedSessionMaterializationTarget(directory),
|
|
||||||
})
|
|
||||||
if (candidateSessionIds.length === 0) return
|
if (candidateSessionIds.length === 0) return
|
||||||
|
|
||||||
const nextStatuses = await opencodeClient.getSessionStatusForDirectory(directory)
|
await resyncDirectorySessionStatuses(directory, store, candidateSessionIds)
|
||||||
// null = fetch failed; preserve existing state. {} or populated = authoritative
|
|
||||||
// snapshot of active sessions — candidates not listed are idle now.
|
|
||||||
if (nextStatuses !== null) {
|
|
||||||
const relevantStatuses: Record<string, SessionStatus> = {}
|
|
||||||
for (const sessionId of candidateSessionIds) {
|
|
||||||
relevantStatuses[sessionId] = toSessionStatus(nextStatuses[sessionId]) ?? { type: "idle" }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(relevantStatuses).length > 0) {
|
|
||||||
store.setState((state: DirectoryStore) => {
|
|
||||||
let changed = false
|
|
||||||
for (const [sessionId, nextStatus] of Object.entries(relevantStatuses)) {
|
|
||||||
if (!haveEquivalentSyncSnapshots(state.session_status?.[sessionId], nextStatus)) {
|
|
||||||
changed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!changed) {
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
session_status: { ...state.session_status, ...relevantStatuses },
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scopedClient = opencodeClient.getScopedSdkClient(directory)
|
const scopedClient = opencodeClient.getScopedSdkClient(directory)
|
||||||
await Promise.all(candidateSessionIds.map(async (sessionId) => {
|
await Promise.all(candidateSessionIds.map(async (sessionId) => {
|
||||||
@@ -1314,6 +1368,12 @@ export function SyncProvider(props: {
|
|||||||
const routingIndexRef = useRef<EventRoutingIndex | null>(null)
|
const routingIndexRef = useRef<EventRoutingIndex | null>(null)
|
||||||
if (!routingIndexRef.current) routingIndexRef.current = createEventRoutingIndex()
|
if (!routingIndexRef.current) routingIndexRef.current = createEventRoutingIndex()
|
||||||
const routingIndex = routingIndexRef.current
|
const routingIndex = routingIndexRef.current
|
||||||
|
const lastActiveEventAtByDirectoryRef = useRef(new Map<string, number>())
|
||||||
|
const lastStatusPollAtByDirectoryRef = useRef(new Map<string, number>())
|
||||||
|
const lastFullResyncAtByDirectoryRef = useRef(new Map<string, number>())
|
||||||
|
const resyncingDirectoriesRef = useRef(new Set<string>())
|
||||||
|
const statusPollingDirectoriesRef = useRef(new Set<string>())
|
||||||
|
const pipelineReconnectRef = useRef<((reason?: string) => void) | null>(null)
|
||||||
|
|
||||||
const system = useMemo<SyncSystem>(
|
const system = useMemo<SyncSystem>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -1324,6 +1384,23 @@ export function SyncProvider(props: {
|
|||||||
[childStores, props.sdk, props.directory],
|
[childStores, props.sdk, props.directory],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const triggerDirectoryResync = useCallback((directory: string) => {
|
||||||
|
const store = childStores.children.get(directory)
|
||||||
|
if (!store) return
|
||||||
|
const resyncing = resyncingDirectoriesRef.current
|
||||||
|
if (resyncing.has(directory)) return
|
||||||
|
|
||||||
|
lastFullResyncAtByDirectoryRef.current.set(directory, Date.now())
|
||||||
|
resyncing.add(directory)
|
||||||
|
void resyncDirectoryAfterReconnect(directory, store, routingIndex)
|
||||||
|
.catch(() => {
|
||||||
|
// Transient failure — the watchdog, next SSE event, or reconnect will catch up.
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
resyncing.delete(directory)
|
||||||
|
})
|
||||||
|
}, [childStores, routingIndex])
|
||||||
|
|
||||||
// Configure child store manager
|
// Configure child store manager
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const bootingDirs = new Set<string>()
|
const bootingDirs = new Set<string>()
|
||||||
@@ -1435,30 +1512,16 @@ export function SyncProvider(props: {
|
|||||||
// Event pipeline — created once per mount. No class, no start/stop.
|
// Event pipeline — created once per mount. No class, no start/stop.
|
||||||
// Abort controller owned by the pipeline closure. Cleanup aborts + flushes.
|
// Abort controller owned by the pipeline closure. Cleanup aborts + flushes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const reconnectMaterializing = new Set<string>()
|
const pipeline = createEventPipeline({
|
||||||
const triggerReconnectMaterialization = (directory: string) => {
|
|
||||||
const store = childStores.children.get(directory)
|
|
||||||
if (!store) return
|
|
||||||
if (reconnectMaterializing.has(directory)) return
|
|
||||||
|
|
||||||
reconnectMaterializing.add(directory)
|
|
||||||
void resyncDirectoryAfterReconnect(directory, store, routingIndex)
|
|
||||||
.catch(() => {
|
|
||||||
// Transient failure during materialization — next SSE event, transport switch,
|
|
||||||
// or reconnect will catch up.
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
reconnectMaterializing.delete(directory)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const { cleanup } = createEventPipeline({
|
|
||||||
sdk: props.sdk,
|
sdk: props.sdk,
|
||||||
transport: messageStreamTransport,
|
transport: messageStreamTransport,
|
||||||
routeDirectory: (directory, payload) => {
|
routeDirectory: (directory, payload) => {
|
||||||
return resolveDirectoryFromRoutingIndex(routingIndex, directory, payload, childStores)
|
return resolveDirectoryFromRoutingIndex(routingIndex, directory, payload, childStores)
|
||||||
},
|
},
|
||||||
onEvent: (directory, payload) => {
|
onEvent: (directory, payload) => {
|
||||||
|
if (!isStreamHeartbeatEvent(payload)) {
|
||||||
|
lastActiveEventAtByDirectoryRef.current.set(directory, Date.now())
|
||||||
|
}
|
||||||
dispatchVSCodeRuntimeNotificationEvent(directory, payload)
|
dispatchVSCodeRuntimeNotificationEvent(directory, payload)
|
||||||
if (payload.type === "installation.update-available") {
|
if (payload.type === "installation.update-available") {
|
||||||
const version = typeof (payload.properties as { version?: unknown })?.version === "string"
|
const version = typeof (payload.properties as { version?: unknown })?.version === "string"
|
||||||
@@ -1477,7 +1540,7 @@ export function SyncProvider(props: {
|
|||||||
connectionPhase: "connected",
|
connectionPhase: "connected",
|
||||||
})
|
})
|
||||||
for (const dir of childStores.children.keys()) {
|
for (const dir of childStores.children.keys()) {
|
||||||
triggerReconnectMaterialization(dir)
|
triggerDirectoryResync(dir)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDisconnect: (reason) => {
|
onDisconnect: (reason) => {
|
||||||
@@ -1489,21 +1552,108 @@ export function SyncProvider(props: {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
onTransportSwitch: () => {
|
onTransportSwitch: () => {
|
||||||
// Transport switched (e.g. WS timeout → SSE fallback) without a full
|
// Transport changes are gap-prone in real networks. Treat them like a
|
||||||
// disconnect. If the active session missed the transition into a busy
|
// reconnect and refresh active session snapshots from HTTP.
|
||||||
// turn, force a targeted resync for the viewed directory.
|
|
||||||
useConfigStore.setState({
|
useConfigStore.setState({
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
hasEverConnected: true,
|
hasEverConnected: true,
|
||||||
connectionPhase: "connected",
|
connectionPhase: "connected",
|
||||||
})
|
})
|
||||||
if (_activeDirectory) {
|
for (const dir of childStores.children.keys()) {
|
||||||
triggerReconnectMaterialization(_activeDirectory)
|
triggerDirectoryResync(dir)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return cleanup
|
pipelineReconnectRef.current = pipeline.reconnect
|
||||||
}, [props.sdk, childStores, routingIndex, messageStreamTransport])
|
return () => {
|
||||||
|
if (pipelineReconnectRef.current === pipeline.reconnect) {
|
||||||
|
pipelineReconnectRef.current = null
|
||||||
|
}
|
||||||
|
pipeline.cleanup()
|
||||||
|
}
|
||||||
|
}, [props.sdk, childStores, routingIndex, messageStreamTransport, triggerDirectoryResync])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let stopped = false
|
||||||
|
let running = false
|
||||||
|
|
||||||
|
const pollDirectoryStatuses = async (
|
||||||
|
directory: string,
|
||||||
|
store: StoreApi<DirectoryStore>,
|
||||||
|
candidateSessionIds: string[],
|
||||||
|
) => {
|
||||||
|
const polling = statusPollingDirectoriesRef.current
|
||||||
|
if (polling.has(directory)) return
|
||||||
|
polling.add(directory)
|
||||||
|
try {
|
||||||
|
const before = store.getState()
|
||||||
|
const statuses = await resyncDirectorySessionStatuses(directory, store, candidateSessionIds)
|
||||||
|
if (!statuses) return
|
||||||
|
const needsSnapshot = candidateSessionIds.some((sessionId) => (
|
||||||
|
needsSnapshotAfterStatusPoll(before, sessionId, statuses[sessionId])
|
||||||
|
))
|
||||||
|
if (needsSnapshot) {
|
||||||
|
triggerDirectoryResync(directory)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
polling.delete(directory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (running || stopped) return
|
||||||
|
running = true
|
||||||
|
void Promise.resolve()
|
||||||
|
.then(() => {
|
||||||
|
if (stopped) return
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [directory, store] of childStores.children.entries()) {
|
||||||
|
const state = store.getState()
|
||||||
|
const candidateSessionIds = getActiveSessionCandidateIds(directory, state)
|
||||||
|
if (candidateSessionIds.length === 0) {
|
||||||
|
lastActiveEventAtByDirectoryRef.current.delete(directory)
|
||||||
|
lastStatusPollAtByDirectoryRef.current.delete(directory)
|
||||||
|
lastFullResyncAtByDirectoryRef.current.delete(directory)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastActiveEventAtByDirectoryRef.current.has(directory)) {
|
||||||
|
lastActiveEventAtByDirectoryRef.current.set(directory, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastStatusPollAt = lastStatusPollAtByDirectoryRef.current.get(directory) ?? 0
|
||||||
|
if (now - lastStatusPollAt >= ACTIVE_SESSION_STATUS_POLL_INTERVAL_MS) {
|
||||||
|
lastStatusPollAtByDirectoryRef.current.set(directory, now)
|
||||||
|
void pollDirectoryStatuses(directory, store, candidateSessionIds).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastActiveEventAt = lastActiveEventAtByDirectoryRef.current.get(directory) ?? now
|
||||||
|
const lastFullResyncAt = lastFullResyncAtByDirectoryRef.current.get(directory) ?? 0
|
||||||
|
if (
|
||||||
|
now - lastActiveEventAt >= ACTIVE_SESSION_STALE_EVENT_MS
|
||||||
|
&& now - lastFullResyncAt >= ACTIVE_SESSION_FULL_RESYNC_COOLDOWN_MS
|
||||||
|
) {
|
||||||
|
pipelineReconnectRef.current?.("active_stream_stale")
|
||||||
|
triggerDirectoryResync(directory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
running = false
|
||||||
|
if (stopped) {
|
||||||
|
statusPollingDirectoriesRef.current.clear()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = setInterval(tick, ACTIVE_SESSION_WATCHDOG_INTERVAL_MS)
|
||||||
|
tick()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopped = true
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [childStores, triggerDirectoryResync])
|
||||||
|
|
||||||
// Ensure current directory's child store exists
|
// Ensure current directory's child store exists
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -726,7 +726,7 @@ const processForwardedEventPayload = (payload, emitSyntheticEvent) => {
|
|||||||
emitSyntheticEvent({
|
emitSyntheticEvent({
|
||||||
type: 'openchamber:session-status',
|
type: 'openchamber:session-status',
|
||||||
properties: {
|
properties: {
|
||||||
sessionId,
|
sessionID: sessionId,
|
||||||
status,
|
status,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
metadata: {
|
metadata: {
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, br
|
|||||||
const syntheticPayload = {
|
const syntheticPayload = {
|
||||||
type: 'openchamber:session-status',
|
type: 'openchamber:session-status',
|
||||||
properties: {
|
properties: {
|
||||||
sessionId,
|
sessionID: sessionId,
|
||||||
status: state.status,
|
status: state.status,
|
||||||
timestamp: state.lastUpdateAt,
|
timestamp: state.lastUpdateAt,
|
||||||
metadata: state.metadata,
|
metadata: state.metadata,
|
||||||
@@ -216,7 +216,7 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, br
|
|||||||
const syntheticPayload = {
|
const syntheticPayload = {
|
||||||
type: 'openchamber:session-status',
|
type: 'openchamber:session-status',
|
||||||
properties: {
|
properties: {
|
||||||
sessionId,
|
sessionID: sessionId,
|
||||||
status: state.status,
|
status: state.status,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
metadata: {},
|
metadata: {},
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ describe('session runtime', () => {
|
|||||||
expect(events).toContainEqual({
|
expect(events).toContainEqual({
|
||||||
type: 'openchamber:session-status',
|
type: 'openchamber:session-status',
|
||||||
properties: expect.objectContaining({
|
properties: expect.objectContaining({
|
||||||
sessionId: 'session-1',
|
sessionID: 'session-1',
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
needsAttention: true,
|
needsAttention: true,
|
||||||
}),
|
}),
|
||||||
@@ -57,7 +57,7 @@ describe('session runtime', () => {
|
|||||||
expect(events.at(-1)).toEqual({
|
expect(events.at(-1)).toEqual({
|
||||||
type: 'openchamber:session-status',
|
type: 'openchamber:session-status',
|
||||||
properties: {
|
properties: {
|
||||||
sessionId: 'session-1',
|
sessionID: 'session-1',
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
timestamp: expect.any(Number),
|
timestamp: expect.any(Number),
|
||||||
metadata: {},
|
metadata: {},
|
||||||
@@ -92,7 +92,7 @@ describe('session runtime', () => {
|
|||||||
expect(events).toContainEqual({
|
expect(events).toContainEqual({
|
||||||
type: 'openchamber:session-status',
|
type: 'openchamber:session-status',
|
||||||
properties: expect.objectContaining({
|
properties: expect.objectContaining({
|
||||||
sessionId: 'legacy-session-1',
|
sessionID: 'legacy-session-1',
|
||||||
status: 'busy',
|
status: 'busy',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user