fix: reuse terminal container for command exec
This commit is contained in:
parent
aacdfc78eb
commit
c787df2cef
@ -127,6 +127,8 @@ class MainTerminal:
|
|||||||
broadcast_callback=None, # CLI模式不需要广播
|
broadcast_callback=None, # CLI模式不需要广播
|
||||||
container_session=container_session,
|
container_session=container_session,
|
||||||
)
|
)
|
||||||
|
# 让 run_command/run_python 复用终端容器,保持环境一致
|
||||||
|
self.terminal_ops.attach_terminal_manager(self.terminal_manager)
|
||||||
self._apply_container_session(container_session)
|
self._apply_container_session(container_session)
|
||||||
|
|
||||||
self.todo_manager = TodoManager(self.context_manager)
|
self.todo_manager = TodoManager(self.context_manager)
|
||||||
|
|||||||
@ -82,6 +82,8 @@ class WebTerminal(MainTerminal):
|
|||||||
broadcast_callback=message_callback,
|
broadcast_callback=message_callback,
|
||||||
container_session=self.container_session
|
container_session=self.container_session
|
||||||
)
|
)
|
||||||
|
# 让 run_command/run_python 与实时终端共享同一容器环境
|
||||||
|
self.terminal_ops.attach_terminal_manager(self.terminal_manager)
|
||||||
|
|
||||||
print(f"[WebTerminal] 初始化完成,项目路径: {project_path}")
|
print(f"[WebTerminal] 初始化完成,项目路径: {project_path}")
|
||||||
print(f"[WebTerminal] 初始模式: {self.run_mode}")
|
print(f"[WebTerminal] 初始模式: {self.run_mode}")
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import signal
|
|||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Optional, Tuple, TYPE_CHECKING
|
from typing import Dict, Optional, Tuple, TYPE_CHECKING
|
||||||
|
from types import SimpleNamespace
|
||||||
try:
|
try:
|
||||||
from config import (
|
from config import (
|
||||||
CODE_EXECUTION_TIMEOUT,
|
CODE_EXECUTION_TIMEOUT,
|
||||||
@ -35,6 +36,7 @@ from modules.toolbox_container import ToolboxContainer
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from modules.user_container_manager import ContainerHandle
|
from modules.user_container_manager import ContainerHandle
|
||||||
|
from modules.terminal_manager import TerminalManager
|
||||||
|
|
||||||
class TerminalOperator:
|
class TerminalOperator:
|
||||||
def __init__(self, project_path: str, container_session: Optional["ContainerHandle"] = None):
|
def __init__(self, project_path: str, container_session: Optional["ContainerHandle"] = None):
|
||||||
@ -43,11 +45,13 @@ class TerminalOperator:
|
|||||||
# 自动检测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)
|
# Docker 容器内的 Python 命令(默认指向预装 venv)
|
||||||
self.container_python_cmd = os.environ.get("CONTAINER_PYTHON_CMD", "python3")
|
self.container_python_cmd = os.environ.get("CONTAINER_PYTHON_CMD", "/opt/agent-venv/bin/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
|
||||||
|
# 记录 TerminalManager 引用,便于 CLI 场景复用同一容器
|
||||||
|
self._terminal_manager: Optional["TerminalManager"] = None
|
||||||
|
|
||||||
def _reset_toolbox(self):
|
def _reset_toolbox(self):
|
||||||
"""强制关闭并重建工具终端,保证每次命令/脚本运行独立环境。"""
|
"""强制关闭并重建工具终端,保证每次命令/脚本运行独立环境。"""
|
||||||
@ -73,6 +77,58 @@ class TerminalOperator:
|
|||||||
|
|
||||||
return self._detect_system_python()
|
return self._detect_system_python()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _derive_pip_from_python(python_path: str) -> str:
|
||||||
|
"""
|
||||||
|
根据 python 可执行文件推导匹配的 pip,可避免“python 在 venv、pip 却指向系统”。
|
||||||
|
若同目录下找不到 pip3/pip,则回退为 `<python> -m pip`。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
bin_dir = Path(python_path).resolve().parent
|
||||||
|
for name in ("pip3", "pip"):
|
||||||
|
cand = bin_dir / name
|
||||||
|
if cand.exists() and os.access(cand, os.X_OK):
|
||||||
|
return str(cand)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return f"{python_path} -m pip"
|
||||||
|
|
||||||
|
def attach_terminal_manager(self, manager: Optional["TerminalManager"]):
|
||||||
|
"""由 MainTerminal/WebTerminal 注入 TerminalManager,便于共享终端容器。"""
|
||||||
|
self._terminal_manager = manager
|
||||||
|
|
||||||
|
def _resolve_active_container_session(self) -> Optional[SimpleNamespace]:
|
||||||
|
"""
|
||||||
|
若已存在活动终端且在容器内运行,返回一个临时的容器句柄,
|
||||||
|
以便 run_command/run_python 复用同一个容器环境。
|
||||||
|
"""
|
||||||
|
manager = self._terminal_manager
|
||||||
|
if not manager:
|
||||||
|
return None
|
||||||
|
active_name = getattr(manager, "active_terminal", None)
|
||||||
|
if not active_name:
|
||||||
|
return None
|
||||||
|
terminal = manager.terminals.get(active_name) if getattr(manager, "terminals", None) else None
|
||||||
|
if not terminal or not getattr(terminal, "using_container", False):
|
||||||
|
return None
|
||||||
|
container_name = getattr(terminal, "sandbox_container_name", None)
|
||||||
|
if not container_name:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
mount_path = (terminal.sandbox_options.get("mount_path") or "/workspace").rstrip("/") or "/workspace"
|
||||||
|
except Exception:
|
||||||
|
mount_path = "/workspace"
|
||||||
|
return SimpleNamespace(mode="docker", container_name=container_name, mount_path=mount_path)
|
||||||
|
|
||||||
|
def _will_use_container(self, session_override: Optional["ContainerHandle"]) -> bool:
|
||||||
|
"""根据会话/回退策略判断此次执行是否在容器中进行。"""
|
||||||
|
if session_override:
|
||||||
|
return getattr(session_override, "mode", None) == "docker"
|
||||||
|
if self.container_session:
|
||||||
|
return getattr(self.container_session, "mode", None) == "docker"
|
||||||
|
# 未绑定容器会话时会使用工具箱容器(同样是 Docker)
|
||||||
|
return True
|
||||||
|
|
||||||
def _detect_preinstalled_python(self) -> Optional[str]:
|
def _detect_preinstalled_python(self) -> Optional[str]:
|
||||||
"""尝试定位预装虚拟环境的 python 可执行文件。"""
|
"""尝试定位预装虚拟环境的 python 可执行文件。"""
|
||||||
candidates = []
|
candidates = []
|
||||||
@ -222,11 +278,20 @@ class TerminalOperator:
|
|||||||
}
|
}
|
||||||
# 每次执行前重置工具容器(保持隔离),但下面改用一次性子进程执行,仍保留重置以兼容后续逻辑
|
# 每次执行前重置工具容器(保持隔离),但下面改用一次性子进程执行,仍保留重置以兼容后续逻辑
|
||||||
self._reset_toolbox()
|
self._reset_toolbox()
|
||||||
execution_in_container = bool(self.container_session and getattr(self.container_session, "mode", None) == "docker")
|
# 尝试复用活动终端的容器(CLI 场景与 terminal_input 环境保持一致)
|
||||||
|
session_override = None
|
||||||
|
if not self.container_session:
|
||||||
|
session_override = self._resolve_active_container_session()
|
||||||
|
# 判断本次应在容器中执行:已绑定容器会话、复用终端容器或将使用工具箱容器
|
||||||
|
execution_in_container = self._will_use_container(session_override)
|
||||||
python_rewrite = self.container_python_cmd if execution_in_container else self.python_cmd
|
python_rewrite = self.container_python_cmd if execution_in_container else self.python_cmd
|
||||||
|
pip_rewrite = self._derive_pip_from_python(python_rewrite)
|
||||||
# 统一替换 python/python3,为保障虚拟环境命中只替换独立单词
|
# 统一替换 python/python3,为保障虚拟环境命中只替换独立单词
|
||||||
if re.search(r"\bpython3?\b", command):
|
if re.search(r"\bpython3?\b", command):
|
||||||
command = re.sub(r"\bpython3?\b", python_rewrite, command)
|
command = re.sub(r"\bpython3?\b", python_rewrite, command)
|
||||||
|
# 同步替换 pip/pip3,确保指向同一环境
|
||||||
|
if re.search(r"(?<![/.\w-])pip3?\b", command):
|
||||||
|
command = re.sub(r"(?<![/.\w-])pip3?\b", pip_rewrite, command)
|
||||||
|
|
||||||
# 验证命令
|
# 验证命令
|
||||||
valid, error = self._validate_command(command)
|
valid, error = self._validate_command(command)
|
||||||
@ -254,10 +319,42 @@ class TerminalOperator:
|
|||||||
|
|
||||||
print(f"{OUTPUT_FORMATS['terminal']} 执行命令: {command}")
|
print(f"{OUTPUT_FORMATS['terminal']} 执行命令: {command}")
|
||||||
print(f"{OUTPUT_FORMATS['info']} 工作目录: {work_path}")
|
print(f"{OUTPUT_FORMATS['info']} 工作目录: {work_path}")
|
||||||
|
|
||||||
start_ts = time.time()
|
start_ts = time.time()
|
||||||
|
|
||||||
|
# 优先在绑定的容器或活动终端的容器内执行,保证与实时终端环境一致
|
||||||
|
if self.container_session or session_override:
|
||||||
|
result_payload = await self._run_command_subprocess(
|
||||||
|
command,
|
||||||
|
work_path,
|
||||||
|
timeout,
|
||||||
|
session_override=session_override
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 若未绑定用户容器,则使用工具箱容器(与终端相同镜像/预装包)
|
||||||
|
toolbox = self._get_toolbox()
|
||||||
|
payload = await toolbox.run(command, work_path, timeout)
|
||||||
|
result_payload = self._format_toolbox_output(payload)
|
||||||
|
# 追加耗时信息以对齐接口
|
||||||
|
result_payload["elapsed_ms"] = int((time.time() - start_ts) * 1000)
|
||||||
|
result_payload["timeout"] = timeout
|
||||||
|
# 字符数检查(与主流程一致)
|
||||||
|
if result_payload.get("success") and "output" in result_payload:
|
||||||
|
char_count = len(result_payload["output"])
|
||||||
|
if char_count > MAX_RUN_COMMAND_CHARS:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"结果内容过大,有{char_count}字符,请使用限制字符数的获取内容方式,根据程度选择10k以内的数",
|
||||||
|
"char_count": char_count,
|
||||||
|
"limit": MAX_RUN_COMMAND_CHARS,
|
||||||
|
"command": command
|
||||||
|
}
|
||||||
|
return result_payload
|
||||||
|
|
||||||
# 改为一次性子进程执行,确保等待到超时或命令结束
|
# 改为一次性子进程执行,确保等待到超时或命令结束
|
||||||
result_payload = await self._run_command_subprocess(command, work_path, timeout)
|
result_payload = result_payload if result_payload is not None else await self._run_command_subprocess(
|
||||||
|
command, work_path, timeout
|
||||||
|
)
|
||||||
|
|
||||||
# 字符数检查
|
# 字符数检查
|
||||||
if result_payload.get("success") and "output" in result_payload:
|
if result_payload.get("success") and "output" in result_payload:
|
||||||
@ -312,17 +409,24 @@ class TerminalOperator:
|
|||||||
result["truncated"] = payload["truncated"]
|
result["truncated"] = payload["truncated"]
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def _run_command_subprocess(self, command: str, work_path: Path, timeout: int) -> Dict:
|
async def _run_command_subprocess(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
work_path: Path,
|
||||||
|
timeout: int,
|
||||||
|
session_override: Optional["ContainerHandle"] = None
|
||||||
|
) -> Dict:
|
||||||
start_ts = time.time()
|
start_ts = time.time()
|
||||||
try:
|
try:
|
||||||
process = None
|
process = None
|
||||||
exec_cmd = None
|
exec_cmd = None
|
||||||
use_shell = True
|
use_shell = True
|
||||||
|
session = session_override or self.container_session
|
||||||
|
|
||||||
# 如果存在容器会话且模式为docker,则在容器内执行
|
# 如果存在容器会话且模式为docker,则在容器内执行
|
||||||
if self.container_session and getattr(self.container_session, "mode", None) == "docker":
|
if session and getattr(session, "mode", None) == "docker":
|
||||||
container_name = getattr(self.container_session, "container_name", None)
|
container_name = getattr(session, "container_name", None)
|
||||||
mount_path = getattr(self.container_session, "mount_path", "/workspace") or "/workspace"
|
mount_path = getattr(session, "mount_path", "/workspace") or "/workspace"
|
||||||
docker_bin = shutil.which("docker") or "docker"
|
docker_bin = shutil.which("docker") or "docker"
|
||||||
try:
|
try:
|
||||||
relative = work_path.relative_to(self.project_path).as_posix()
|
relative = work_path.relative_to(self.project_path).as_posix()
|
||||||
@ -334,6 +438,10 @@ class TerminalOperator:
|
|||||||
exec_cmd = [
|
exec_cmd = [
|
||||||
docker_bin,
|
docker_bin,
|
||||||
"exec",
|
"exec",
|
||||||
|
"-e",
|
||||||
|
"PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||||
|
"-e",
|
||||||
|
"VIRTUAL_ENV=/opt/agent-venv",
|
||||||
"-w",
|
"-w",
|
||||||
container_workdir,
|
container_workdir,
|
||||||
container_name,
|
container_name,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user