fix: surface disconnect reason, switch health probe to /global/health (#978)

- event-pipeline: onDisconnect now carries a reason tag (ws_closed:code=N, ws_error_frame, ws_closed_before_ready, sse_error)
- useConfigStore: store lastDisconnectReason, clear on reconnect
- send guards: embed reason in Connection lost toast so we can tell which path tripped
- lifecycle: isOpenCodeProcessHealthy hits /global/health (healthy flag) with 5s timeout instead of /session with 2s, avoids false restarts under stream load
This commit is contained in:
Bohdan Triapitsyn
2026-04-22 00:33:21 +03:00
committed by GitHub
parent 8e86891d8e
commit 5ec7f46105
6 changed files with 46 additions and 16 deletions
@@ -1520,7 +1520,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
else if (commandName === 'compact' && currentSessionId) { else if (commandName === 'compact' && currentSessionId) {
try { try {
if (!useConfigStore.getState().isConnected) { if (!useConfigStore.getState().isConnected) {
throw new Error("Connection lost. Please wait for reconnection."); const reason = useConfigStore.getState().lastDisconnectReason;
const suffix = reason ? ` (${reason})` : " (never connected)";
throw new Error(`Connection lost${suffix}. Please wait for reconnection.`);
} }
const { opencodeClient } = await import('@/lib/opencode/client'); const { opencodeClient } = await import('@/lib/opencode/client');
const sdk = opencodeClient.getSdkClient(); const sdk = opencodeClient.getSdkClient();
+2
View File
@@ -468,6 +468,7 @@ interface ConfigStore {
agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } }; agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } };
defaultProviders: { [key: string]: string }; defaultProviders: { [key: string]: string };
isConnected: boolean; isConnected: boolean;
lastDisconnectReason: string | null;
isInitialized: boolean; isInitialized: boolean;
modelsMetadata: Map<string, ModelMetadata>; modelsMetadata: Map<string, ModelMetadata>;
// OpenChamber settings-based defaults (take precedence over agent preferences) // OpenChamber settings-based defaults (take precedence over agent preferences)
@@ -588,6 +589,7 @@ export const useConfigStore = create<ConfigStore>()(
agentModelSelections: {}, agentModelSelections: {},
defaultProviders: {}, defaultProviders: {},
isConnected: false, isConnected: false,
lastDisconnectReason: null,
isInitialized: false, isInitialized: false,
modelsMetadata: new Map<string, ModelMetadata>(), modelsMetadata: new Map<string, ModelMetadata>(),
settingsDefaultModel: undefined, settingsDefaultModel: undefined,
+18 -3
View File
@@ -34,7 +34,7 @@ export type EventPipelineInput = {
/** Called after stream reconnects (visibility restore or heartbeat timeout). */ /** Called after stream reconnects (visibility restore or heartbeat timeout). */
onReconnect?: () => void onReconnect?: () => void
/** Called when the stream disconnects (heartbeat timeout, network error, or transport failure). */ /** Called when the stream disconnects (heartbeat timeout, network error, or transport failure). */
onDisconnect?: () => void onDisconnect?: (reason: string) => void
transport?: "auto" | "ws" | "sse" transport?: "auto" | "ws" | "sse"
} }
@@ -429,6 +429,7 @@ export function createEventPipeline(input: EventPipelineInput) {
if (frame.type === "error") { if (frame.type === "error") {
const error = new Error(frame.message || "Message stream WebSocket error") const error = new Error(frame.message || "Message stream WebSocket error")
;(error as Error & { reason?: string }).reason = `ws_error_frame:${frame.message || "unknown"}`
setFallbackCode(error) setFallbackCode(error)
settleReject(error) settleReject(error)
try { try {
@@ -463,13 +464,16 @@ export function createEventPipeline(input: EventPipelineInput) {
void 0 void 0
} }
socket.onclose = () => { socket.onclose = (event) => {
if (signal.aborted) { if (signal.aborted) {
settleResolve() settleResolve()
return return
} }
const error = new Error("Global message stream WebSocket closed") const error = new Error("Global message stream WebSocket closed")
;(error as Error & { reason?: string }).reason = opened
? `ws_closed:code=${event?.code ?? "?"}`
: "ws_closed_before_ready"
setFallbackCode(error) setFallbackCode(error)
settleReject(error) settleReject(error)
} }
@@ -521,7 +525,18 @@ export function createEventPipeline(input: EventPipelineInput) {
// setState calls on every failed retry attempt. // setState calls on every failed retry attempt.
if (!disconnected) { if (!disconnected) {
disconnected = true disconnected = true
onDisconnect?.() const taggedReason = typeof error === "object" && error !== null
? (error as { reason?: unknown }).reason
: undefined
const message = typeof error === "object" && error !== null
? (error as { message?: unknown }).message
: undefined
const reason = typeof taggedReason === "string" && taggedReason.length > 0
? taggedReason
: typeof message === "string" && message.length > 0
? `${currentTransport}_error:${message.slice(0, 80)}`
: `${currentTransport}_error:unknown`
onDisconnect?.(reason)
} }
} }
} finally { } finally {
+11 -5
View File
@@ -54,6 +54,12 @@ function dir() {
return _getDirectory() || undefined return _getDirectory() || undefined
} }
function connectionLostError(): Error {
const reason = useConfigStore.getState().lastDisconnectReason
const suffix = reason ? ` (${reason})` : " (never connected)"
return new Error(`Connection lost${suffix}. Please wait for reconnection.`)
}
function getSessionDirectory(sessionId: string): string | undefined { function getSessionDirectory(sessionId: string): string | undefined {
return useSessionUIStore.getState().getDirectoryForSession(sessionId) || dir() return useSessionUIStore.getState().getDirectoryForSession(sessionId) || dir()
} }
@@ -327,7 +333,7 @@ export async function optimisticSend(input: {
} }
if (!useConfigStore.getState().isConnected) { if (!useConfigStore.getState().isConnected) {
throw new Error("Connection lost. Please wait for reconnection.") throw connectionLostError()
} }
const store = dirStore() const store = dirStore()
@@ -414,7 +420,7 @@ export async function respondToPermission(
response: "once" | "always" | "reject", response: "once" | "always" | "reject",
): Promise<void> { ): Promise<void> {
if (!useConfigStore.getState().isConnected) { if (!useConfigStore.getState().isConnected) {
throw new Error("Connection lost. Please wait for reconnection.") throw connectionLostError()
} }
const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({ const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({
requestID: requestId, requestID: requestId,
@@ -430,7 +436,7 @@ export async function dismissPermission(
requestId: string, requestId: string,
): Promise<void> { ): Promise<void> {
if (!useConfigStore.getState().isConnected) { if (!useConfigStore.getState().isConnected) {
throw new Error("Connection lost. Please wait for reconnection.") throw connectionLostError()
} }
const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({ const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({
requestID: requestId, requestID: requestId,
@@ -451,7 +457,7 @@ export async function respondToQuestion(
answers: string[] | string[][], answers: string[] | string[][],
): Promise<void> { ): Promise<void> {
if (!useConfigStore.getState().isConnected) { if (!useConfigStore.getState().isConnected) {
throw new Error("Connection lost. Please wait for reconnection.") throw connectionLostError()
} }
const result = await getRequestReplyClient("question", sessionId, requestId).question.reply({ const result = await getRequestReplyClient("question", sessionId, requestId).question.reply({
requestID: requestId, requestID: requestId,
@@ -467,7 +473,7 @@ export async function rejectQuestion(
requestId: string, requestId: string,
): Promise<void> { ): Promise<void> {
if (!useConfigStore.getState().isConnected) { if (!useConfigStore.getState().isConnected) {
throw new Error("Connection lost. Please wait for reconnection.") throw connectionLostError()
} }
const result = await getRequestReplyClient("question", sessionId, requestId).question.reject({ const result = await getRequestReplyClient("question", sessionId, requestId).question.reject({
requestID: requestId, requestID: requestId,
+3 -3
View File
@@ -1347,7 +1347,7 @@ export function SyncProvider(props: {
handleEvent(directory, payload, childStores, routingIndex) handleEvent(directory, payload, childStores, routingIndex)
}, },
onReconnect: () => { onReconnect: () => {
useConfigStore.setState({ isConnected: true }) useConfigStore.setState({ isConnected: true, lastDisconnectReason: null })
for (const [dir, store] of childStores.children) { for (const [dir, store] of childStores.children) {
if (reconnectResyncing.has(dir)) continue if (reconnectResyncing.has(dir)) continue
if (getReconnectCandidateSessionIds(store.getState()).length === 0) continue if (getReconnectCandidateSessionIds(store.getState()).length === 0) continue
@@ -1362,8 +1362,8 @@ export function SyncProvider(props: {
}) })
} }
}, },
onDisconnect: () => { onDisconnect: (reason) => {
useConfigStore.setState({ isConnected: false }) useConfigStore.setState({ isConnected: false, lastDisconnectReason: reason })
}, },
}) })
return cleanup return cleanup
@@ -319,12 +319,17 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
} }
try { try {
const response = await fetch(buildOpenCodeUrl('/session', ''), { const response = await fetch(buildOpenCodeUrl('/global/health', ''), {
method: 'GET', method: 'GET',
headers: getOpenCodeAuthHeaders(), headers: {
signal: AbortSignal.timeout(2000), Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
signal: AbortSignal.timeout(5000),
}); });
return response.ok; if (!response.ok) return false;
const body = await response.json().catch(() => null);
return body?.healthy === true;
} catch { } catch {
return false; return false;
} }