feat(agent-office): WS reconnect exponential backoff + status detail

- Replace fixed 3s reconnect with exponential backoff
  (1s/2s/4s/8s/16s/30s, capped). Reduces console noise when
  upstream WebSocket is blocked (e.g. DSM reverse proxy without
  WS upgrade headers).
- ws.onerror swallowed (onclose still schedules reconnect) so the
  browser stops printing an unhandled-error pair per attempt.
- Expose reconnectAttempt in hook; TopBar shows 'Connecting…'
  pre-first-attempt and 'Disconnected · 재연결 시도 #N' after.

Root cause of WS failure is upstream (curl proves the endpoint
itself is fine — see DSM reverse proxy WebSocket headers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 08:25:18 +09:00
parent 0dce449124
commit b713f00bf9
3 changed files with 33 additions and 8 deletions

View File

@@ -10,7 +10,7 @@ import './AgentOffice.css';
export default function AgentOffice() { export default function AgentOffice() {
const { const {
agents, pendingTasks, notifications, connected, agents, pendingTasks, notifications, connected, reconnectAttempt,
refreshTrigger, clearNotifications refreshTrigger, clearNotifications
} = useAgentManager(); } = useAgentManager();
@@ -53,7 +53,7 @@ export default function AgentOffice() {
return ( return (
<div className="ao-root"> <div className="ao-root">
<TopBar connected={connected} /> <TopBar connected={connected} reconnectAttempt={reconnectAttempt} />
<div className="ao-main"> <div className="ao-main">
<div className="ao-grid-wrap"> <div className="ao-grid-wrap">
<AgentGrid <AgentGrid

View File

@@ -1,11 +1,19 @@
// src/pages/agent-office/components/TopBar.jsx // src/pages/agent-office/components/TopBar.jsx
export default function TopBar({ connected }) { export default function TopBar({ connected, reconnectAttempt = 0 }) {
let statusText;
if (connected) {
statusText = 'Connected';
} else if (reconnectAttempt === 0) {
statusText = 'Connecting…';
} else {
statusText = `Disconnected · 재연결 시도 #${reconnectAttempt}`;
}
return ( return (
<div className="ao-topbar"> <div className="ao-topbar">
<div className="ao-topbar-left"> <div className="ao-topbar-left">
<span className="ao-topbar-title">Agent Office</span> <span className="ao-topbar-title">Agent Office</span>
<span className={`ao-topbar-status ${connected ? 'connected' : 'disconnected'}`}> <span className={`ao-topbar-status ${connected ? 'connected' : 'disconnected'}`}>
{connected ? 'Connected' : 'Disconnected'} {statusText}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -1,25 +1,34 @@
// src/pages/agent-office/hooks/useAgentManager.js // src/pages/agent-office/hooks/useAgentManager.js
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
const WS_RECONNECT_DELAY = 3000; // Exponential backoff with cap. 1s → 2s → 4s → 8s → 16s → 30s (cap)
const WS_BACKOFF_BASE_MS = 1000;
const WS_BACKOFF_CAP_MS = 30000;
const WS_BACKOFF_MAX_EXP = 5;
export function useAgentManager() { export function useAgentManager() {
const [agents, setAgents] = useState({}); // { agentId: { state, detail, task_id } } const [agents, setAgents] = useState({}); // { agentId: { state, detail, task_id } }
const [pendingTasks, setPendingTasks] = useState([]); // [{id, agent_id, task_type, input_data}] const [pendingTasks, setPendingTasks] = useState([]); // [{id, agent_id, task_type, input_data}]
const [notifications, setNotifications] = useState({}); // { agentId: count } const [notifications, setNotifications] = useState({}); // { agentId: count }
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [reconnectAttempt, setReconnectAttempt] = useState(0);
const [refreshTrigger, setRefreshTrigger] = useState(0); // 탭 데이터 리프레시용 const [refreshTrigger, setRefreshTrigger] = useState(0); // 탭 데이터 리프레시용
const wsRef = useRef(null); const wsRef = useRef(null);
const reconnectRef = useRef(null); const reconnectRef = useRef(null);
const connectRef = useRef(null); const connectRef = useRef(null);
const attemptRef = useRef(0);
const connect = useCallback(() => { const connect = useCallback(() => {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${protocol}://${window.location.host}/api/agent-office/ws`); const ws = new WebSocket(`${protocol}://${window.location.host}/api/agent-office/ws`);
wsRef.current = ws; wsRef.current = ws;
ws.onopen = () => setConnected(true); ws.onopen = () => {
setConnected(true);
attemptRef.current = 0;
setReconnectAttempt(0);
};
ws.onmessage = (e) => { ws.onmessage = (e) => {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
@@ -69,10 +78,17 @@ export function useAgentManager() {
ws.onclose = () => { ws.onclose = () => {
setConnected(false); setConnected(false);
reconnectRef.current = setTimeout(() => connectRef.current?.(), WS_RECONNECT_DELAY); const exp = Math.min(attemptRef.current, WS_BACKOFF_MAX_EXP);
const delay = Math.min(WS_BACKOFF_BASE_MS * 2 ** exp, WS_BACKOFF_CAP_MS);
attemptRef.current += 1;
setReconnectAttempt(attemptRef.current);
reconnectRef.current = setTimeout(() => connectRef.current?.(), delay);
}; };
ws.onerror = () => ws.close(); // onerror fires before onclose; swallow so the browser doesn't print an
// unhandled-error pair for every retry. onclose still runs and schedules
// the next attempt.
ws.onerror = () => {};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -108,6 +124,7 @@ export function useAgentManager() {
pendingTasks, pendingTasks,
notifications, notifications,
connected, connected,
reconnectAttempt,
refreshTrigger, refreshTrigger,
sendCommand, sendCommand,
sendApproval, sendApproval,