fix(code_execution): handle closed PTYs while reading output

Agent Zero Local committed May 2, 2026 at 21:33 UTC eecbb5ba344cbb9f780e6c0c14af1a4b16219144
2 files changed +48 -11
plugins/_code_execution/helpers/shell_local.py
+8 -1
@@ -21,8 +21,15 @@ class LocalInteractiveSession:
21
22 async def close(self):
23 if self.session:
24 - await self.session.close()
24 + session = self.session
25 self.session = None
26 + try:
27 + await session.close()
28 + except Exception:
29 + try:
30 + session.kill()
31 + except Exception:
32 + pass
33
34 async def send_command(self, command: str):
35 if not self.session:
plugins/_code_execution/tools/code_execution_tool.py
+40 -10
@@ -1,4 +1,5 @@
1 import asyncio
2 +import errno
3 from dataclasses import dataclass
4 import re
5 import shlex
@@ -15,6 +16,17 @@ from plugins._code_execution.helpers.shell_local import LocalInteractiveSession
16 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
22 + if isinstance(exc, OSError) and exc.errno in (errno.EBADF, errno.EIO, errno.EINVAL):
23 + return True
24 + cause = getattr(exc, "__cause__", None)
25 + if cause and cause is not exc:
26 + return _is_closed_pty_error(cause)
27 + return False
28 +
29 +
30 @dataclass
31 class ShellWrap:
32 id: int
@@ -194,11 +206,12 @@ class CodeExecution(Tool):
206 )
207
208 except Exception as e:
197 - PrintStyle.error(str(e))
198 - await self.prepare_state(cfg, reset=True, session=session)
199 - if i == 0:
209 + if _is_closed_pty_error(e) and i == 0:
210 + PrintStyle.warning(f"Terminal session {session} was closed; resetting and retrying once.")
211 + await self.prepare_state(cfg, reset=True, session=session)
212 continue
201 - raise e
213 + PrintStyle.error(str(e))
214 + raise
215
216 def format_command_for_output(self, command: str):
217 short_cmd = command[:250]
@@ -244,9 +257,19 @@ class CodeExecution(Tool):
257
258 while True:
259 await asyncio.sleep(sleep_time)
247 - full_output, partial_output = await self.state.shells[session].session.read_output(
248 - timeout=1, reset_full_output=reset_full_output
249 - )
260 + try:
261 + full_output, partial_output = await self.state.shells[session].session.read_output(
262 + timeout=1, reset_full_output=reset_full_output
263 + )
264 + except Exception as e:
265 + if _is_closed_pty_error(e):
266 + await self.prepare_state(cfg, reset=True, session=session)
267 + self.mark_session_idle(session)
268 + sysinfo = "Terminal session was closed and has been reset. Please run the command again."
269 + response = self.agent.read_prompt("fw.code.info.md", info=sysinfo)
270 + self.log.update(content=prefix + response)
271 + return response
272 + raise
273 reset_full_output = False # only reset once
274
275 await self.agent.handle_intervention()
@@ -363,9 +386,16 @@ class CodeExecution(Tool):
386 prompt_patterns = cfg["prompt_patterns"]
387 dialog_patterns = cfg["dialog_patterns"]
388
366 - full_output, _ = await self.state.shells[session].session.read_output(
367 - timeout=1, reset_full_output=reset_full_output
368 - )
389 + try:
390 + full_output, _ = await self.state.shells[session].session.read_output(
391 + timeout=1, reset_full_output=reset_full_output
392 + )
393 + except Exception as e:
394 + if _is_closed_pty_error(e):
395 + await self.prepare_state(cfg, reset=True, session=session)
396 + self.mark_session_idle(session)
397 + return None
398 + raise
399 truncated_output = self.fix_full_output(full_output)
400 self.set_progress(truncated_output)
401 heading = self.get_heading_from_output(truncated_output, 0)