Handle shell/SSH process exits as completion

Detect and treat terminated local/SSH/TTY shells as definitive command completion. Add is_terminated and get_exit_code helpers to LocalInteractiveSession, SSHInteractiveSession, and TTYSession; expand _is_closed_pty_error to recognize exited TTY processes. CodeExecution now reports a shell-exit prompt, recreates terminated sessions lazily before the next command, and returns immediately when a shell has exited. Docs and README updated to describe strict-mode/exit behavior, and tests were added/updated to cover the new termination detection and handling.
This commit is contained in:
frdel 2026-07-20 17:04:05 +02:00
parent fd795bda82
commit 4ffcdf5246
8 changed files with 199 additions and 4 deletions

View file

@ -15,6 +15,7 @@
- Keep session concurrency, timeout, streaming, and reset behavior predictable.
- Execute multi-line terminal input as one current-shell compound so intermediate prompts cannot mark queued work complete; preserve `cd`, exports, and other shell state.
- Treat local process exit and SSH channel termination as definitive command completion even when no final prompt is emitted; recreate terminated sessions before their next command.
- Terminal reset/close must not hang on foreground commands or shells that ignore SIGTERM.
- Explicitly target local versus SSH execution runtimes.
- Do not hardcode secrets, SSH credentials, or local user paths.

View file

@ -24,6 +24,7 @@ This plugin provides the code execution tool used by agents for development task
- Can open SSH interactive sessions instead of local shells when configured.
- **Streaming output**
- Continuously reads shell output, updates the current log item, and detects progress while commands are running.
- Detects local shell exit and SSH channel termination when strict mode, `exit`, or a lost connection prevents a final prompt from appearing.
- **Long-running work**
- Keeps normal command execution responsive while giving the `output` runtime longer polling windows for builds, installs, servers, tests, and training jobs.
- **Safety around running sessions**

View file

@ -57,6 +57,14 @@ class LocalInteractiveSession:
raise Exception("Shell not connected")
self.full_output = ""
await self.session.sendline(command)
def is_terminated(self) -> bool:
return self.session is None or self.session.is_terminated()
def get_exit_code(self) -> int | None:
if not self.session:
return None
return self.session.get_exit_code()
async def read_output(self, timeout: float = 0, reset_full_output: bool = False) -> Tuple[str, Optional[str]]:
if not self.session:
@ -75,4 +83,4 @@ class LocalInteractiveSession:
if not partial_output:
return clean_full_output, None
return clean_full_output, partial_output
return clean_full_output, partial_output

View file

@ -34,6 +34,7 @@ class SSHInteractiveSession:
self.last_command = b""
self.trimmed_command_length = 0 # Initialize trimmed_command_length
self.cwd = cwd
self._exit_code: int | None = None
async def connect(self, keepalive_interval: int = 5):
"""
@ -67,6 +68,7 @@ class SSHInteractiveSession:
# invoke interactive shell
self.shell = self.client.invoke_shell(width=100, height=50)
self._exit_code = None
# disable systemd/OSC prompt metadata and disable local echo
initial_command = f"unset PROMPT_COMMAND PS0; stty -echo; {PAGER_DISABLE_COMMAND}"
@ -110,6 +112,27 @@ class SSHInteractiveSession:
self.last_command = command.encode()
self.trimmed_command_length = 0
self.shell.send(self.last_command)
def is_terminated(self) -> bool:
if not self.shell:
return True
try:
transport = self.client.get_transport()
if not transport or not transport.is_active():
return True
return self.shell.closed or self.shell.exit_status_ready()
except Exception:
return True
def get_exit_code(self) -> int | None:
if self._exit_code is not None:
return self._exit_code
try:
if self.shell and self.shell.exit_status_ready():
self._exit_code = self.shell.recv_exit_status()
except Exception:
return None
return self._exit_code
async def read_output(
self, timeout: float = 0, reset_full_output: bool = False

View file

@ -176,6 +176,16 @@ class TTYSession:
raise RuntimeError("TTYSpawn is not started")
return await self._proc.wait()
def is_terminated(self) -> bool:
"""Return whether the managed shell process has exited."""
return self._proc is None or getattr(self._proc, "returncode", None) is not None
def get_exit_code(self) -> int | None:
"""Return the managed shell exit code when it is already available."""
if self._proc is None:
return None
return getattr(self._proc, "returncode", None)
def kill(self):
"""Force-kill the running child process.

View file

@ -0,0 +1 @@
Terminal shell exited{{status}}. The command has finished; a new shell will be created before the next command.

View file

@ -17,8 +17,10 @@ from plugins._code_execution.helpers.shell_ssh import SSHInteractiveSession
def _is_closed_pty_error(exc: BaseException) -> bool:
if isinstance(exc, RuntimeError) and "TTYSpawn PTY is closed" in str(exc):
return True
if isinstance(exc, RuntimeError):
message = str(exc)
if "TTYSpawn PTY is closed" in message or "TTYSpawn process has exited" in message:
return True
if isinstance(exc, OSError) and exc.errno in (errno.EBADF, errno.EIO, errno.EINVAL):
return True
cause = getattr(exc, "__cause__", None)
@ -190,6 +192,11 @@ class CodeExecution(Tool):
if response := await self.handle_running_session(cfg, session):
return response
# A strict-mode command can terminate the persistent shell itself.
# Recreate such a session lazily before accepting the next command.
if self.state.shells[session].session.is_terminated():
await self.prepare_state(cfg, reset=True, session=session)
# try again on lost connection
for i in range(2):
try:
@ -295,6 +302,26 @@ class CodeExecution(Tool):
last_output_time = now
got_output = True
# ``set -e``, ``exit``, or a lost SSH channel can end the managed
# shell without ever producing another prompt. Treat that process
# or channel termination as a definitive command end.
shell = self.state.shells[session].session
if shell.is_terminated():
exit_code = shell.get_exit_code()
status = f" with exit code {exit_code}" if exit_code is not None else ""
sysinfo = self.agent.read_prompt(
"fw.code.shell_exit.md", status=status
)
response = self.agent.read_prompt("fw.code.info.md", info=sysinfo)
if truncated_output:
response = truncated_output + "\n\n" + response
PrintStyle.warning(sysinfo)
heading = self.get_heading_from_output(truncated_output, 0, True)
self.log.update(content=prefix + response, heading=heading)
self.mark_session_idle(session)
return response
if partial_output:
# Check for shell prompt at the end of output
last_lines = (
truncated_output.splitlines()[-3:] if truncated_output else []
@ -411,6 +438,10 @@ class CodeExecution(Tool):
await self.set_progress(truncated_output)
heading = self.get_heading_from_output(truncated_output, 0)
if self.state.shells[session].session.is_terminated():
self.mark_session_idle(session)
return None
last_lines = (
truncated_output.splitlines()[-3:] if truncated_output else []
)