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:
Bohdan Triapitsyn
2026-05-24 19:17:04 +03:00
parent c4956af565
commit 0c8301d579
6 changed files with 333 additions and 76 deletions
+79 -11
View File
@@ -12,7 +12,7 @@
* 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 { syncDebug } from "./debug"
@@ -59,6 +59,11 @@ export type EventPipelineInput = {
wsReadyTimeoutMs?: number
}
export type EventPipeline = {
cleanup: () => void
reconnect: (reason?: string) => void
}
type MessageStreamWsFrame = {
type: "ready" | "event" | "error" | "backpressure"
payload?: unknown
@@ -68,7 +73,70 @@ type MessageStreamWsFrame = {
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 normalizedOpenChamberStatus = normalizeOpenChamberSessionStatus(payload)
if (normalizedOpenChamberStatus) {
return normalizedOpenChamberStatus
}
const type = (payload as { type?: unknown }).type
if (typeof type !== "string") {
return payload
@@ -173,13 +241,10 @@ type DirectoryQueue = {
type AttemptAbortReason =
| "pipeline_stopped"
| "ws_heartbeat_timeout"
| "sse_heartbeat_timeout"
| "ws_system_resume"
| "sse_system_resume"
| `${"ws" | "sse"}_${string}`
| null
export function createEventPipeline(input: EventPipelineInput) {
export function createEventPipeline(input: EventPipelineInput): EventPipeline {
const {
sdk,
onEvent,
@@ -669,10 +734,8 @@ export function createEventPipeline(input: EventPipelineInput) {
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.
// The consumer still gets a hook so it can resync authoritative
// state; real networks can lose/buffer events around transport flips.
onTransportSwitch?.()
} else if (!isAbortError(error)) {
consecutiveFailures += 1
@@ -772,6 +835,11 @@ export function createEventPipeline(input: EventPipelineInput) {
attempt?.abort()
}
const reconnect = (reason = "manual") => {
attemptAbortReason = `${activeTransport}_${reason}`
attempt?.abort()
}
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisibility)
window.addEventListener("pageshow", onPageShow)
@@ -799,5 +867,5 @@ export function createEventPipeline(input: EventPipelineInput) {
flushAll()
}
return { cleanup }
return { cleanup, reconnect }
}