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.

frdel committed Jul 20, 2026 at 17:04 UTC 4ffcdf5246ba6f31bd27a44f41af41adf1c8cefd
8 files changed +199 -4
plugins/_code_execution/AGENTS.md
+1
@@ -15,6 +15,7 @@
15
16 - Keep session concurrency, timeout, streaming, and reset behavior predictable.
17 - 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.
18 +- 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.
19 - Terminal reset/close must not hang on foreground commands or shells that ignore SIGTERM.
20 - Explicitly target local versus SSH execution runtimes.
21 - Do not hardcode secrets, SSH credentials, or local user paths.
plugins/_code_execution/README.md
+1
@@ -24,6 +24,7 @@ This plugin provides the code execution tool used by agents for development task
24 - Can open SSH interactive sessions instead of local shells when configured.
25 - **Streaming output**
26 - Continuously reads shell output, updates the current log item, and detects progress while commands are running.
27 + - Detects local shell exit and SSH channel termination when strict mode, `exit`, or a lost connection prevents a final prompt from appearing.
28 - **Long-running work**
29 - Keeps normal command execution responsive while giving the `output` runtime longer polling windows for builds, installs, servers, tests, and training jobs.
30 - **Safety around running sessions**
plugins/_code_execution/helpers/shell_local.py
+9 -1
@@ -57,6 +57,14 @@ class LocalInteractiveSession:
57 raise Exception("Shell not connected")
58 self.full_output = ""
59 await self.session.sendline(command)
60 +
61 + def is_terminated(self) -> bool:
62 + return self.session is None or self.session.is_terminated()
63 +
64 + def get_exit_code(self) -> int | None:
65 + if not self.session:
66 + return None
67 + return self.session.get_exit_code()
68
69 async def read_output(self, timeout: float = 0, reset_full_output: bool = False) -> Tuple[str, Optional[str]]:
70 if not self.session:
@@ -75,4 +83,4 @@ class LocalInteractiveSession:
83
84 if not partial_output:
85 return clean_full_output, None
78 - return clean_full_output, partial_output
\ No newline at end of file
86 + return clean_full_output, partial_output
plugins/_code_execution/helpers/shell_ssh.py
+23
@@ -34,6 +34,7 @@ class SSHInteractiveSession:
34 self.last_command = b""
35 self.trimmed_command_length = 0 # Initialize trimmed_command_length
36 self.cwd = cwd
37 + self._exit_code: int | None = None
38
39 async def connect(self, keepalive_interval: int = 5):
40 """
@@ -67,6 +68,7 @@ class SSHInteractiveSession:
68
69 # invoke interactive shell
70 self.shell = self.client.invoke_shell(width=100, height=50)
71 + self._exit_code = None
72
73 # disable systemd/OSC prompt metadata and disable local echo
74 initial_command = f"unset PROMPT_COMMAND PS0; stty -echo; {PAGER_DISABLE_COMMAND}"
@@ -110,6 +112,27 @@ class SSHInteractiveSession:
112 self.last_command = command.encode()
113 self.trimmed_command_length = 0
114 self.shell.send(self.last_command)
115 +
116 + def is_terminated(self) -> bool:
117 + if not self.shell:
118 + return True
119 + try:
120 + transport = self.client.get_transport()
121 + if not transport or not transport.is_active():
122 + return True
123 + return self.shell.closed or self.shell.exit_status_ready()
124 + except Exception:
125 + return True
126 +
127 + def get_exit_code(self) -> int | None:
128 + if self._exit_code is not None:
129 + return self._exit_code
130 + try:
131 + if self.shell and self.shell.exit_status_ready():
132 + self._exit_code = self.shell.recv_exit_status()
133 + except Exception:
134 + return None
135 + return self._exit_code
136
137 async def read_output(
138 self, timeout: float = 0, reset_full_output: bool = False
plugins/_code_execution/helpers/tty_session.py
+10
@@ -176,6 +176,16 @@ class TTYSession:
176 raise RuntimeError("TTYSpawn is not started")
177 return await self._proc.wait()
178
179 + def is_terminated(self) -> bool:
180 + """Return whether the managed shell process has exited."""
181 + return self._proc is None or getattr(self._proc, "returncode", None) is not None
182 +
183 + def get_exit_code(self) -> int | None:
184 + """Return the managed shell exit code when it is already available."""
185 + if self._proc is None:
186 + return None
187 + return getattr(self._proc, "returncode", None)
188 +
189 def kill(self):
190 """Force-kill the running child process.
191
plugins/_code_execution/prompts/fw.code.shell_exit.md new
+1
@@ -0,0 +1 @@
1 +Terminal shell exited{{status}}. The command has finished; a new shell will be created before the next command.
plugins/_code_execution/tools/code_execution_tool.py
+33 -2
@@ -17,8 +17,10 @@ from plugins._code_execution.helpers.shell_ssh import SSHInteractiveSession
17
18
19 def _is_closed_pty_error(exc: BaseException) -> bool:
20 - if isinstance(exc, RuntimeError) and "TTYSpawn PTY is closed" in str(exc):
21 - return True
20 + if isinstance(exc, RuntimeError):
21 + message = str(exc)
22 + if "TTYSpawn PTY is closed" in message or "TTYSpawn process has exited" in message:
23 + return True
24 if isinstance(exc, OSError) and exc.errno in (errno.EBADF, errno.EIO, errno.EINVAL):
25 return True
26 cause = getattr(exc, "__cause__", None)
@@ -190,6 +192,11 @@ class CodeExecution(Tool):
192 if response := await self.handle_running_session(cfg, session):
193 return response
194
195 + # A strict-mode command can terminate the persistent shell itself.
196 + # Recreate such a session lazily before accepting the next command.
197 + if self.state.shells[session].session.is_terminated():
198 + await self.prepare_state(cfg, reset=True, session=session)
199 +
200 # try again on lost connection
201 for i in range(2):
202 try:
@@ -295,6 +302,26 @@ class CodeExecution(Tool):
302 last_output_time = now
303 got_output = True
304
305 + # ``set -e``, ``exit``, or a lost SSH channel can end the managed
306 + # shell without ever producing another prompt. Treat that process
307 + # or channel termination as a definitive command end.
308 + shell = self.state.shells[session].session
309 + if shell.is_terminated():
310 + exit_code = shell.get_exit_code()
311 + status = f" with exit code {exit_code}" if exit_code is not None else ""
312 + sysinfo = self.agent.read_prompt(
313 + "fw.code.shell_exit.md", status=status
314 + )
315 + response = self.agent.read_prompt("fw.code.info.md", info=sysinfo)
316 + if truncated_output:
317 + response = truncated_output + "\n\n" + response
318 + PrintStyle.warning(sysinfo)
319 + heading = self.get_heading_from_output(truncated_output, 0, True)
320 + self.log.update(content=prefix + response, heading=heading)
321 + self.mark_session_idle(session)
322 + return response
323 +
324 + if partial_output:
325 # Check for shell prompt at the end of output
326 last_lines = (
327 truncated_output.splitlines()[-3:] if truncated_output else []
@@ -411,6 +438,10 @@ class CodeExecution(Tool):
438 await self.set_progress(truncated_output)
439 heading = self.get_heading_from_output(truncated_output, 0)
440
441 + if self.state.shells[session].session.is_terminated():
442 + self.mark_session_idle(session)
443 + return None
444 +
445 last_lines = (
446 truncated_output.splitlines()[-3:] if truncated_output else []
447 )
tests/test_code_execution_pager.py
+121 -1
@@ -5,10 +5,17 @@ code execution tool: without user input they block forever and spin at 100% CPU.
5 """
6
7 import asyncio
8 +from types import SimpleNamespace
9
10 from plugins._code_execution.helpers import shell_local, shell_ssh
11 from plugins._code_execution.helpers.tty_session import TTYSession
11 -from plugins._code_execution.tools.code_execution_tool import _group_multiline_command
12 +from plugins._code_execution.tools.code_execution_tool import (
13 + CodeExecution,
14 + ShellWrap,
15 + State,
16 + _group_multiline_command,
17 + _is_closed_pty_error,
18 +)
19
20
21 def test_local_env_disables_pagers_and_preserves_existing():
@@ -44,6 +51,10 @@ def test_multiline_terminal_commands_are_one_current_shell_compound():
51 )
52
53
54 +def test_exited_tty_process_is_a_recoverable_closed_session():
55 + assert _is_closed_pty_error(RuntimeError("TTYSpawn process has exited"))
56 +
57 +
58 def test_tty_close_kills_term_resistant_process():
59 async def run():
60 session = TTYSession("bash -lc 'trap \"\" TERM; sleep 30'")
@@ -52,3 +63,112 @@ def test_tty_close_kills_term_resistant_process():
63 assert session._proc is None
64
65 asyncio.run(run())
66 +
67 +
68 +def test_tty_reports_strict_mode_shell_exit():
69 + async def run():
70 + session = TTYSession("/bin/bash --noprofile --norc -i")
71 + await session.start()
72 + await session.read_full_until_idle(idle_timeout=0.05, total_timeout=1)
73 + await session.sendline("{\nset -euo pipefail\nfalse\nprintf 'unreachable\\n'\n}")
74 +
75 + exit_code = await asyncio.wait_for(session.wait(), timeout=5)
76 +
77 + assert exit_code != 0
78 + assert session.is_terminated()
79 + assert session.get_exit_code() == exit_code
80 + await session.close()
81 +
82 + asyncio.run(run())
83 +
84 +
85 +def test_ssh_session_reports_channel_exit_status():
86 + class FakeChannel:
87 + closed = False
88 +
89 + @staticmethod
90 + def exit_status_ready():
91 + return True
92 +
93 + @staticmethod
94 + def recv_exit_status():
95 + return 7
96 +
97 + session = object.__new__(shell_ssh.SSHInteractiveSession)
98 + session.shell = FakeChannel()
99 + session.client = SimpleNamespace(
100 + get_transport=lambda: SimpleNamespace(is_active=lambda: True)
101 + )
102 + session._exit_code = None
103 +
104 + assert session.is_terminated()
105 + assert session.get_exit_code() == 7
106 +
107 +
108 +def test_code_execution_returns_immediately_when_shell_exits():
109 + class FinishedSession:
110 + async def read_output(self, timeout=0, reset_full_output=False):
111 + return "nothing to commit, working tree clean\n", "nothing to commit, working tree clean\n"
112 +
113 + @staticmethod
114 + def is_terminated():
115 + return True
116 +
117 + @staticmethod
118 + def get_exit_code():
119 + return 1
120 +
121 + class FakeAgent:
122 + agent_name = "test"
123 +
124 + async def handle_intervention(self):
125 + return None
126 +
127 + @staticmethod
128 + def read_prompt(name, **kwargs):
129 + if name == "fw.code.shell_exit.md":
130 + return f"Terminal shell exited{kwargs['status']}. The command has finished."
131 + if name == "fw.code.info.md":
132 + return f"[SYSTEM: {kwargs['info']}]"
133 + raise AssertionError(f"Unexpected prompt: {name}")
134 +
135 + async def run():
136 + session = FinishedSession()
137 + state = State(
138 + ssh_enabled=False,
139 + shells={0: ShellWrap(id=0, session=session, running=True)},
140 + )
141 + tool = CodeExecution(
142 + FakeAgent(),
143 + "code_execution_tool",
144 + "",
145 + {"runtime": "terminal", "session": 0},
146 + "",
147 + None,
148 + )
149 + updates = []
150 + tool.log = SimpleNamespace(update=lambda **kwargs: updates.append(kwargs))
151 +
152 + async def prepare_state(*args, **kwargs):
153 + return state
154 +
155 + async def set_progress(content):
156 + return None
157 +
158 + tool.prepare_state = prepare_state
159 + tool.set_progress = set_progress
160 + tool.fix_full_output = lambda output: output
161 +
162 + response = await tool.get_terminal_output(
163 + {"prompt_patterns": [], "dialog_patterns": []},
164 + session=0,
165 + sleep_time=0,
166 + )
167 +
168 + assert "nothing to commit" in response
169 + assert "exit code 1" in response
170 + assert "command has finished" in response
171 + assert not state.shells[0].running
172 + assert updates[-1]["heading"].endswith(" icon://done_all")
173 +
174 + asyncio.run(run())