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:
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user