From 0c839e8c6fb501dbc668f2036eadc6197d9aab2e Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Thu, 28 May 2026 09:33:35 +0200 Subject: [PATCH] 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) --- apps/api/src/api/tool_instances.py | 2 + apps/api/src/services/docker.py | 8 +++- apps/web/src/components/terminal.tsx | 59 ++++++++++++++++++---------- 3 files changed, 46 insertions(+), 23 deletions(-) diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 3d3a199..bedd69a 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -659,6 +659,8 @@ services: app: image: {image_tag} container_name: {instance_name.lower()} + stdin_open: true + tty: true {ports_section} volumes: - {repo_path}:/workspace restart: unless-stopped diff --git a/apps/api/src/services/docker.py b/apps/api/src/services/docker.py index edf4f80..8c0bd58 100644 --- a/apps/api/src/services/docker.py +++ b/apps/api/src/services/docker.py @@ -136,6 +136,8 @@ def execute_compose_command( def get_container_id(instance_name: str) -> str | None: """Get the container ID for a compose service. + Searches all containers including stopped/exited ones. + Args: instance_name: The service name in compose @@ -143,7 +145,7 @@ def get_container_id(instance_name: str) -> str | None: Container ID or None if not found """ result = subprocess.run( - ["docker", "ps", "-q", "--filter", f"name={instance_name}"], + ["docker", "ps", "-a", "-q", "--filter", f"name={instance_name}"], capture_output=True, text=True, ) @@ -156,6 +158,8 @@ def get_container_id(instance_name: str) -> str | None: def get_container_name(instance_name: str) -> str | None: """Get the full container name for a compose service. + Searches all containers including stopped/exited ones. + Args: instance_name: The service name in compose @@ -163,7 +167,7 @@ def get_container_name(instance_name: str) -> str | None: Container name or None if not found """ result = subprocess.run( - ["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"], + ["docker", "ps", "-a", "--format", "{{.Names}}", "--filter", f"name={instance_name}"], capture_output=True, text=True, ) diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx index e902927..e46ca00 100644 --- a/apps/web/src/components/terminal.tsx +++ b/apps/web/src/components/terminal.tsx @@ -62,6 +62,7 @@ export const TerminalComponent: React.FC = ({ const lastPingRef = useRef(0); const heartbeatCheckRef = useRef(null); const isUnmountingRef = useRef(false); + const permanentErrorRef = useRef(null); const calculateFontSize = useCallback(() => { return fontSize; @@ -151,35 +152,48 @@ export const TerminalComponent: React.FC = ({ }; 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 = ({ // Visibility API for reconnection const handleVisibilityChange = () => { if (document.visibilityState === "visible" && ws && ws.readyState !== WebSocket.OPEN) { + if (permanentErrorRef.current) { + return; + } reconnectAttemptsRef.current = 0; connectWebSocket(); }