Fix loading state bugs and server crash
Backend fixes: - Fix server crash when logging large session_state messages (186 agents) - Use safe logging that extracts only message/stack properties - Prevents TypeError in util.inspect on circular refs or deep objects Frontend loading state fixes: - Add reactive WebSocket update to prevent stale session.ws references - Add timeout/retry wrapper for session_state loading (3 retries × 15s) - Add hasEverReceivedSessionState flag to track cached data across reconnects - Implement data-aware loading (isInitialLoad vs isRevalidating) - Show reconnecting badge instead of hiding agents on reconnect Debug improvements: - Add detailed connection state logging in useAggregatedAgents - Add connection status logging in AgentInputArea - Track connecting reasons for easier debugging Fixes: - Agent screen no longer shows false "Offline" toast on reconnect - Home screen shows loading immediately (no empty state flash) - Home screen keeps agents visible during reconnect with badge - Stuck loading has timeout protection with graceful degradation - Server no longer crashes when sending session_state with many agents 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,11 +3,11 @@ import type { ReactNode } from "react";
|
||||
import { useDaemonRegistry, type DaemonProfile } from "./daemon-registry-context";
|
||||
|
||||
export type ConnectionState =
|
||||
| { status: "idle"; lastError: null; lastOnlineAt: string | null; sessionReady: false }
|
||||
| { status: "connecting"; lastError: null; lastOnlineAt: string | null; sessionReady: false }
|
||||
| { status: "online"; lastError: null; lastOnlineAt: string; sessionReady: boolean }
|
||||
| { status: "offline"; lastError: string | null; lastOnlineAt: string | null; sessionReady: false }
|
||||
| { status: "error"; lastError: string; lastOnlineAt: string | null; sessionReady: false };
|
||||
| { status: "idle"; lastError: null; lastOnlineAt: string | null; sessionReady: false; hasEverReceivedSessionState: false }
|
||||
| { status: "connecting"; lastError: null; lastOnlineAt: string | null; sessionReady: false; hasEverReceivedSessionState: boolean }
|
||||
| { status: "online"; lastError: null; lastOnlineAt: string; sessionReady: boolean; hasEverReceivedSessionState: boolean }
|
||||
| { status: "offline"; lastError: string | null; lastOnlineAt: string | null; sessionReady: false; hasEverReceivedSessionState: boolean }
|
||||
| { status: "error"; lastError: string; lastOnlineAt: string | null; sessionReady: false; hasEverReceivedSessionState: boolean };
|
||||
|
||||
export type ConnectionStatus = ConnectionState["status"];
|
||||
|
||||
@@ -36,6 +36,7 @@ function createDefaultConnectionState(): ConnectionState {
|
||||
lastError: null,
|
||||
lastOnlineAt: null,
|
||||
sessionReady: false,
|
||||
hasEverReceivedSessionState: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,20 +46,31 @@ function resolveNextConnectionState(
|
||||
): ConnectionState {
|
||||
switch (update.status) {
|
||||
case "idle":
|
||||
return { status: "idle", lastError: null, lastOnlineAt: existing.lastOnlineAt, sessionReady: false };
|
||||
return {
|
||||
status: "idle",
|
||||
lastError: null,
|
||||
lastOnlineAt: existing.lastOnlineAt,
|
||||
sessionReady: false,
|
||||
hasEverReceivedSessionState: false,
|
||||
};
|
||||
case "connecting":
|
||||
return {
|
||||
status: "connecting",
|
||||
lastError: null,
|
||||
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
|
||||
sessionReady: false,
|
||||
hasEverReceivedSessionState: existing.hasEverReceivedSessionState ?? false,
|
||||
};
|
||||
case "online":
|
||||
const currentSessionReady = update.sessionReady ?? (existing.status === "online" ? existing.sessionReady : false);
|
||||
|
||||
return {
|
||||
status: "online",
|
||||
lastError: null,
|
||||
lastOnlineAt: update.lastOnlineAt,
|
||||
sessionReady: update.sessionReady ?? (existing.status === "online" ? existing.sessionReady : false),
|
||||
sessionReady: currentSessionReady,
|
||||
hasEverReceivedSessionState:
|
||||
currentSessionReady || existing.hasEverReceivedSessionState || false,
|
||||
};
|
||||
case "offline":
|
||||
return {
|
||||
@@ -66,6 +78,7 @@ function resolveNextConnectionState(
|
||||
lastError: update.lastError ?? null,
|
||||
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
|
||||
sessionReady: false,
|
||||
hasEverReceivedSessionState: existing.hasEverReceivedSessionState ?? false,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
@@ -73,6 +86,7 @@ function resolveNextConnectionState(
|
||||
lastError: update.lastError,
|
||||
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
|
||||
sessionReady: false,
|
||||
hasEverReceivedSessionState: existing.hasEverReceivedSessionState ?? false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +246,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
||||
const providerModelRequestIdsRef = useRef<Map<any, string>>(new Map());
|
||||
const hasHydratedSnapshotRef = useRef(false);
|
||||
const hasRequestedInitialSnapshotRef = useRef(false);
|
||||
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Buffer for streaming audio chunks
|
||||
interface AudioChunk {
|
||||
@@ -261,6 +262,11 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
||||
initializeSession(serverId, ws, audioPlayer);
|
||||
}, [serverId, ws, audioPlayer, initializeSession]);
|
||||
|
||||
const updateSessionWebSocket = useSessionStore((state) => state.updateSessionWebSocket);
|
||||
useEffect(() => {
|
||||
updateSessionWebSocket(serverId, ws);
|
||||
}, [serverId, ws, updateSessionWebSocket]);
|
||||
|
||||
// Connection status tracking
|
||||
useEffect(() => {
|
||||
if (ws.isConnected) {
|
||||
@@ -496,14 +502,62 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
||||
return;
|
||||
}
|
||||
hasRequestedInitialSnapshotRef.current = true;
|
||||
ws.send({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "load_conversation_request",
|
||||
conversationId: ws.conversationId ?? "",
|
||||
},
|
||||
});
|
||||
}, [wsIsConnected, ws]);
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const TIMEOUT_MS = 15000;
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
|
||||
let retryCount = 0;
|
||||
|
||||
const requestSessionState = () => {
|
||||
console.log(`[Session] Requesting session_state (attempt ${retryCount + 1}/${MAX_RETRIES + 1})`, { serverId });
|
||||
|
||||
ws.send({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "load_conversation_request",
|
||||
conversationId: ws.conversationId ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
if (sessionStateTimeoutRef.current) {
|
||||
clearTimeout(sessionStateTimeoutRef.current);
|
||||
}
|
||||
|
||||
sessionStateTimeoutRef.current = setTimeout(() => {
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount++;
|
||||
console.warn(`[Session] session_state timeout, retrying in ${RETRY_DELAY_MS}ms`, {
|
||||
serverId,
|
||||
attempt: retryCount,
|
||||
maxRetries: MAX_RETRIES,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
requestSessionState();
|
||||
}, RETRY_DELAY_MS);
|
||||
} else {
|
||||
console.error(`[Session] session_state failed after ${MAX_RETRIES} retries`, { serverId });
|
||||
|
||||
setHasHydratedAgents(serverId, true);
|
||||
updateConnectionStatus(serverId, {
|
||||
status: "online",
|
||||
lastOnlineAt: new Date().toISOString(),
|
||||
sessionReady: true
|
||||
});
|
||||
}
|
||||
}, TIMEOUT_MS);
|
||||
};
|
||||
|
||||
requestSessionState();
|
||||
|
||||
return () => {
|
||||
if (sessionStateTimeoutRef.current) {
|
||||
clearTimeout(sessionStateTimeoutRef.current);
|
||||
sessionStateTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [wsIsConnected, ws, serverId, setHasHydratedAgents, updateConnectionStatus]);
|
||||
|
||||
// WebSocket message handlers - directly update Zustand store
|
||||
useEffect(() => {
|
||||
@@ -511,6 +565,12 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
||||
|
||||
const unsubSessionState = ws.on("session_state", (message) => {
|
||||
if (message.type !== "session_state") return;
|
||||
|
||||
if (sessionStateTimeoutRef.current) {
|
||||
clearTimeout(sessionStateTimeoutRef.current);
|
||||
sessionStateTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
const { agents: agentsList, commands: commandsList } = message.payload;
|
||||
|
||||
console.log("[Session] ✅ Received session_state:", agentsList.length, "agents,", commandsList.length, "commands");
|
||||
|
||||
Reference in New Issue
Block a user