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())