From d19c50cf7723e39517cf3258ae14b85865a0b682 Mon Sep 17 00:00:00 2001 From: Viswamedha Nalabotu Date: Wed, 18 Mar 2026 10:20:57 +0000 Subject: [PATCH] Added websocket reconnect with backoff --- site/src/stores/agentBackoff.ts | 3 ++ site/src/stores/agentStore.ts | 61 +++++++++++++++++++++---- site/src/stores/onboardingAgentStore.ts | 61 +++++++++++++++++++++---- 3 files changed, 107 insertions(+), 18 deletions(-) create mode 100644 site/src/stores/agentBackoff.ts diff --git a/site/src/stores/agentBackoff.ts b/site/src/stores/agentBackoff.ts new file mode 100644 index 0000000..8a02ff2 --- /dev/null +++ b/site/src/stores/agentBackoff.ts @@ -0,0 +1,3 @@ +export const BACKOFF_BASE_MS = 1000 +export const BACKOFF_MAX_MS = 30000 +export const BACKOFF_MAX_ATTEMPTS = 6 diff --git a/site/src/stores/agentStore.ts b/site/src/stores/agentStore.ts index aab08c7..a454499 100644 --- a/site/src/stores/agentStore.ts +++ b/site/src/stores/agentStore.ts @@ -6,6 +6,7 @@ import type { AgentSocketEventPayload, AgentStartPayload, } from '../types/agent' +import { BACKOFF_BASE_MS, BACKOFF_MAX_MS, BACKOFF_MAX_ATTEMPTS } from './agentBackoff' export const useAgentStore = defineStore('agent', () => { const isConnected = ref(false) @@ -14,6 +15,11 @@ export const useAgentStore = defineStore('agent', () => { const lastExecutionId = ref(null) const socket = ref(null) + let currentUrl = '' + let reconnectAttempts = 0 + let reconnectTimer: ReturnType | null = null + let intentionalClose = false + const pushEvent = (evt: AgentSocketEventPayload) => { eventLog.value.unshift({ type: evt.type, @@ -23,17 +29,32 @@ export const useAgentStore = defineStore('agent', () => { }) } - const connect = (id: string) => { - if (socket.value) { - socket.value.close() - socket.value = null + const clearReconnectTimer = () => { + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer) + reconnectTimer = null } + } - const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws' - const wsUrl = `${wsProtocol}://${window.location.host}/ws/onboarding/chat/${id}/` - socket.value = new WebSocket(wsUrl) + const scheduleReconnect = () => { + if (reconnectAttempts >= BACKOFF_MAX_ATTEMPTS) { + pushEvent({ type: 'error', message: 'Connection lost. Please refresh the page.' }) + executionStatus.value = 'idle' + return + } + const delay = Math.min(BACKOFF_BASE_MS * 2 ** reconnectAttempts, BACKOFF_MAX_MS) + reconnectAttempts++ + pushEvent({ type: 'status', message: `Reconnecting in ${Math.round(delay / 1000)}s (attempt ${reconnectAttempts}/${BACKOFF_MAX_ATTEMPTS})...` }) + reconnectTimer = setTimeout(() => { + if (!intentionalClose) openSocket(currentUrl) + }, delay) + } + + const openSocket = (url: string) => { + socket.value = new WebSocket(url) socket.value.onopen = () => { + reconnectAttempts = 0 isConnected.value = true pushEvent({ type: 'status', message: 'Connected to Orchestrator' }) } @@ -81,13 +102,35 @@ export const useAgentStore = defineStore('agent', () => { } } - socket.value.onclose = () => { + socket.value.onclose = (event) => { isConnected.value = false - executionStatus.value = 'idle' + // code 1000 = clean close (server finished normally); don't reconnect + if (!intentionalClose && event.code !== 1000) { + scheduleReconnect() + } else { + executionStatus.value = 'idle' + } } } + const connect = (id: string) => { + intentionalClose = false + clearReconnectTimer() + reconnectAttempts = 0 + + if (socket.value) { + socket.value.close() + socket.value = null + } + + const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws' + currentUrl = `${wsProtocol}://${window.location.host}/ws/onboarding/chat/${id}/` + openSocket(currentUrl) + } + const disconnect = () => { + intentionalClose = true + clearReconnectTimer() if (socket.value) { socket.value.close() socket.value = null diff --git a/site/src/stores/onboardingAgentStore.ts b/site/src/stores/onboardingAgentStore.ts index db1a483..66b344d 100644 --- a/site/src/stores/onboardingAgentStore.ts +++ b/site/src/stores/onboardingAgentStore.ts @@ -6,6 +6,7 @@ import type { AgentSocketEventPayload, AgentStartPayload, } from '../types/agent' +import { BACKOFF_BASE_MS, BACKOFF_MAX_MS, BACKOFF_MAX_ATTEMPTS } from './agentBackoff' export const useOnboardingAgentStore = defineStore('onboarding-agent', () => { const isConnected = ref(false) @@ -14,6 +15,11 @@ export const useOnboardingAgentStore = defineStore('onboarding-agent', () => { const lastExecutionId = ref(null) const socket = ref(null) + let currentUrl = '' + let reconnectAttempts = 0 + let reconnectTimer: ReturnType | null = null + let intentionalClose = false + const pushEvent = (evt: AgentSocketEventPayload) => { eventLog.value.unshift({ type: evt.type, @@ -23,17 +29,32 @@ export const useOnboardingAgentStore = defineStore('onboarding-agent', () => { }) } - const connect = (id: string) => { - if (socket.value) { - socket.value.close() - socket.value = null + const clearReconnectTimer = () => { + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer) + reconnectTimer = null } + } - const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws' - const wsUrl = `${wsProtocol}://${window.location.host}/ws/onboarding/generate/${id}/` - socket.value = new WebSocket(wsUrl) + const scheduleReconnect = () => { + if (reconnectAttempts >= BACKOFF_MAX_ATTEMPTS) { + pushEvent({ type: 'error', message: 'Connection lost. Please refresh the page.' }) + executionStatus.value = 'idle' + return + } + const delay = Math.min(BACKOFF_BASE_MS * 2 ** reconnectAttempts, BACKOFF_MAX_MS) + reconnectAttempts++ + pushEvent({ type: 'status', message: `Reconnecting in ${Math.round(delay / 1000)}s (attempt ${reconnectAttempts}/${BACKOFF_MAX_ATTEMPTS})...` }) + reconnectTimer = setTimeout(() => { + if (!intentionalClose) openSocket(currentUrl) + }, delay) + } + + const openSocket = (url: string) => { + socket.value = new WebSocket(url) socket.value.onopen = () => { + reconnectAttempts = 0 isConnected.value = true pushEvent({ type: 'status', message: 'Connected to Orchestrator' }) } @@ -81,13 +102,35 @@ export const useOnboardingAgentStore = defineStore('onboarding-agent', () => { } } - socket.value.onclose = () => { + socket.value.onclose = (event) => { isConnected.value = false - executionStatus.value = 'idle' + // code 1000 = clean close (server finished normally); don't reconnect + if (!intentionalClose && event.code !== 1000) { + scheduleReconnect() + } else { + executionStatus.value = 'idle' + } } } + const connect = (id: string) => { + intentionalClose = false + clearReconnectTimer() + reconnectAttempts = 0 + + if (socket.value) { + socket.value.close() + socket.value = null + } + + const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws' + currentUrl = `${wsProtocol}://${window.location.host}/ws/onboarding/generate/${id}/` + openSocket(currentUrl) + } + const disconnect = () => { + intentionalClose = true + clearReconnectTimer() if (socket.value) { socket.value.close() socket.value = null