fix: fallback timeout wrapper for terminal commands

This commit is contained in:
JOJO 2026-01-19 21:57:59 +08:00
parent 88dc7e02a4
commit aacdfc78eb
2 changed files with 46 additions and 16 deletions

View File

@ -3,6 +3,7 @@
import json import json
import time import time
import shlex import shlex
import shutil
from typing import Dict, List, Optional, Callable, TYPE_CHECKING from typing import Dict, List, Optional, Callable, TYPE_CHECKING
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
@ -122,6 +123,8 @@ class TerminalManager:
if sandbox_options and sandbox_options.get("container_name"): if sandbox_options and sandbox_options.get("container_name"):
self.sandbox_mode = "docker" self.sandbox_mode = "docker"
self._apply_container_session(container_session) self._apply_container_session(container_session)
# 终端命令超时工具(兼容 macOS 无 coreutils 的情况)
self._timeout_bin = shutil.which("timeout") or shutil.which("gtimeout")
# 终端会话字典 # 终端会话字典
self.terminals: Dict[str, PersistentTerminal] = {} self.terminals: Dict[str, PersistentTerminal] = {}
@ -532,11 +535,7 @@ class TerminalManager:
base_timeout = timeout base_timeout = timeout
marker = f"__CMD_DONE__{int(time.time()*1000)}__" marker = f"__CMD_DONE__{int(time.time()*1000)}__"
# 1) timeout -k 2 : 超时后先发 SIGTERM再 2 秒后 SIGKILL只结束命令不关终端 wrapped_command, wait_timeout = self._build_wrapped_command(command, marker, timeout)
# 2) sh -c 'cmd; echo marker' 用标记区分正常结束;在终端侧过滤掉回显中的误判
wrapped_inner = f"{command} ; echo {marker}"
wrapped_command = f"timeout -k 2 {int(timeout)}s sh -c {shlex.quote(wrapped_inner)}"
wait_timeout = timeout + 3 # 留出终止与收尾输出
result = terminal.send_command( result = terminal.send_command(
wrapped_command, wrapped_command,
@ -549,6 +548,33 @@ class TerminalManager:
return result return result
def _build_wrapped_command(self, command: str, marker: str, timeout: int) -> (str, int):
"""
构造带超时与完成标记的包装命令
- 优先使用 coreutils timeout / gtimeout
- 若不可用 macOS 默认环境退化为 sleep+kill 方案
"""
safe_timeout = max(1, int(timeout))
wait_timeout = safe_timeout + 3
if self._timeout_bin:
wrapped_inner = f"{command} ; echo {marker}"
wrapped_command = f"{self._timeout_bin} -k 2 {safe_timeout}s sh -c {shlex.quote(wrapped_inner)}"
return wrapped_command, wait_timeout
# fallback: 使用 sh + sleep/kill 实现超时(适用于缺少 timeout 的环境)
fallback_inner = (
f"{command} & CMD_PID=$!; "
f"(sleep {safe_timeout} && kill -s INT $CMD_PID >/dev/null 2>&1 && sleep 2 && kill -s KILL $CMD_PID >/dev/null 2>&1) & "
f"WAITER=$!; "
f"wait $CMD_PID; CMD_STATUS=$?; kill $WAITER >/dev/null 2>&1; "
f"echo {marker}; exit $CMD_STATUS"
)
wrapped_command = f"sh -c {shlex.quote(fallback_inner)}"
# 额外留 1s 收尾
return wrapped_command, safe_timeout + 4
def get_terminal_output( def get_terminal_output(
self, self,
session_name: str = None, session_name: str = None,

View File

@ -40,9 +40,11 @@ class TerminalOperator:
def __init__(self, project_path: str, container_session: Optional["ContainerHandle"] = None): def __init__(self, project_path: str, container_session: Optional["ContainerHandle"] = None):
self.project_path = Path(project_path).resolve() self.project_path = Path(project_path).resolve()
self.process = None self.process = None
# 自动检测Python命令并记录虚拟环境变量若存在 # 自动检测Python命令并记录虚拟环境变量仅宿主机使用
self._python_env: Dict[str, str] = {} self._python_env: Dict[str, str] = {}
self.python_cmd = self._detect_python_runtime() self.python_cmd = self._detect_python_runtime()
# Docker 容器内的 Python 命令(镜像内已将 /opt/agent-venv/bin 置于 PATH
self.container_python_cmd = os.environ.get("CONTAINER_PYTHON_CMD", "python3")
print(f"{OUTPUT_FORMATS['info']} 检测到Python命令: {self.python_cmd}") print(f"{OUTPUT_FORMATS['info']} 检测到Python命令: {self.python_cmd}")
self._toolbox: Optional[ToolboxContainer] = None self._toolbox: Optional[ToolboxContainer] = None
self.container_session: Optional["ContainerHandle"] = container_session self.container_session: Optional["ContainerHandle"] = container_session
@ -220,9 +222,11 @@ class TerminalOperator:
} }
# 每次执行前重置工具容器(保持隔离),但下面改用一次性子进程执行,仍保留重置以兼容后续逻辑 # 每次执行前重置工具容器(保持隔离),但下面改用一次性子进程执行,仍保留重置以兼容后续逻辑
self._reset_toolbox() self._reset_toolbox()
execution_in_container = bool(self.container_session and getattr(self.container_session, "mode", None) == "docker")
python_rewrite = self.container_python_cmd if execution_in_container else self.python_cmd
# 统一替换 python/python3为保障虚拟环境命中只替换独立单词 # 统一替换 python/python3为保障虚拟环境命中只替换独立单词
if re.search(r"\bpython3?\b", command): if re.search(r"\bpython3?\b", command):
command = re.sub(r"\bpython3?\b", self.python_cmd, command) command = re.sub(r"\bpython3?\b", python_rewrite, command)
# 验证命令 # 验证命令
valid, error = self._validate_command(command) valid, error = self._validate_command(command)
@ -507,9 +511,9 @@ class TerminalOperator:
except ValueError: except ValueError:
relative_temp = temp_file.as_posix() relative_temp = temp_file.as_posix()
# 使用检测到的Python命令执行文件相对路径可兼容容器挂载路径 # 使用通用 python3 占位,由 run_command 根据执行环境重写
result = await self.run_command( result = await self.run_command(
f'{self.python_cmd} -u "{relative_temp}"', f'python3 -u "{relative_temp}"',
timeout=timeout timeout=timeout
) )
@ -579,8 +583,8 @@ class TerminalOperator:
except ValueError: except ValueError:
relative_path = full_path.as_posix() relative_path = full_path.as_posix()
# 使用检测到的Python命令构建命令 # 使用通用 python3 占位,由 run_command 根据执行环境重写
command = f'{self.python_cmd} "{relative_path}"' command = f'python3 "{relative_path}"'
if args: if args:
command += f" {args}" command += f" {args}"
@ -599,8 +603,8 @@ class TerminalOperator:
""" """
print(f"{OUTPUT_FORMATS['terminal']} 安装包: {package}") print(f"{OUTPUT_FORMATS['terminal']} 安装包: {package}")
# 使用检测到的Python命令安装 # 使用通用 python3 占位,由 run_command 根据执行环境重写
command = f'{self.python_cmd} -m pip install {package}' command = f'python3 -m pip install {package}'
result = await self.run_command(command, timeout=120) result = await self.run_command(command, timeout=120)
@ -625,7 +629,7 @@ class TerminalOperator:
# 获取Python版本 # 获取Python版本
version_result = await self.run_command( version_result = await self.run_command(
f'{self.python_cmd} --version', 'python3 --version',
timeout=5 timeout=5
) )
if version_result["success"]: if version_result["success"]:
@ -633,7 +637,7 @@ class TerminalOperator:
# 获取pip版本 # 获取pip版本
pip_result = await self.run_command( pip_result = await self.run_command(
f'{self.python_cmd} -m pip --version', 'python3 -m pip --version',
timeout=5 timeout=5
) )
if pip_result["success"]: if pip_result["success"]:
@ -641,7 +645,7 @@ class TerminalOperator:
# 获取已安装的包 # 获取已安装的包
packages_result = await self.run_command( packages_result = await self.run_command(
f'{self.python_cmd} -m pip list --format=json', 'python3 -m pip list --format=json',
timeout=10 timeout=10
) )
if packages_result["success"]: if packages_result["success"]: