fix: terminal 4004 infinite reconnect loop for pi-agent tool type

- Add stdin_open: true and tty: true to dockerfile-based compose generation.
  Without these, bash (PID 1) exits immediately, causing a container restart
  loop that makes the instance invisible to docker ps and triggers 4004.
- Treat WebSocket close codes 4001/4003/4004 as permanent errors in the
  frontend. Stop retrying and show the server reason to the user.
- Prevent visibilitychange handler from resetting retry attempts after a
  permanent error has occurred.
- Use docker ps -a in get_container_id/get_container_name to find
  stopped/exited containers for diagnostics.

Quality gates: tsc --noEmit (pass), pytest (98 passed, 4 pre-existing failures)
This commit is contained in:
2026-05-28 09:33:35 +02:00
parent c63cf7db50
commit 0c839e8c6f
3 changed files with 46 additions and 23 deletions
+38 -21
View File
@@ -62,6 +62,7 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
const lastPingRef = useRef<number>(0);
const heartbeatCheckRef = useRef<number | null>(null);
const isUnmountingRef = useRef(false);
const permanentErrorRef = useRef<string | null>(null);
const calculateFontSize = useCallback(() => {
return fontSize;
@@ -151,35 +152,48 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
};
ws.onclose = (event) => {
setStatus("disconnected");
// Clean up heartbeat check
if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current);
heartbeatCheckRef.current = null;
}
if (event.code !== 1000 && event.code !== 4000) {
setError(`Connection closed (code: ${event.code})`);
// Attempt reconnection
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
reconnectAttemptsRef.current++;
const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1);
setTimeout(() => {
if (isUnmountingRef.current) {
return;
}
if (document.visibilityState !== "hidden") {
connectWebSocket();
} else {
}
}, delay);
} else {
}
} else if (event.code === 4000) {
// Permanent errors: do not retry
if (event.code === 4001 || event.code === 4003 || event.code === 4004) {
const reason = event.reason || `Instance error (code: ${event.code})`;
setStatus("error");
setError(reason);
permanentErrorRef.current = reason;
return;
}
if (event.code === 1000) {
setStatus("disconnected");
return;
}
if (event.code === 4000) {
// Server closed old connection for concurrent connection - don't reconnect
// The new connection is already established
return;
}
// Transient errors: attempt reconnection
setStatus("disconnected");
setError(`Connection closed (code: ${event.code})`);
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
reconnectAttemptsRef.current++;
const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1);
setTimeout(() => {
if (isUnmountingRef.current) {
return;
}
if (document.visibilityState !== "hidden") {
connectWebSocket();
} else {
}
}, delay);
}
};
@@ -365,6 +379,9 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
// Visibility API for reconnection
const handleVisibilityChange = () => {
if (document.visibilityState === "visible" && ws && ws.readyState !== WebSocket.OPEN) {
if (permanentErrorRef.current) {
return;
}
reconnectAttemptsRef.current = 0;
connectWebSocket();
}