From e7d3e9d767b473b9fbcf7b85884aa90758a514f9 Mon Sep 17 00:00:00 2001 From: angelos Date: Thu, 9 Apr 2026 02:12:26 +0000 Subject: [PATCH] fix(terminal): persistent sandbox envs survive between turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_cleanup_task_resources` was unconditionally calling `cleanup_vm()` at the end of every `run_conversation` (i.e. every user turn), tearing down the docker/daytona/modal sandbox container regardless of its `persistent_filesystem` setting. This contradicted the documented intent of `terminal.lifetime_seconds` (idle reaper) and `container_persistent`, and caused per-turn loss of `/workspace`, `~/.config`, agent CLI auth state, and any other content living inside the sandbox. The unconditional teardown was introduced in fbd3a2fd ("prevent leakage of morph instances between tasks", 2025-11-04) to plug a Morph backend leak, two days after `lifetime_seconds` shipped in faecbddd. It was later refactored into `_cleanup_task_resources` in 70dd3a16 without changing semantics. Code and docs have disagreed since. Fix: introduce `terminal_tool.is_persistent_env(task_id)` and skip the per-turn `cleanup_vm` when the active env is persistent. The idle reaper (`_cleanup_inactive_envs`) still tears persistent envs down once `terminal.lifetime_seconds` is exceeded. Non-persistent backends (Morph) are unchanged — still torn down per turn, preserving the original leak-prevention intent. --- run_agent.py | 22 +++++++++++++++++++--- tools/terminal_tool.py | 17 +++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/run_agent.py b/run_agent.py index 02803890a..793ddd675 100644 --- a/run_agent.py +++ b/run_agent.py @@ -66,7 +66,7 @@ from model_tools import ( handle_function_call, check_toolset_requirements, ) -from tools.terminal_tool import cleanup_vm, get_active_env +from tools.terminal_tool import cleanup_vm, get_active_env, is_persistent_env from tools.tool_result_storage import maybe_persist_tool_result, enforce_turn_budget from tools.interrupt import set_interrupt as _set_interrupt from tools.browser_tool import cleanup_browser @@ -1695,9 +1695,25 @@ class AIAgent: return None def _cleanup_task_resources(self, task_id: str) -> None: - """Clean up VM and browser resources for a given task.""" + """Clean up VM and browser resources for a given task. + + Skips ``cleanup_vm`` when the active terminal environment is marked + persistent (``persistent_filesystem=True``) so that long-lived sandbox + containers survive between turns. The idle reaper in + ``terminal_tool._cleanup_inactive_envs`` still tears them down once + ``terminal.lifetime_seconds`` is exceeded. Non-persistent backends are + torn down per-turn as before to prevent resource leakage (the original + intent of this hook for the Morph backend, see commit fbd3a2fd). + """ try: - cleanup_vm(task_id) + if is_persistent_env(task_id): + if self.verbose_logging: + logging.debug( + f"Skipping per-turn cleanup_vm for persistent env {task_id}; " + f"idle reaper will handle it." + ) + else: + cleanup_vm(task_id) except Exception as e: if self.verbose_logging: logging.warning(f"Failed to cleanup VM for task {task_id}: {e}") diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 243127a29..183e89833 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -814,6 +814,23 @@ def get_active_env(task_id: str): return _active_environments.get(task_id) +def is_persistent_env(task_id: str) -> bool: + """Return True if the active environment for task_id is configured for + cross-turn persistence (``persistent_filesystem=True``). + + Used by the agent loop to skip per-turn teardown for backends whose whole + point is to survive between turns (docker with ``container_persistent``, + daytona, modal, etc.). Non-persistent backends (e.g. Morph) still get torn + down at end-of-turn to prevent leakage. The idle reaper + (``_cleanup_inactive_envs``) handles persistent envs once they exceed + ``terminal.lifetime_seconds``. + """ + env = get_active_env(task_id) + if env is None: + return False + return bool(getattr(env, "_persistent", False)) + + def get_active_environments_info() -> Dict[str, Any]: """Get information about currently active environments.""" info = {