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:
committed by
GitHub
parent
8e86891d8e
commit
5ec7f46105
@@ -1520,7 +1520,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
else if (commandName === 'compact' && currentSessionId) {
|
||||
try {
|
||||
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 sdk = opencodeClient.getSdkClient();
|
||||
|
||||
@@ -468,6 +468,7 @@ interface ConfigStore {
|
||||
agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } };
|
||||
defaultProviders: { [key: string]: string };
|
||||
isConnected: boolean;
|
||||
lastDisconnectReason: string | null;
|
||||
isInitialized: boolean;
|
||||
modelsMetadata: Map<string, ModelMetadata>;
|
||||
// OpenChamber settings-based defaults (take precedence over agent preferences)
|
||||
@@ -588,6 +589,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
agentModelSelections: {},
|
||||
defaultProviders: {},
|
||||
isConnected: false,
|
||||
lastDisconnectReason: null,
|
||||
isInitialized: false,
|
||||
modelsMetadata: new Map<string, ModelMetadata>(),
|
||||
settingsDefaultModel: undefined,
|
||||
|
||||
@@ -34,7 +34,7 @@ export type EventPipelineInput = {
|
||||
/** Called after stream reconnects (visibility restore or heartbeat timeout). */
|
||||
onReconnect?: () => void
|
||||
/** Called when the stream disconnects (heartbeat timeout, network error, or transport failure). */
|
||||
onDisconnect?: () => void
|
||||
onDisconnect?: (reason: string) => void
|
||||
transport?: "auto" | "ws" | "sse"
|
||||
}
|
||||
|
||||
@@ -429,6 +429,7 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
|
||||
if (frame.type === "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)
|
||||
settleReject(error)
|
||||
try {
|
||||
@@ -463,13 +464,16 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
void 0
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
socket.onclose = (event) => {
|
||||
if (signal.aborted) {
|
||||
settleResolve()
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
settleReject(error)
|
||||
}
|
||||
@@ -521,7 +525,18 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
// setState calls on every failed retry attempt.
|
||||
if (!disconnected) {
|
||||
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 {
|
||||
|
||||
@@ -54,6 +54,12 @@ function dir() {
|
||||
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 {
|
||||
return useSessionUIStore.getState().getDirectoryForSession(sessionId) || dir()
|
||||
}
|
||||
@@ -327,7 +333,7 @@ export async function optimisticSend(input: {
|
||||
}
|
||||
|
||||
if (!useConfigStore.getState().isConnected) {
|
||||
throw new Error("Connection lost. Please wait for reconnection.")
|
||||
throw connectionLostError()
|
||||
}
|
||||
|
||||
const store = dirStore()
|
||||
@@ -414,7 +420,7 @@ export async function respondToPermission(
|
||||
response: "once" | "always" | "reject",
|
||||
): Promise<void> {
|
||||
if (!useConfigStore.getState().isConnected) {
|
||||
throw new Error("Connection lost. Please wait for reconnection.")
|
||||
throw connectionLostError()
|
||||
}
|
||||
const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({
|
||||
requestID: requestId,
|
||||
@@ -430,7 +436,7 @@ export async function dismissPermission(
|
||||
requestId: string,
|
||||
): Promise<void> {
|
||||
if (!useConfigStore.getState().isConnected) {
|
||||
throw new Error("Connection lost. Please wait for reconnection.")
|
||||
throw connectionLostError()
|
||||
}
|
||||
const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({
|
||||
requestID: requestId,
|
||||
@@ -451,7 +457,7 @@ export async function respondToQuestion(
|
||||
answers: string[] | string[][],
|
||||
): Promise<void> {
|
||||
if (!useConfigStore.getState().isConnected) {
|
||||
throw new Error("Connection lost. Please wait for reconnection.")
|
||||
throw connectionLostError()
|
||||
}
|
||||
const result = await getRequestReplyClient("question", sessionId, requestId).question.reply({
|
||||
requestID: requestId,
|
||||
@@ -467,7 +473,7 @@ export async function rejectQuestion(
|
||||
requestId: string,
|
||||
): Promise<void> {
|
||||
if (!useConfigStore.getState().isConnected) {
|
||||
throw new Error("Connection lost. Please wait for reconnection.")
|
||||
throw connectionLostError()
|
||||
}
|
||||
const result = await getRequestReplyClient("question", sessionId, requestId).question.reject({
|
||||
requestID: requestId,
|
||||
|
||||
@@ -1347,7 +1347,7 @@ export function SyncProvider(props: {
|
||||
handleEvent(directory, payload, childStores, routingIndex)
|
||||
},
|
||||
onReconnect: () => {
|
||||
useConfigStore.setState({ isConnected: true })
|
||||
useConfigStore.setState({ isConnected: true, lastDisconnectReason: null })
|
||||
for (const [dir, store] of childStores.children) {
|
||||
if (reconnectResyncing.has(dir)) continue
|
||||
if (getReconnectCandidateSessionIds(store.getState()).length === 0) continue
|
||||
@@ -1362,8 +1362,8 @@ export function SyncProvider(props: {
|
||||
})
|
||||
}
|
||||
},
|
||||
onDisconnect: () => {
|
||||
useConfigStore.setState({ isConnected: false })
|
||||
onDisconnect: (reason) => {
|
||||
useConfigStore.setState({ isConnected: false, lastDisconnectReason: reason })
|
||||
},
|
||||
})
|
||||
return cleanup
|
||||
|
||||
@@ -319,12 +319,17 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(buildOpenCodeUrl('/session', ''), {
|
||||
const response = await fetch(buildOpenCodeUrl('/global/health', ''), {
|
||||
method: 'GET',
|
||||
headers: getOpenCodeAuthHeaders(),
|
||||
signal: AbortSignal.timeout(2000),
|
||||
headers: {
|
||||
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 {
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user