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:
@@ -659,6 +659,8 @@ services:
|
|||||||
app:
|
app:
|
||||||
image: {image_tag}
|
image: {image_tag}
|
||||||
container_name: {instance_name.lower()}
|
container_name: {instance_name.lower()}
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
{ports_section} volumes:
|
{ports_section} volumes:
|
||||||
- {repo_path}:/workspace
|
- {repo_path}:/workspace
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -136,6 +136,8 @@ def execute_compose_command(
|
|||||||
def get_container_id(instance_name: str) -> str | None:
|
def get_container_id(instance_name: str) -> str | None:
|
||||||
"""Get the container ID for a compose service.
|
"""Get the container ID for a compose service.
|
||||||
|
|
||||||
|
Searches all containers including stopped/exited ones.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance_name: The service name in compose
|
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
|
Container ID or None if not found
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
|
["docker", "ps", "-a", "-q", "--filter", f"name={instance_name}"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=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:
|
def get_container_name(instance_name: str) -> str | None:
|
||||||
"""Get the full container name for a compose service.
|
"""Get the full container name for a compose service.
|
||||||
|
|
||||||
|
Searches all containers including stopped/exited ones.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance_name: The service name in compose
|
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
|
Container name or None if not found
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
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,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
const lastPingRef = useRef<number>(0);
|
const lastPingRef = useRef<number>(0);
|
||||||
const heartbeatCheckRef = useRef<number | null>(null);
|
const heartbeatCheckRef = useRef<number | null>(null);
|
||||||
const isUnmountingRef = useRef(false);
|
const isUnmountingRef = useRef(false);
|
||||||
|
const permanentErrorRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const calculateFontSize = useCallback(() => {
|
const calculateFontSize = useCallback(() => {
|
||||||
return fontSize;
|
return fontSize;
|
||||||
@@ -151,35 +152,48 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
setStatus("disconnected");
|
|
||||||
|
|
||||||
// Clean up heartbeat check
|
// Clean up heartbeat check
|
||||||
if (heartbeatCheckRef.current) {
|
if (heartbeatCheckRef.current) {
|
||||||
window.clearInterval(heartbeatCheckRef.current);
|
window.clearInterval(heartbeatCheckRef.current);
|
||||||
heartbeatCheckRef.current = null;
|
heartbeatCheckRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.code !== 1000 && event.code !== 4000) {
|
// Permanent errors: do not retry
|
||||||
setError(`Connection closed (code: ${event.code})`);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// Attempt reconnection
|
if (event.code === 1000) {
|
||||||
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
|
setStatus("disconnected");
|
||||||
reconnectAttemptsRef.current++;
|
return;
|
||||||
const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1);
|
}
|
||||||
setTimeout(() => {
|
|
||||||
if (isUnmountingRef.current) {
|
if (event.code === 4000) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (document.visibilityState !== "hidden") {
|
|
||||||
connectWebSocket();
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
}, delay);
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
} else if (event.code === 4000) {
|
|
||||||
// Server closed old connection for concurrent connection - don't reconnect
|
// Server closed old connection for concurrent connection - don't reconnect
|
||||||
// The new connection is already established
|
// 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
|
// Visibility API for reconnection
|
||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
if (document.visibilityState === "visible" && ws && ws.readyState !== WebSocket.OPEN) {
|
if (document.visibilityState === "visible" && ws && ws.readyState !== WebSocket.OPEN) {
|
||||||
|
if (permanentErrorRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user