From 493ac160eb4482977e6fd7a7173a86aff61a13d2 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Fri, 31 Jul 2026 11:31:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(windows):=20=E5=B9=B3=E5=8F=B0=E9=80=82?= =?UTF-8?q?=E9=85=8D=E4=B8=8E=E7=A8=B3=E5=AE=9A=E6=80=A7=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=EF=BC=88=E8=BF=9B=E7=A8=8B=E7=BB=88=E6=AD=A2/=E7=BC=96?= =?UTF-8?q?=E7=A0=81/=E5=8E=9F=E5=AD=90=E5=86=99/=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E7=AB=9E=E6=80=81/=E5=AD=90=E6=99=BA=E8=83=BD=E4=BD=93?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 进程终止:Windows 用 CTRL_BREAK + taskkill /F /T 替代不存在的 killpg/SIGKILL, 超时/取消全路径有界;_is_pid_alive 改 ctypes(原 os.kill(pid,0) 在 Windows 会真杀进程); reader 任务 finally 收口,消除 Task was destroyed but it is pending / unclosed transport - 子智能体调度链:_run_coro 超时 60s + 失败 cancel/close 防幽灵协程;create 改先调度后提交、 失败回滚;state 归属守卫 + 新建 120s 宽限期防误标已终止;_ensure_event_loop 加锁; 子智能体 todo 改 per-agent 隔离存储,不再串到主智能体前端 - 子智能体执行环境(execution_env_text.py):创建/切换/恢复三时点注入环境说明, Windows 下 sandbox=WSL2 bash、direct=cmd;inject_notification 纯通知不触发新一轮工作, tool 序列中延迟到安全点 flush;传统子智能体按既定语义不通知 - 编码:git 侧边栏 subprocess 显式 utf-8 + errors=replace(修中文 Windows GBK _readerthread 崩溃导致侧边栏空白);check_environment/install_package 用 self.python_cmd - 安全/路径:permission.py Windows deny 列表生效 + 驱动器根拦截;路径比较统一 normcase; /tmp 白名单平台分流;正斜杠判断归一化;os-release 平台守卫;PowerShell -ExecutionPolicy Bypass - 稳定性:任务事件轮询持锁快照(修 deque mutated during iteration); 原子写新增 replace_with_retry(修 WinError 5/32 瞬时持锁); 共享文件兜底链 symlink→os.link 硬链接→copy2 - 部署:新增 _bootstrap.bat / setup.bat / start.bat Windows 启动脚本 --- _bootstrap.bat | 107 ++++++++++ core/main_terminal_parts/tools_execution.py | 9 +- main.py | 11 +- modules/background_command_manager.py | 60 +++++- modules/execution_env_text.py | 107 ++++++++++ modules/file_manager/path_mixin.py | 9 +- modules/host_workspace_manager.py | 4 +- modules/multi_agent/role_store.py | 3 +- modules/multi_agent/sub_agent_context.py | 45 ++--- modules/shallow_versioning.py | 5 +- modules/sub_agent/manager.py | 205 +++++++++++++++++--- modules/sub_agent/prompts.py | 11 +- modules/sub_agent/state.py | 18 ++ modules/sub_agent/task.py | 62 +++++- modules/terminal_ops/run.py | 114 +++++++---- modules/user_manager.py | 10 + prompts/sub_agent/system.txt | 4 +- server/api_v1.py | 3 +- server/chat/permission.py | 44 ++++- server/status/git.py | 9 + server/tasks/api.py | 3 +- server/tasks/models.py | 11 ++ setup.bat | 31 +++ start.bat | 44 +++++ utils/atomic_io.py | 46 +++++ utils/context_manager/runtime_mixin.py | 4 +- utils/conversation_manager/crud_mixin.py | 4 +- utils/conversation_manager/index_mixin.py | 6 +- utils/media_store.py | 8 +- 29 files changed, 868 insertions(+), 129 deletions(-) create mode 100644 _bootstrap.bat create mode 100644 modules/execution_env_text.py create mode 100644 setup.bat create mode 100644 start.bat create mode 100644 utils/atomic_io.py diff --git a/_bootstrap.bat b/_bootstrap.bat new file mode 100644 index 00000000..ae0c6b63 --- /dev/null +++ b/_bootstrap.bat @@ -0,0 +1,107 @@ +@echo off +rem 共享引导逻辑:被 setup.bat / start.bat 通过 call 调用(对应 bash 版 _bootstrap.sh 的 source)。 +rem 负责定位项目根、准备 Python venv 与依赖、准备 Node 依赖。 +rem +rem 用法: call _bootstrap.bat ^<函数名^> +rem ensure_python_env 准备虚拟环境与 Python 依赖 +rem ensure_node_env 准备 Node 依赖(easyagent / 前端构建,可选) +rem has_env_file .env 存在返回 0,否则返回 1 +rem +rem 说明:本脚本故意不使用 setlocal,使 ROOT/VENV_DIR/VENV_PY 等变量 +rem 保留在调用者环境中(与 bash source 语义一致)。 + +rem 项目根目录 = 本脚本所在目录(%~dp0 自带末尾反斜杠,去掉)。 +set "ROOT=%~dp0" +if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%" +set "VENV_DIR=%ROOT%\.venv" +set "VENV_PY=%VENV_DIR%\Scripts\python.exe" + +if /i "%~1"=="ensure_python_env" goto :ensure_python_env +if /i "%~1"=="ensure_node_env" goto :ensure_node_env +if /i "%~1"=="has_env_file" goto :has_env_file +echo [error] _bootstrap.bat: 未知命令 "%~1" 1>&2 +exit /b 1 + +rem --------------------------------------------------------------- +rem 准备虚拟环境与 Python 依赖(依赖系统已装 Python,不内置解释器)。 +rem 已存在 venv 则复用;缺失则创建并安装 requirements.lock.txt(优先)或 requirements.txt。 +:ensure_python_env +if exist "%VENV_PY%" goto :venv_ready + +rem 选择系统 Python:python -> python3 -> py -3。 +rem 用 --version 探测而不是 where,可跳过 WindowsApps 的商店占位 stub。 +set "SYS_PY=" +python --version >nul 2>&1 && set "SYS_PY=python" +if not defined SYS_PY ( python3 --version >nul 2>&1 && set "SYS_PY=python3" ) +if not defined SYS_PY ( py -3 --version >nul 2>&1 && set "SYS_PY=py -3" ) +if not defined SYS_PY ( + echo [error] 未找到系统 Python(需要 python 或 python3)。请先安装 Python 3.9+。 1>&2 + exit /b 1 +) +echo [setup] 使用 %SYS_PY% 创建虚拟环境:%VENV_DIR% +%SYS_PY% -m venv "%VENV_DIR%" +if errorlevel 1 exit /b 1 + +:venv_ready +echo [setup] 升级 pip 并安装依赖... +"%VENV_PY%" -m pip install --upgrade pip >nul +if errorlevel 1 exit /b 1 +if exist "%ROOT%\requirements.lock.txt" ( + "%VENV_PY%" -m pip install -r "%ROOT%\requirements.lock.txt" +) else if exist "%ROOT%\requirements.txt" ( + "%VENV_PY%" -m pip install -r "%ROOT%\requirements.txt" +) else ( + echo [error] 找不到 requirements.lock.txt 或 requirements.txt 1>&2 + exit /b 1 +) +if errorlevel 1 exit /b 1 +exit /b 0 + +rem --------------------------------------------------------------- +rem 准备 Node 依赖(依赖系统已装 Node;不内置 Node)。 +rem easyagent 仅需运行时依赖;前端需要构建产物(vite build)。 +:ensure_node_env +node --version >nul 2>&1 +if errorlevel 1 ( + echo [warn] 未找到系统 Node。子智能体(easyagent)与前端构建将不可用。 1>&2 + echo 如需完整功能,请安装 Node.js 18+ 后重跑。 1>&2 + exit /b 0 +) +call npm --version >nul 2>&1 +if errorlevel 1 ( + echo [warn] 找到 node 但未找到 npm,跳过 Node 依赖安装。 1>&2 + exit /b 0 +) + +rem easyagent 运行时依赖 +if not exist "%ROOT%\easyagent\package.json" goto :easyagent_done +if exist "%ROOT%\easyagent\node_modules" goto :easyagent_done +echo [setup] 安装 easyagent 依赖(npm ci)... +pushd "%ROOT%\easyagent" +call npm ci +if errorlevel 1 goto :npm_fail +popd +:easyagent_done + +rem 前端构建产物。仅在缺失时构建,避免每次启动都跑。 +if not exist "%ROOT%\package.json" goto :frontend_done +if exist "%ROOT%\node_modules" goto :frontend_done +echo [setup] 安装前端依赖并构建(npm ci ^&^& npm run build)... +pushd "%ROOT%" +call npm ci +if errorlevel 1 goto :npm_fail +call npm run build +if errorlevel 1 goto :npm_fail +popd +:frontend_done +exit /b 0 + +:npm_fail +popd +exit /b 1 + +rem --------------------------------------------------------------- +rem .env 是否存在(首次启动判断依据)。 +:has_env_file +if exist "%ROOT%\.env" exit /b 0 +exit /b 1 diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index edd96f05..f95f8f0d 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -1915,7 +1915,8 @@ class MainTerminalToolsExecutionMixin: _data_dir = str(getattr(self, "data_dir", "")) _custom_dir = infer_custom_roles_dir(_data_dir) # host模式下 custom_dir 就是 runtime_dir;web模式下 runtime_dir 指向 web 预设目录 - _is_web = '/web/users/' in _data_dir + # Windows 下 data_dir 用反斜杠,统一归一化再匹配 + _is_web = '/web/users/' in _data_dir.replace("\\", "/") _runtime_dir = WEB_PRESET_ROLES_DIR if _is_web else _custom_dir role = load_preset_role(role_id, runtime_dir=_runtime_dir, custom_dir=_custom_dir) if not role: @@ -2145,7 +2146,8 @@ class MainTerminalToolsExecutionMixin: _data_dir = str(getattr(self, "data_dir", "")) from modules.multi_agent.role_store import infer_custom_roles_dir _custom_dir = infer_custom_roles_dir(_data_dir) - _is_web = '/web/users/' in _data_dir + # Windows 下 data_dir 用反斜杠,统一归一化再匹配 + _is_web = '/web/users/' in _data_dir.replace("\\", "/") _runtime_dir = None if _is_web else _custom_dir existing_ids = {r.role_id for r in list_roles(runtime_dir=_runtime_dir, custom_dir=_custom_dir)} if role_id in existing_ids: @@ -2162,7 +2164,8 @@ class MainTerminalToolsExecutionMixin: from modules.multi_agent.role_store import list_roles, infer_custom_roles_dir _data_dir = str(getattr(self, "data_dir", "")) _custom_dir = infer_custom_roles_dir(_data_dir) - _is_web = '/web/users/' in _data_dir + # Windows 下 data_dir 用反斜杠,统一归一化再匹配 + _is_web = '/web/users/' in _data_dir.replace("\\", "/") _runtime_dir = None if _is_web else _custom_dir roles = list_roles(runtime_dir=_runtime_dir, custom_dir=_custom_dir) result = {"success": True, "roles": [r.to_dict() for r in roles]} diff --git a/main.py b/main.py index fe12ce18..f2759f9f 100644 --- a/main.py +++ b/main.py @@ -203,17 +203,20 @@ class AgentSystem: def is_unsafe_path(self, path: str) -> bool: """检查路径是否安全""" - resolved_path = str(Path(path).resolve()) + # Windows 路径大小写不敏感(c:\windows 与 C:\Windows 同义), + # 统一 normcase 后再比较,避免小写路径绕过禁用列表 + resolved_path = os.path.normcase(str(Path(path).resolve())) # 检查是否是根路径 for forbidden_root in FORBIDDEN_ROOT_PATHS: - expanded = os.path.expanduser(forbidden_root) - if resolved_path == expanded or resolved_path == forbidden_root: + expanded = os.path.normcase(os.path.expanduser(forbidden_root)) + if resolved_path == expanded or resolved_path == os.path.normcase(forbidden_root): return True # 检查是否在系统目录 for forbidden in FORBIDDEN_PATHS: - if resolved_path.startswith(forbidden + os.sep) or resolved_path == forbidden: + forbidden_norm = os.path.normcase(forbidden) + if resolved_path.startswith(forbidden_norm + os.sep) or resolved_path == forbidden_norm: return True # 检查是否包含向上遍历 diff --git a/modules/background_command_manager.py b/modules/background_command_manager.py index ae3820bb..3711d055 100644 --- a/modules/background_command_manager.py +++ b/modules/background_command_manager.py @@ -321,21 +321,19 @@ class BackgroundCommandManager: except subprocess.TimeoutExpired: status = "timeout" message = f"命令执行超时 ({timeout}秒)" - try: - os.killpg(process.pid, signal.SIGINT) - except Exception: - pass + # 跨平台终止:POSIX killpg(SIGINT→SIGKILL),Windows CTRL_BREAK→taskkill + self._terminate_pid(process.pid) try: process.wait(timeout=2) except subprocess.TimeoutExpired: try: - os.killpg(process.pid, signal.SIGKILL) + process.kill() except Exception: - try: - process.kill() - except Exception: - pass - process.wait(timeout=2) + pass + try: + process.wait(timeout=2) + except Exception: + pass return_code = process.returncode if process.returncode is not None else -1 t_out.join(timeout=1) @@ -400,6 +398,26 @@ class BackgroundCommandManager: normalized = self._coerce_pid(pid) if not normalized: return False + if os.name == "nt": + # Windows 上 os.kill(pid, 0) 不是“存活探测”,非 CTRL 事件信号会直接 + # TerminateProcess 杀掉被检查的进程!必须用 OpenProcess 查询。 + try: + import ctypes + kernel32 = ctypes.windll.kernel32 + # PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + handle = kernel32.OpenProcess(0x1000, False, normalized) + if not handle: + return False + try: + exit_code = ctypes.c_ulong(0) + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)): + return False + # STILL_ACTIVE = 259 + return exit_code.value == 259 + finally: + kernel32.CloseHandle(handle) + except Exception: + return False try: os.kill(normalized, 0) except ProcessLookupError: @@ -414,6 +432,28 @@ class BackgroundCommandManager: normalized = self._coerce_pid(pid) if not normalized: return False + if os.name == "nt": + # Windows:无 killpg/SIGKILL。先尝试 CTRL_BREAK_EVENT 优雅中断 + # (子进程以 start_new_session=True 启动,即独立进程组), + # 超时后用 taskkill /F /T 强制终止整棵进程树,避免孙进程残留。 + try: + os.kill(normalized, signal.CTRL_BREAK_EVENT) + except Exception: + pass + deadline = time.time() + 2.0 + while time.time() < deadline: + if not self._is_pid_alive(normalized): + return True + time.sleep(0.05) + try: + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(normalized)], + capture_output=True, + timeout=5, + ) + except Exception: + pass + return not self._is_pid_alive(normalized) try: os.killpg(normalized, signal.SIGINT) except Exception: diff --git a/modules/execution_env_text.py b/modules/execution_env_text.py new file mode 100644 index 00000000..c4150157 --- /dev/null +++ b/modules/execution_env_text.py @@ -0,0 +1,107 @@ +"""子智能体执行环境说明文本(精简版,三时点共用)。 + +主智能体的完整版见 core/main_terminal_parts/context/mode.py +(_build_windows_environment_rules 等);本模块面向子智能体场景: + +- 创建时:注入系统提示词的环境段(build_sub_agent_env_section) +- 切换时:注入运行中子智能体的上下文通知(build_sub_agent_mode_switch_notice) +- 恢复时:任务从磁盘恢复后的环境告知(build_sub_agent_restore_notice) + +注意:子智能体没有切换执行环境的入口,因此文本中不要出现 +「请求用户切换执行环境」之类的引导,改为「在报告中说明」。 +""" +from __future__ import annotations + +import platform + + +def _is_windows() -> bool: + return platform.system() == "Windows" + + +def _normalize_mode(execution_mode: str) -> str: + return "direct" if str(execution_mode or "").strip().lower() == "direct" else "sandbox" + + +def build_sub_agent_env_section(workspace_path: str, execution_mode: str) -> str: + """创建/恢复时注入系统提示词的执行环境段。 + + execution_mode: "sandbox" / "direct"(其他值按 sandbox 处理)。 + workspace_path 为宿主机视角路径(Windows 下为反斜杠路径)。 + """ + mode = _normalize_mode(execution_mode) + ws = str(workspace_path or "").strip() or "<工作区>" + + if _is_windows(): + if mode == "direct": + return ( + "## 执行环境\n\n" + "- 运行环境:Windows 宿主机,直接执行(完全访问权限)\n" + "- 命令解释器:Windows cmd(可用 dir、type、findstr 及系统已安装的程序;\n" + " ls/grep/find/sed 等 Linux 命令与 bash 语法不可用)\n" + f"- 工作区路径:{ws}(Windows 风格,引号内注意反斜杠转义)\n" + "- 仅在必须时执行高权限操作;涉及删除/覆盖/系统级变更前,在报告中说明风险\n" + ) + return ( + "## 执行环境\n\n" + "- 运行环境:Windows 宿主机,WSL2 Linux 沙箱执行\n" + "- 命令解释器:Linux bash(可用 ls、cat、grep、find、python3 等 Linux 工具;\n" + " cmd、powershell、.exe 等 Windows 程序不可用)\n" + f"- 工作区路径:{ws}(Windows 视角);命令的工作目录已默认落在沙箱内对应 Linux 路径,\n" + " 工作区内操作请直接使用相对路径\n" + "- 引用其他 Windows 路径必须转换:盘符小写、反斜杠变正斜杠,\n" + " 例如 D:\\tools\\a.txt → /mnt/d/tools/a.txt;bash 会把反斜杠当作转义符\n" + "- 工作区外全部只读(写入报 Read-only file system),这是刻意设置的边界,\n" + " 不要尝试绕过;无法满足时在报告中说明\n" + ) + + # macOS / Linux:沙箱与直接执行同为 POSIX shell,仅权限边界不同 + if mode == "direct": + return ( + "## 执行环境\n\n" + "- 运行环境:宿主机直接执行(完全访问权限)\n" + "- 命令解释器:POSIX shell(可用 ls、cat、grep、find 等)\n" + "- 仅在必须时执行高权限操作;涉及删除/覆盖/系统级变更前,在报告中说明风险\n" + ) + return ( + "## 执行环境\n\n" + "- 运行环境:宿主机,系统 OS 沙箱执行\n" + "- 命令解释器:POSIX shell(可用 ls、cat、grep、find 等)\n" + "- 若操作受系统权限限制:这是用户刻意设置的边界。先提供安全替代方案;\n" + " 仍无法满足时在报告中说明,不要尝试绕过\n" + ) + + +def build_sub_agent_mode_switch_notice(execution_mode: str) -> str: + """执行环境切换通知正文(运行中以 user 消息注入,纯上下文,不触发新一轮工作)。""" + mode = _normalize_mode(execution_mode) + label = "完全访问权限" if mode == "direct" else "沙箱" + + if _is_windows(): + if mode == "direct": + detail = ( + "你后续的终端命令将改为在 Windows cmd 中执行,请立即调整命令写法:\n" + "- 使用 dir、type、findstr 等 cmd 命令;ls/grep/find 等 Linux 命令与 bash 语法不再可用\n" + "- 路径改用 Windows 风格(如 E:\\proj\\file.txt)\n" + "- 此前按 Linux 环境得到的结论(路径、工具可用性)可能不再适用" + ) + else: + detail = ( + "你后续的终端命令将改为在 WSL2 Linux 沙箱中以 bash 执行,请立即调整命令写法:\n" + "- 使用 ls、cat、grep 等 Linux 命令;cmd、powershell、.exe 等 Windows 程序不再可用\n" + "- 工作区内操作请用相对路径;其他 Windows 路径按 D:\\a\\b → /mnt/d/a/b 转换\n" + "- 工作区外只读,写入报 Read-only file system 属预期边界,不要绕过\n" + "- 此前按 Windows 环境得到的结论(路径、工具可用性)可能不再适用" + ) + return f"执行环境已被切换为:{label}。\n{detail}" + + return f"执行环境已被切换为:{label}(命令语言不变,仍为 POSIX shell,注意权限边界变化)。" + + +def build_sub_agent_restore_notice(workspace_path: str, execution_mode: str) -> str: + """任务从磁盘恢复后的环境告知正文(恢复时以 user 消息注入,不触发新一轮工作)。""" + section = build_sub_agent_env_section(workspace_path, execution_mode) + return ( + "任务已从持久化状态恢复运行。以下是你当前的实际执行环境" + "(可能与任务创建时不同,以本说明为准):\n\n" + section + ) diff --git a/modules/file_manager/path_mixin.py b/modules/file_manager/path_mixin.py index a15113e3..b45341f2 100644 --- a/modules/file_manager/path_mixin.py +++ b/modules/file_manager/path_mixin.py @@ -154,7 +154,14 @@ class PathMixin: return False def _host_allowed_roots(self, access: str) -> List[Path]: - roots: List[Path] = [self.project_path.resolve(), Path("/tmp").resolve(), Path("/private/tmp").resolve()] + # 临时目录白名单按平台分流:POSIX 用 /tmp、/private/tmp; + # Windows 用系统临时目录(Path("/tmp") 在 Windows 会解析为 C:\tmp,语义错误) + if os.name == "nt": + import tempfile + temp_roots = [Path(tempfile.gettempdir()).resolve()] + else: + temp_roots = [Path("/tmp").resolve(), Path("/private/tmp").resolve()] + roots: List[Path] = [self.project_path.resolve(), *temp_roots] raw_items = get_macos_writable_paths() if access == "write" else get_macos_readable_paths() for raw in raw_items: try: diff --git a/modules/host_workspace_manager.py b/modules/host_workspace_manager.py index cd933f3c..227b38eb 100644 --- a/modules/host_workspace_manager.py +++ b/modules/host_workspace_manager.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, Union from config import HOST_WORKSPACES_FILE +from utils.atomic_io import replace_with_retry _WORKSPACE_ID_RE = re.compile(r"^[a-zA-Z0-9._-]{1,64}$") _REPO_ROOT = Path(__file__).resolve().parents[1] @@ -43,7 +44,8 @@ def _atomic_write_json(path: Path, data: Dict[str, Any]) -> None: json.dump(data, fp, ensure_ascii=False, indent=2) fp.flush() os.fsync(fp.fileno()) - os.replace(tmp_path, path) + # Windows 瞬时持锁(并发读取/杀软扫描)重试,POSIX 行为不变 + replace_with_retry(tmp_path, path) finally: try: if os.path.exists(tmp_path): diff --git a/modules/multi_agent/role_store.py b/modules/multi_agent/role_store.py index a8476312..b0ad53a0 100644 --- a/modules/multi_agent/role_store.py +++ b/modules/multi_agent/role_store.py @@ -62,7 +62,8 @@ def infer_custom_roles_dir(data_dir: str | Path | None) -> Optional[Path]: if not data_dir: return _host_runtime_dir() data_path = str(Path(data_dir).expanduser().resolve()) - if "/web/users/" in data_path: + # Windows resolve 后路径用反斜杠,统一归一化再匹配 + if "/web/users/" in data_path.replace("\\", "/"): # web 模式:按用户隔离 dp = Path(data_path).resolve() # users//projects//data -> users//personal/mutiagents/agents diff --git a/modules/multi_agent/sub_agent_context.py b/modules/multi_agent/sub_agent_context.py index 37f83919..2c30dcb1 100644 --- a/modules/multi_agent/sub_agent_context.py +++ b/modules/multi_agent/sub_agent_context.py @@ -59,38 +59,25 @@ def _load_agents_md(workspace_path: str) -> Optional[str]: def _build_execution_env_section(workspace_path: str, *, sandbox_mode: str = "") -> str: - """构建执行环境信息段(精简版,只含宿主机/docker + 沙箱/直接)。""" + """构建执行环境信息段。 + + 宿主机模式:复用子智能体共享环境文本(平台细分:Windows 下 sandbox=WSL2 bash、 + direct=cmd,mac/Linux 同为 POSIX shell),创建时快照,切换由运行期通知补充。 + Docker 模式:保持容器语义描述。 + """ mode = (sandbox_mode or TERMINAL_SANDBOX_MODE or "").lower() if IS_HOST_MODE: - runtime_label = "宿主机模式" - # host 模式下区分 sandbox / direct - if mode == "direct": - exec_label = "直接执行(完全访问权限)" - rules = ( - "- 当前为宿主机直接执行模式\n" - "- 仅在必须时执行高权限操作,保持最小化命令范围\n" - "- 涉及删除/覆盖/系统级变更前,先说明风险再执行" - ) - else: - exec_label = "沙箱执行" - rules = ( - "- 所有命令默认在系统 OS 沙箱中执行\n" - "- 若操作受系统权限限制:先提供安全替代方案;若仍无法满足,说明需要更高权限\n" - "- 不要通过复杂绕过手段规避沙箱限制" - ) - else: - runtime_label = "Docker 容器模式" - exec_label = "容器内执行" - rules = ( - "- 所有命令在 Docker 容器内执行\n" - "- 工作区挂载在容器内 /workspace 路径下\n" - "- 网络可能受限,仅允许 localhost" - ) + from modules.execution_env_text import build_sub_agent_env_section + + return build_sub_agent_env_section(workspace_path, mode) return ( - f"## 执行环境\n\n" - f"- 运行环境:{runtime_label}\n" - f"- 执行方式:{exec_label}\n\n" - f"### 当前规则\n{rules}\n" + "## 执行环境\n\n" + "- 运行环境:Docker 容器模式\n" + "- 执行方式:容器内执行\n\n" + "### 当前规则\n" + "- 所有命令在 Docker 容器内执行\n" + "- 工作区挂载在容器内 /workspace 路径下\n" + "- 网络可能受限,仅允许 localhost\n" ) diff --git a/modules/shallow_versioning.py b/modules/shallow_versioning.py index 98462dc6..48ee8489 100644 --- a/modules/shallow_versioning.py +++ b/modules/shallow_versioning.py @@ -39,6 +39,8 @@ from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Set +from utils.atomic_io import replace_with_retry + class ShallowVersioningError(RuntimeError): """Raised when shallow versioning fails.""" @@ -151,7 +153,8 @@ class ShallowVersioningManager: for snapshot in self._snapshots ) tmp_path.write_text(payload, encoding="utf-8") - os.replace(tmp_path, self.state_file) + # Windows 瞬时持锁(并发读取/杀软扫描)重试,POSIX 行为不变 + replace_with_retry(tmp_path, self.state_file) def _upsert_snapshot(self, snapshot: ShallowSnapshot) -> None: """Insert or replace the snapshot for its message_id in memory. diff --git a/modules/sub_agent/manager.py b/modules/sub_agent/manager.py index a180c5a9..3bfe4f26 100644 --- a/modules/sub_agent/manager.py +++ b/modules/sub_agent/manager.py @@ -9,6 +9,7 @@ from __future__ import annotations import asyncio import json +import shutil import threading import time from pathlib import Path, PurePosixPath @@ -81,7 +82,13 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi self._running_tasks: Dict[str, asyncio.Task] = {} self._event_loop: Optional[asyncio.AbstractEventLoop] = None self._loop_thread: Optional[threading.Thread] = None + # _ensure_event_loop 并发创建保护(并发 create_sub_agent 时避免创建多个事件循环) + self._loop_lock = threading.Lock() self._state_lock = threading.Lock() + # 子智能体隔离待办存储:agent_id -> todo dict。 + # 子智能体的 todo_create/todo_update_task 不得写入主智能体的 todo_list, + # 否则会串到主对话前端快捷菜单显示。 + self._sub_agent_todos: Dict[int, Dict[str, Any]] = {} # agent_id -> SubAgentTask 映射(供多智能体消息注入使用) self._sub_agent_instances: Dict[int, Any] = {} @@ -103,35 +110,54 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi """确保有一个独立的后台事件循环供子智能体使用。""" if self._event_loop is not None and not self._event_loop.is_closed(): return self._event_loop + # 加锁 + 双重检查:并发 create_sub_agent 时避免创建多个事件循环, + # 导致任务散落在不同 loop 上、状态回调与恢复逻辑错乱 + with self._loop_lock: + if self._event_loop is not None and not self._event_loop.is_closed(): + return self._event_loop - loop = asyncio.new_event_loop() - self._event_loop = loop + loop = asyncio.new_event_loop() + self._event_loop = loop - def run_loop(): - asyncio.set_event_loop(loop) - try: - loop.run_forever() - finally: + def run_loop(): + asyncio.set_event_loop(loop) try: - loop.close() - except Exception: - pass + loop.run_forever() + finally: + try: + loop.close() + except Exception: + pass - thread = threading.Thread(target=run_loop, name="sub-agent-loop", daemon=True) - thread.start() - self._loop_thread = thread - return loop + thread = threading.Thread(target=run_loop, name="sub-agent-loop", daemon=True) + thread.start() + self._loop_thread = thread + return loop async def _create_task(self, coro): """在事件循环内部把协程包装为 Task。""" return asyncio.create_task(coro) def _run_coro(self, coro): - """在后台事件循环中调度一个协程并返回 asyncio.Task。""" + """在后台事件循环中调度一个协程并返回 asyncio.Task。 + + 调度失败(如循环线程繁忙超时)时必须取消提交并关闭协程, + 否则协程可能在循环空闲后被“幽灵执行”,而调用方已按失败处理, + 造成状态不一致(曾实测:超时返回失败后任务仍运行并交付结果)。 + """ loop = self._ensure_event_loop() # 先提交创建 Task 的协程,阻塞等待拿到 Task 句柄 future = asyncio.run_coroutine_threadsafe(self._create_task(coro), loop) - return future.result(timeout=10) + try: + return future.result(timeout=60) + except Exception: + # 取消尚未执行的调度请求,并关闭协程避免 never-awaited/幽灵执行 + future.cancel() + try: + coro.close() + except Exception: + pass + raise def set_terminal(self, terminal: "WebTerminal") -> None: """注入主终端引用,用于工具执行代理。""" @@ -143,7 +169,54 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi def set_host_execution_mode(self, mode: str) -> None: normalized = str(mode or "").strip().lower() - self.host_execution_mode = "direct" if normalized == "direct" else "sandbox" + target = "direct" if normalized == "direct" else "sandbox" + changed = target != self.host_execution_mode + self.host_execution_mode = target + if changed: + # 存活子智能体的工具调用走主终端工具链,脚下环境已实时切换; + # 注入纯上下文通知告知「语言」变化(Windows 下 bash↔cmd),不触发新一轮工作。 + try: + self.notify_execution_mode_changed(target) + except Exception: + logger.exception("[SubAgent] 执行环境变更通知失败") + + def notify_execution_mode_changed(self, mode: str) -> int: + """执行环境切换后,向存活的多智能体子智能体注入上下文通知。 + + 纯通知语义(task.inject_notification):不唤醒 idle、不触发新一轮工作, + 运行中的子智能体在下一轮模型调用前的安全点看到。 + 传统子智能体按既定语义不通知(提示词为创建时快照,任务周期短)。 + 返回成功注入的子智能体数。 + """ + normalized = str(mode or "").strip().lower() + if normalized not in {"sandbox", "direct"}: + return 0 + from modules.execution_env_text import build_sub_agent_mode_switch_notice + + text = "[系统通知|执行环境变更]\n" + build_sub_agent_mode_switch_notice(normalized) + target_cid = ( + getattr(getattr(self.terminal, "context_manager", None), "current_conversation_id", None) + or getattr(self, "owner_conversation_id", None) + ) + injected = 0 + for inst in list(self._sub_agent_instances.values()): + try: + if not getattr(inst, "multi_agent_mode", False): + continue + inst_task = getattr(inst, "_task", None) + if inst_task is None or inst_task.done(): + continue + record = self.tasks.get(getattr(inst, "task_id", "")) or {} + record_cid = record.get("conversation_id") + if target_cid and record_cid and record_cid != target_cid: + continue + inst.inject_notification(text) + injected += 1 + except Exception: + logger.exception("[SubAgent] 注入执行环境变更通知失败") + if injected: + logger.info(f"[SubAgent] 执行环境切换为 {normalized},已通知 {injected} 个多智能体子智能体") + return injected # ------------------------------------------------------------------ # 公共方法 @@ -204,6 +277,8 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi try: deliverables_path = self._resolve_deliverables_dir(deliverables_dir, multi_agent_mode=multi_agent_mode) except ValueError as exc: + # 回滚已创建的任务目录,避免残留状态(交付目录校验失败时 deliverables 不会创建) + shutil.rmtree(task_root, ignore_errors=True) return {"success": False, "error": str(exc)} task_file = task_root / "task.txt" @@ -225,7 +300,8 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi if system_prompt: final_system_prompt = system_prompt else: - final_system_prompt = build_system_prompt(prompt_workspace) + # 快照当前执行环境写入提示词;后续切换由 notify_execution_mode_changed 补充告知 + final_system_prompt = build_system_prompt(prompt_workspace, execution_mode=self.host_execution_mode) system_prompt_file.write_text(final_system_prompt, encoding="utf-8") # timeout_seconds 为 None 表示永久子智能体(不会被时间终结) @@ -255,11 +331,10 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi "compress_threshold_tokens": compress_threshold_tokens, "container_name": None, } - self.tasks[task_id] = task_record - self._mark_agent_id_used(conversation_id, agent_id) - self._save_state() - # 多智能体模式:为该会话创建或复用 MultiAgentState + # 注意:状态提交(self.tasks / _mark_agent_id_used / _save_state)必须在 + # 调度成功之后进行,否则调度失败会留下幽灵记录,且 reconcile 会在 + # “记录已存在但 _running_tasks 无句柄”的窗口期把任务误标为 terminated multi_agent_state = None if multi_agent_mode: multi_agent_state = self.get_or_create_multi_agent_state(conversation_id) @@ -276,6 +351,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi try: multi_agent_state.register_instance(inst) except ValueError: + shutil.rmtree(task_root, ignore_errors=True) return {"success": False, "error": f"agent_id {agent_id} 已在该会话中使用"} sub_agent = SubAgentTask( @@ -297,15 +373,39 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi state_id=id(multi_agent_state) if multi_agent_state else None, ) task_coro = sub_agent.run() - asyncio_task = self._run_coro(task_coro) + try: + asyncio_task = self._run_coro(task_coro) + except Exception as exc: + # 调度失败:完整回滚已创建的资源,避免幽灵任务/残留状态 + try: + task_coro.close() + except Exception: + pass + if multi_agent_state is not None: + try: + multi_agent_state.agents.pop(agent_id, None) + multi_agent_state.task_id_to_agent_id.pop(task_id, None) + except Exception: + pass + shutil.rmtree(task_root, ignore_errors=True) + logger.exception(f"[SubAgent] 子智能体调度失败: agent_id={agent_id}, task_id={task_id}") + return {"success": False, "error": f"子智能体调度失败(事件循环繁忙),请稍后重试: {exc}"} + + # 调度成功后再提交状态:self.tasks 记录与 _running_tasks 句柄同步出现, + # reconcile_task_states 任何时刻看到该记录都能拿到运行句柄,不会误标 terminated + self.tasks[task_id] = task_record + self._mark_agent_id_used(conversation_id, agent_id) sub_agent._task = asyncio_task self._running_tasks[task_id] = asyncio_task # 缓存 sub_agent 实例供给多智能体模式 Poli注入使用 self._sub_agent_instances[agent_id] = sub_agent + self._save_state() def _on_done(fut): try: self._running_tasks.pop(task_id, None) + # 清理该子智能体的隔离待办存储 + self._sub_agent_todos.pop(agent_id, None) # 多智能体模式下 failed 视为可复活状态,保留实例引用供后续 send_message_to_sub_agent 重新激活 if multi_agent_mode: final_task = self.tasks.get(task_id) or {} @@ -845,11 +945,55 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi # ------------------------------------------------------------------ # 工具执行代理 # ------------------------------------------------------------------ - async def execute_tool_for_sub_agent(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: + def _execute_sub_agent_todo(self, tool_name: str, arguments: Dict[str, Any], agent_id: Optional[int]) -> Dict[str, Any]: + """子智能体待办工具:写入该 agent 隔离的存储。 + + 子智能体的 todo_create/todo_update_task 不得写入主智能体的 todo_list, + 否则会覆盖主对话的待办列表并串到前端快捷菜单显示。 + """ + from modules.todo_manager import TodoManager + + class _TodoContext: + """适配 TodoManager 所需的最小 context_manager 接口。""" + def __init__(self, store: Dict[str, Any]): + self._store = store + + @property + def todo_list(self): + return self._store.get("todo") + + def set_todo_list(self, todo): + self._store["todo"] = todo + + key = agent_id if agent_id is not None else -1 + store = self._sub_agent_todos.setdefault(key, {}) + todo_manager = TodoManager(_TodoContext(store)) + if tool_name == "todo_create": + return todo_manager.create_todo_list( + overview=arguments.get("overview", ""), + tasks=arguments.get("tasks", []), + ) + if tool_name == "todo_update_task": + task_indices = arguments.get("task_indices") + if task_indices is None: + task_indices = arguments.get("task_index") + return todo_manager.update_task_status( + task_indices=task_indices, + completed=arguments.get("completed", True), + ) + if tool_name == "todo_get": + return {"success": True, "todo_list": todo_manager.get_snapshot()} + return {"success": False, "error": f"未知待办工具: {tool_name}"} + + async def execute_tool_for_sub_agent(self, tool_name: str, arguments: Dict[str, Any], agent_id: Optional[int] = None) -> Dict[str, Any]: """代表子智能体在主进程中执行工具。""" if not self.terminal: return {"success": False, "error": "子智能体管理器未绑定终端,无法执行工具"} + # 待办工具走子智能体隔离存储,避免覆盖主智能体待办并串到前端显示 + if tool_name in {"todo_create", "todo_update_task", "todo_get"}: + return self._execute_sub_agent_todo(tool_name, arguments, agent_id) + try: # 多智能体模式常见问答工具已在 SubAgentTask._execute_multi_agent_tool 中处理 # 这里只处理实际通过主进程执行的工具 @@ -993,6 +1137,19 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi except Exception as exc: logger.warning(f"[restore] 更新任务 {task_id} output 文件失败: {exc}") + # 恢复的任务提示词是创建时快照,执行环境可能已变化: + # 注入当前环境告知(纯上下文不触发工作;排序安全由 inject_notification 保证) + try: + from modules.execution_env_text import build_sub_agent_restore_notice + + sub_agent.inject_notification( + "[系统通知|执行环境]\n" + build_sub_agent_restore_notice( + self._get_runtime_path(self.project_path), self.host_execution_mode + ) + ) + except Exception: + logger.warning(f"[restore] 任务 {task_id} 注入执行环境告知失败", exc_info=True) + task_coro = sub_agent.run() asyncio_task = self._run_coro(task_coro) sub_agent._task = asyncio_task diff --git a/modules/sub_agent/prompts.py b/modules/sub_agent/prompts.py index 2057466f..65daf13e 100644 --- a/modules/sub_agent/prompts.py +++ b/modules/sub_agent/prompts.py @@ -63,8 +63,14 @@ def build_user_message( ) -def build_system_prompt(workspace_path: str) -> str: - """构建子智能体的系统提示。""" +def build_system_prompt(workspace_path: str, execution_mode: str = "") -> str: + """构建子智能体的系统提示。 + + `execution_mode` 为创建时刻的执行环境(sandbox/direct),快照写入提示词; + 后续若发生切换,由运行期通知机制补充告知(见 manager.notify_execution_mode_changed)。 + """ + from modules.execution_env_text import build_sub_agent_env_section + system_info = f"{platform.system()} {platform.release()}" current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") return _format_template( @@ -72,4 +78,5 @@ def build_system_prompt(workspace_path: str) -> str: workspace_path=workspace_path, system_info=system_info, current_time=current_time, + execution_env_section=build_sub_agent_env_section(workspace_path, execution_mode), ) diff --git a/modules/sub_agent/state.py b/modules/sub_agent/state.py index 3fba47ea..73f38644 100644 --- a/modules/sub_agent/state.py +++ b/modules/sub_agent/state.py @@ -531,6 +531,24 @@ class SubAgentStateMixin: ma_debug("refresh_task_runtime_state_idle_no_output_file", task_id=task_id) return {"status": "idle", "task_id": task_id} + # 归属守卫:对话级任务只由所属对话的 manager 做终态判定。 + # 其他 manager(工作区级或同工作区其他对话的 manager)本地没有该任务的 + # 运行句柄,继续往下走会把正在运行的任务误标为 terminated(吸收态,不可逆)。 + owner_cid = getattr(self, "owner_conversation_id", None) + task_cid = task.get("conversation_id") + if task_cid and task_cid != owner_cid: + ma_debug("refresh_task_runtime_state_skip_foreign_task", task_id=task_id, task_cid=task_cid, owner_cid=owner_cid) + return {"status": status, "task_id": task_id} + + # 调度窗口宽限:新创建任务的 asyncio.Task 可能尚未登记到 _running_tasks + # (或状态刚由另一线程提交),宽限期内不做 terminated 判定 + try: + created_at = float(task.get("created_at") or 0) + except (TypeError, ValueError): + created_at = 0 + if created_at > 0 and (time.time() - created_at) < 120: + return {"status": "running", "task_id": task_id} + if self._should_force_cleanup_stale_task(task): return self._mark_task_terminated( task, diff --git a/modules/sub_agent/task.py b/modules/sub_agent/task.py index 3e8e1bd6..5ddc82a4 100644 --- a/modules/sub_agent/task.py +++ b/modules/sub_agent/task.py @@ -119,6 +119,10 @@ class SubAgentTask: self._idle = False self._pending_answer_question_id: Optional[str] = None self._answered_question_ids: Set[str] = set() + # 纯上下文通知(不触发新一轮工作)延迟队列:当上下文末尾处于 + # assistant tool_calls 与 tool 结果之间时,直接 append 会违反 API 的 + # tool 消息顺序约束,先入队,由主循环在安全点 _flush_pending_notifications 写入。 + self._pending_notifications: List[str] = [] def emit(self, type_: str, data: Dict[str, Any]) -> None: """输出一行 JSONL 到 progress 文件并缓存。""" @@ -291,6 +295,9 @@ class SubAgentTask: pending_answer_question_id=self._pending_answer_question_id, ) + # 安全点:写入延迟的上下文通知(不触发新一轮工作,仅让下一轮模型调用看到) + self._flush_pending_notifications() + assistant_message, reasoning, tool_calls, usage = await self._call_model(client, model_key, tools) if usage: self._apply_usage(usage) @@ -486,6 +493,59 @@ class SubAgentTask: if self.multi_agent_state: self.multi_agent_state.mark_status(self.agent_id, "idle") + def inject_notification(self, text: str) -> None: + """注入纯上下文通知(user 消息),不唤醒 idle、不触发新一轮工作。 + + 与 inject_message 的核心区别:不 set _continue_event。 + - idle 的子智能体保持 idle,通知在其下一次被真正消息唤醒时随上下文看到; + - 运行中的子智能体在下一轮模型调用前看到(见 _flush_pending_notifications); + - 若末尾消息处于 tool 序列中(assistant tool_calls / tool),改为入队延迟写入, + 避免产生 tool 消息顺序非法的上下文。 + 用途:执行环境变更等「需要知道但不需要行动」的运行期通知。 + """ + content = str(text or "").strip() + if not content: + return + last = self.messages[-1] if self.messages else None + mid_tool_sequence = bool( + last is not None + and (last.get("role") == "tool" or (last.get("role") == "assistant" and last.get("tool_calls"))) + ) + if mid_tool_sequence: + self._pending_notifications.append(content) + else: + self.messages.append({"role": "user", "content": content}) + ma_debug( + "sub_agent_notification_injected", + task_id=self.task_id, + agent_id=self.agent_id, + display_name=self.display_name, + queued=mid_tool_sequence, + was_idle=self._idle, + message_preview=content[:300], + ) + + def _flush_pending_notifications(self) -> None: + """在主循环安全点(下一轮模型调用前)把延迟通知写入上下文。 + + 到达安全点时工具循环已结束:正常完成会写完全部 tool 结果,软停止会为跳过的 + tool_call 补「被取消」结果,取消则直接退出循环——因此末尾不会存在悬空的 + assistant tool_calls,append user 消息序列合法。 + """ + if not self._pending_notifications: + return + for content in self._pending_notifications: + self.messages.append({"role": "user", "content": content}) + count = len(self._pending_notifications) + self._pending_notifications.clear() + ma_debug( + "sub_agent_notifications_flushed", + task_id=self.task_id, + agent_id=self.agent_id, + display_name=self.display_name, + count=count, + ) + def inject_message(self, message_text: str) -> None: """外部向子智能体上下文插入 user 消息,并唤醒 idle 状态。""" self.messages.append({"role": "user", "content": message_text}) @@ -646,7 +706,7 @@ class SubAgentTask: result = await self._execute_multi_agent_tool(name, args) if result is not None: return result - return await self.manager.execute_tool_for_sub_agent(name, args) + return await self.manager.execute_tool_for_sub_agent(name, args, agent_id=self.agent_id) async def _execute_multi_agent_tool(self, name: str, args: Dict[str, Any]) -> Optional[Dict[str, Any]]: """处理多智能体模式专属的通信工具。返回 None 表示不 属于多智能体工具。""" diff --git a/modules/terminal_ops/run.py b/modules/terminal_ops/run.py index 42c37709..01621cee 100644 --- a/modules/terminal_ops/run.py +++ b/modules/terminal_ops/run.py @@ -49,6 +49,60 @@ if TYPE_CHECKING: class RunMixin: """TerminalOperator run 能力 mixin。""" + async def _interrupt_subprocess(self, process) -> None: + """超时后的优雅中断。 + + POSIX 用 killpg(SIGINT) 中断整个进程组;Windows 没有 killpg/SIGINT 语义, + 子进程以 start_new_session=True 启动(映射为 CREATE_NEW_PROCESS_GROUP), + 可用 CTRL_BREAK_EVENT 发送到该进程组,失败则直接强杀。 + """ + if os.name == "nt": + try: + process.send_signal(signal.CTRL_BREAK_EVENT) + except Exception: + try: + process.kill() + except Exception: + pass + return + try: + os.killpg(process.pid, signal.SIGINT) + except Exception: + try: + process.terminate() + except Exception: + pass + + async def _kill_subprocess(self, process) -> None: + """强制终止。 + + POSIX 用 killpg(SIGKILL);Windows 没有 SIGKILL,用 taskkill /F /T 终止整棵进程树, + 避免 shell 子进程被杀后孙进程残留为孤儿。 + """ + if os.name == "nt": + try: + await asyncio.to_thread( + subprocess.run, + ["taskkill", "/F", "/T", "/PID", str(process.pid)], + capture_output=True, + timeout=5, + ) + return + except Exception: + pass + try: + process.kill() + except Exception: + pass + return + try: + os.killpg(process.pid, signal.SIGKILL) + except Exception: + try: + process.kill() + except Exception: + pass + async def check_environment(self) -> Dict: """检查Python环境""" print(f"{OUTPUT_FORMATS['info']} 检查Python环境...") @@ -61,9 +115,9 @@ class RunMixin: "working_directory": str(self.project_path) } - # 获取Python版本 + # 获取Python版本(使用已检测到的 python 命令,Windows 上通常为 python/py 而非 python3) version_result = await self.run_command( - 'python3 --version', + f'{self.python_cmd} --version', timeout=5 ) if version_result["success"]: @@ -71,7 +125,7 @@ class RunMixin: # 获取pip版本 pip_result = await self.run_command( - 'python3 -m pip --version', + f'{self.python_cmd} -m pip --version', timeout=5 ) if pip_result["success"]: @@ -79,7 +133,7 @@ class RunMixin: # 获取已安装的包 packages_result = await self.run_command( - 'python3 -m pip list --format=json', + f'{self.python_cmd} -m pip list --format=json', timeout=10 ) if packages_result["success"]: @@ -245,41 +299,25 @@ class RunMixin: timed_out = False try: - await asyncio.wait_for(process.wait(), timeout=timeout) - except asyncio.TimeoutError: - timed_out = True try: - os.killpg(process.pid, signal.SIGINT) - except Exception: - pass - try: - await asyncio.wait_for(process.wait(), timeout=2) + await asyncio.wait_for(process.wait(), timeout=timeout) except asyncio.TimeoutError: + timed_out = True + await self._interrupt_subprocess(process) try: - os.killpg(process.pid, signal.SIGKILL) - except Exception: - process.kill() - await process.wait() - except asyncio.CancelledError: - # 用户主动停止任务或会话断开,立即终止子进程 - try: - os.killpg(process.pid, signal.SIGKILL) - except Exception: - try: - process.kill() - except Exception: - pass - # 取消读取任务,避免孤儿任务 - stdout_task.cancel() - stderr_task.cancel() - try: - await asyncio.gather(stdout_task, stderr_task, return_exceptions=True) - except Exception: - pass - raise - - # 确保读取协程结束(超时场景强制收口,避免永久等待 EOF) - await _finish_reader_tasks(force=timed_out) + await asyncio.wait_for(process.wait(), timeout=2) + except asyncio.TimeoutError: + await self._kill_subprocess(process) + await process.wait() + except asyncio.CancelledError: + # 用户主动停止任务或会话断开,立即终止子进程 + await self._kill_subprocess(process) + raise + finally: + # 收口 reader 任务(正常/超时/取消路径统一执行),避免泄漏 + # pending 任务及 Windows Proactor 下未关闭的管道传输层 + # (Task was destroyed but it is pending / unclosed transport 告警) + await _finish_reader_tasks(force=timed_out) # 非超时场景下兜底再读一次,防止剩余缓冲未被读取 if not timed_out: @@ -369,8 +407,8 @@ class RunMixin: """ print(f"{OUTPUT_FORMATS['terminal']} 安装包: {package}") - # 使用通用 python3 占位,由 run_command 根据执行环境重写 - command = f'python3 -m pip install {package}' + # 使用已检测到的 python 命令(Windows 上通常为 python/py 而非 python3) + command = f'{self.python_cmd} -m pip install {package}' result = await self.run_command(command, timeout=120) diff --git a/modules/user_manager.py b/modules/user_manager.py index 0f85c022..8a221487 100644 --- a/modules/user_manager.py +++ b/modules/user_manager.py @@ -1,6 +1,7 @@ """User and workspace management utilities for multi-user support.""" import json +import os import re import shutil from dataclasses import dataclass @@ -210,6 +211,15 @@ class UserManager: link_path.symlink_to(target_path) except Exception: if not link_path.exists() and target_path.exists(): + # Windows 无管理员权限/未开开发者模式时 symlink 常失败。 + # 优先退回硬链接:同卷内无需特权,且两侧指向同一文件内容, + # 保持「项目间共享用户状态」的语义,不会像 copy 那样静默分叉。 + try: + os.link(target_path, link_path) + return + except Exception: + pass + # 跨卷/文件系统不支持硬链接时最后退回复制(可能分叉,仅兜底)。 try: shutil.copy2(target_path, link_path) except Exception: diff --git a/prompts/sub_agent/system.txt b/prompts/sub_agent/system.txt index 9bbd3101..48bd81b9 100644 --- a/prompts/sub_agent/system.txt +++ b/prompts/sub_agent/system.txt @@ -102,7 +102,7 @@ 你拥有以下工具能力: - read_file: 读取文件内容 - write_file / edit_file: 创建或修改文件 -- run_command: 执行终端命令(需要搜索文件/代码时,用它执行 rg 或 grep -rn / find;务必排除 node_modules、.git 等重目录,例如 grep -rn --exclude-dir=node_modules --exclude-dir=.git) +- run_command: 执行终端命令(命令解释器与可用命令见下方「执行环境」段,务必按当前环境书写命令;需要搜索文件/代码时,Linux/macOS 用 rg 或 grep -rn / find,Windows cmd 用 dir /s 或 findstr /s /n;务必排除 node_modules、.git 等重目录) - web_search / extract_webpage: 搜索和提取网页内容 - read_mediafile: 读取图片/视频文件 - finish_task: 完成任务并退出(必须调用) @@ -115,6 +115,8 @@ 4. **不要等待输入**:你是自主运行的,不会收到用户的进一步指令 5. **注意时间限制**:超时会被强制终止,优先完成核心工作 +{execution_env_section} + # 当前环境 - 工作区路径: {workspace_path} diff --git a/server/api_v1.py b/server/api_v1.py index 96170e96..fa4dab1d 100644 --- a/server/api_v1.py +++ b/server/api_v1.py @@ -396,7 +396,8 @@ def get_task_events(task_id: str): offset = int(request.args.get("from", 0)) except Exception: offset = 0 - events = [e for e in rec.events if e["idx"] >= offset] + # 工作线程会持续追加事件,必须持锁快照,不能直接迭代 rec.events + events = task_manager.get_events_since(rec, offset) next_offset = events[-1]["idx"] + 1 if events else offset return jsonify({ "success": True, diff --git a/server/chat/permission.py b/server/chat/permission.py index 2c3f9245..19ad776c 100644 --- a/server/chat/permission.py +++ b/server/chat/permission.py @@ -38,6 +38,7 @@ from modules.host_sandbox_policy import ( save_policy, get_macos_deny_read_paths, get_macos_deny_read_regexes, + get_windows_deny_read_paths, ) from modules.user_manager import UserWorkspace from core.web_terminal import WebTerminal @@ -84,18 +85,44 @@ def _sync_workspace_terminal_mode(username: str, workspace, kind: str, mode: str pass +# Windows 特有的 deny 正则(POSIX 的 ^/.*\.env$ 无法匹配 C:\ 开头的路径) +_WINDOWS_DENY_READ_REGEXES = [ + r"^[A-Za-z]:[\\/].*\.env(\.[^\\/]*)?$", +] + + +def _windows_system_deny_paths() -> list: + """Windows 系统敏感目录(从环境变量取值,适配非默认安装位置)。""" + return [ + os.environ.get("SystemRoot", r"C:\Windows"), + os.environ.get("ProgramData", r"C:\ProgramData"), + os.environ.get("ProgramFiles", r"C:\Program Files"), + os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"), + ] + + +def _normalize_for_compare(p: str) -> str: + """路径比较归一化:展开 ~、转绝对路径、统一大小写与分隔符。""" + return os.path.normcase(os.path.abspath(os.path.expanduser(p))).lower().rstrip("\\/") + + def _path_conflicts_with_deny_list(path: str) -> Optional[str]: """检查用户授权路径是否与内置 deny 列表冲突,返回错误信息或 None。""" if not path: return None expanded = os.path.abspath(os.path.expanduser(path)) - for deny_path in get_macos_deny_read_paths(): - deny_expanded = os.path.abspath(os.path.expanduser(deny_path)) - deny_lower = deny_expanded.lower().rstrip("/") - expanded_lower = expanded.lower().rstrip("/") - if expanded_lower == deny_lower or expanded_lower.startswith(deny_lower + "/"): + deny_paths = list(get_macos_deny_read_paths()) + deny_regexes = list(get_macos_deny_read_regexes()) + if os.name == "nt": + # Windows:补充 Windows deny 列表与系统目录(此前完全不校验) + deny_paths += list(get_windows_deny_read_paths()) + _windows_system_deny_paths() + deny_regexes += _WINDOWS_DENY_READ_REGEXES + expanded_lower = _normalize_for_compare(expanded) + for deny_path in deny_paths: + deny_lower = _normalize_for_compare(deny_path) + if expanded_lower == deny_lower or expanded_lower.startswith(deny_lower + os.sep): return f"禁止授权敏感路径: {path}" - for pattern in get_macos_deny_read_regexes(): + for pattern in deny_regexes: try: if re.search(pattern, expanded) or re.search(pattern, path): return f"禁止授权敏感文件: {path}" @@ -396,6 +423,7 @@ def get_path_authorization(terminal: WebTerminal, workspace: UserWorkspace, user "readable_extra_paths": data.get("macos_readable_extra_paths", []), "deny_read_paths": data.get("macos_deny_read_paths", []), "deny_read_regexes": data.get("macos_deny_read_regexes", []), + "windows_deny_read_paths": data.get("windows_deny_read_paths", []), }) @chat_bp.route('/api/path-authorization', methods=['POST']) @@ -416,6 +444,10 @@ def update_path_authorization(terminal: WebTerminal, workspace: UserWorkspace, u readable_extra = [str(x).strip() for x in readable_items if str(x).strip()] if "/" in writable or "/" in readable_extra: return jsonify({"success": False, "error": "禁止授权根目录 /"}), 400 + # Windows:禁止授权驱动器根目录(如 C:\、D:/),此前仅检查 POSIX 根 "/" + drive_root_pattern = re.compile(r"^[A-Za-z]:[\\/]?$") + if any(drive_root_pattern.match(p) for p in writable + readable_extra): + return jsonify({"success": False, "error": "禁止授权驱动器根目录(如 C:\\)"}), 400 for p in writable + readable_extra: conflict = _path_conflicts_with_deny_list(p) if conflict: diff --git a/server/status/git.py b/server/status/git.py index e3fcb7f9..58730c66 100644 --- a/server/status/git.py +++ b/server/status/git.py @@ -41,7 +41,12 @@ def _run_project_git(project_path: Path, args: list[str]) -> tuple[bool, str]: proc = subprocess.run( [git_bin, "-c", "core.quotePath=false", "-C", str(project_path), *args], cwd=str(project_path), + # git 输出固定为 UTF-8(core.quotePath=false 时中文路径亦为 UTF-8 字节); + # Windows text=True 默认按 GBK 解码会让 _readerthread 抛 UnicodeDecodeError, + # 导致输出被截断、侧边栏拿不到数据,必须显式指定编码。 text=True, + encoding="utf-8", + errors="replace", stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=2, @@ -59,7 +64,11 @@ def _run_project_git_raw(project_path: Path, args: list[str], timeout: int = 4) proc = subprocess.run( [git_bin, "-c", "core.quotePath=false", "-C", str(project_path), *args], cwd=str(project_path), + # 同上:固定按 UTF-8 解码;git show 出的文件内容可能是 GBK 等旧编码, + # errors=replace 保证个别坏字节不会炸掉整个读取线程。 text=True, + encoding="utf-8", + errors="replace", stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=timeout, diff --git a/server/tasks/api.py b/server/tasks/api.py index e1440fc1..ec66b601 100644 --- a/server/tasks/api.py +++ b/server/tasks/api.py @@ -234,7 +234,8 @@ def get_task_api(task_id: str): offset = int(request.args.get("from", 0)) except Exception: offset = 0 - events = [e for e in rec.events if e["idx"] >= offset] + # 工作线程会持续追加事件,必须持锁快照,不能直接迭代 rec.events + events = task_manager.get_events_since(rec, offset) next_offset = events[-1]["idx"] + 1 if events else offset elapsed_ms = (time.time() - started_at) * 1000.0 should_log = ( diff --git a/server/tasks/models.py b/server/tasks/models.py index a027d55f..9896311f 100644 --- a/server/tasks/models.py +++ b/server/tasks/models.py @@ -213,6 +213,17 @@ class TaskManager: return None return rec + def get_events_since(self, rec: TaskRecord, offset: int) -> List[Dict[str, Any]]: + """按 offset 过滤事件。 + + rec.events 由任务工作线程持续追加(流式输出期间非常频繁), + 直接迭代会在并发追加时抛 RuntimeError: deque mutated during iteration。 + 先在锁内做 O(n) 浅拷贝快照,再在锁外过滤。 + """ + with self._lock: + snapshot = list(rec.events) + return [e for e in snapshot if e["idx"] >= offset] + def list_tasks(self, username: str, workspace_id: Optional[str] = None) -> List[TaskRecord]: with self._lock: return [ diff --git a/setup.bat b/setup.bat new file mode 100644 index 00000000..2addba9e --- /dev/null +++ b/setup.bat @@ -0,0 +1,31 @@ +@echo off +rem 首次初始化入口(Windows 版 setup.sh): +rem 1. 创建/复用 Python 虚拟环境并安装依赖 +rem 2. 准备 Node 依赖(easyagent / 前端,依赖系统已装 Node) +rem 3. 运行 python -m scripts.setup 交互式向导,写出 .env 与模型配置 +rem +rem 用法: +rem setup.bat 首次初始化(已存在 .env 时向导会提示备份后重配) +rem setup.bat --force 跳过「已存在 .env」确认(仍会备份) + +rem 控制台切到 UTF-8,保证中文提示与日志正常显示。 +chcp 65001 >nul +setlocal + +set "ROOT=%~dp0" +if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%" +set "VENV_PY=%ROOT%\.venv\Scripts\python.exe" + +echo ======================================== +echo AI Agent 初始化 +echo ======================================== + +call "%ROOT%\_bootstrap.bat" ensure_python_env +if errorlevel 1 exit /b 1 +call "%ROOT%\_bootstrap.bat" ensure_node_env +if errorlevel 1 exit /b 1 + +echo. +echo [setup] 启动配置向导... +"%VENV_PY%" -m scripts.setup %* +exit /b %errorlevel% diff --git a/start.bat b/start.bat new file mode 100644 index 00000000..486484ff --- /dev/null +++ b/start.bat @@ -0,0 +1,44 @@ +@echo off +rem 启动入口(Windows 版 start.sh): +rem 1. 确保 Python venv 与依赖就绪(缺失才安装) +rem 2. 确保 Node 依赖就绪(缺失才安装) +rem 3. 若没有 .env(首次启动),自动运行配置向导 +rem 4. 启动 python -m server.app +rem +rem 端口/监听地址/模式等由 .env 决定(见 config/server.py、config/paths.py)。 +rem 透传的命令行参数会传给 server.app(如 --port / --path / --thinking-mode)。 +rem +rem 用法: +rem start.bat 正常启动(首次会自动初始化) +rem start.bat --thinking-mode 透传参数给 server.app + +rem 控制台切到 UTF-8,保证中文提示与日志正常显示。 +chcp 65001 >nul +setlocal + +set "ROOT=%~dp0" +if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%" +set "VENV_PY=%ROOT%\.venv\Scripts\python.exe" + +call "%ROOT%\_bootstrap.bat" ensure_python_env +if errorlevel 1 exit /b 1 +call "%ROOT%\_bootstrap.bat" ensure_node_env +if errorlevel 1 exit /b 1 + +call "%ROOT%\_bootstrap.bat" has_env_file +if not errorlevel 1 goto :start_server + +echo. +echo [start] 未检测到 .env,进入首次初始化向导... +"%VENV_PY%" -m scripts.setup +call "%ROOT%\_bootstrap.bat" has_env_file +if not errorlevel 1 goto :start_server +echo [start] 初始化未完成(未生成 .env),已退出。 1>&2 +exit /b 1 + +:start_server +echo. +echo [start] 启动 Web 服务... +cd /d "%ROOT%" +"%VENV_PY%" -m server.app %* +exit /b %errorlevel% diff --git a/utils/atomic_io.py b/utils/atomic_io.py new file mode 100644 index 00000000..3ac2b8c1 --- /dev/null +++ b/utils/atomic_io.py @@ -0,0 +1,46 @@ +"""原子写文件工具:temp file + os.replace 的 Windows 加固版。 + +背景:Windows 上目标文件被任何句柄以默认共享模式打开时(msvcrt 默认共享读/写, +不含 FILE_SHARE_DELETE),os.replace 会抛 WinError 5(拒绝访问); +并发写入者之间则可能抛 WinError 32(共享冲突)。杀毒软件实时扫描、 +Windows Search 索引、同进程并发的读取线程都会造成这类短暂持锁。 +POSIX 下 rename 不受打开句柄影响,无此问题。 + +对策:仅对这两种 winerror 做短退避重试;其他错误立即抛出。 +""" +from __future__ import annotations + +import os +import time +from pathlib import Path +from typing import Union + +# ERROR_ACCESS_DENIED / ERROR_SHARING_VIOLATION +_RETRY_WINERRORS = {5, 32} + +PathLike = Union[str, Path] + + +def replace_with_retry( + src: PathLike, + dst: PathLike, + *, + attempts: int = 6, + initial_delay: float = 0.05, +) -> None: + """os.replace 的 Windows 加固版:对瞬时持锁做指数退避重试。 + + attempts 为总尝试次数(含首次),退避序列默认 0.05/0.1/0.2/0.4/0.8s, + 最坏情况约 1.55s。最后一次失败时原样抛出该 OSError。 + """ + delay = initial_delay + total = max(1, int(attempts)) + for i in range(total): + try: + os.replace(str(src), str(dst)) + return + except OSError as exc: + if getattr(exc, "winerror", None) not in _RETRY_WINERRORS or i == total - 1: + raise + time.sleep(delay) + delay = min(delay * 2, 0.8) diff --git a/utils/context_manager/runtime_mixin.py b/utils/context_manager/runtime_mixin.py index 220d4998..be5c50a8 100644 --- a/utils/context_manager/runtime_mixin.py +++ b/utils/context_manager/runtime_mixin.py @@ -103,6 +103,8 @@ class RuntimeMixin: def _read_os_release_pretty(self) -> str: """读取 Linux 发行版信息 / Read Linux distro from os-release.""" + if sys.platform != "linux": + return "" path = Path("/etc/os-release") if not path.exists(): return "" @@ -155,7 +157,7 @@ class RuntimeMixin: return self._run_command(["sysctl", "-n", "hw.model"]) if system == "Windows": model = self._run_command( - ["powershell", "-NoProfile", "-Command", "(Get-CimInstance -ClassName Win32_ComputerSystem).Model"] + ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "(Get-CimInstance -ClassName Win32_ComputerSystem).Model"] ) if model: return model diff --git a/utils/conversation_manager/crud_mixin.py b/utils/conversation_manager/crud_mixin.py index fb93c0ca..4ab2765b 100644 --- a/utils/conversation_manager/crud_mixin.py +++ b/utils/conversation_manager/crud_mixin.py @@ -65,7 +65,9 @@ def _debug_conversation_save_trace(conversation_id: str, file_path, data: Dict): fn = str(fr.filename) if repo_root and not fn.startswith(str(repo_root)): continue - if "/.astrion/" in fn or "site-packages" in fn: + # Windows 下调用帧文件名用反斜杠,统一归一化再匹配 + fn_norm = fn.replace("\\", "/") + if "/.astrion/" in fn_norm or "site-packages" in fn_norm: continue rel = fn[len(str(repo_root)) + 1:] if repo_root and fn.startswith(str(repo_root)) else fn frames.append(f"{rel}:{fr.lineno}:{fr.name}") diff --git a/utils/conversation_manager/index_mixin.py b/utils/conversation_manager/index_mixin.py index 00896918..1076764e 100644 --- a/utils/conversation_manager/index_mixin.py +++ b/utils/conversation_manager/index_mixin.py @@ -9,6 +9,8 @@ from datetime import datetime from pathlib import Path from typing import Dict, List, Optional, Any from dataclasses import dataclass + +from utils.atomic_io import replace_with_retry try: from config import DATA_DIR, HOST_WORKSPACES_FILE except ImportError: @@ -113,7 +115,9 @@ class IndexMixin: json.dump(payload, fh, ensure_ascii=False, indent=2) fh.flush() os.fsync(fh.fileno()) - os.replace(str(temp_path), str(target_file)) + # Windows 下目标文件被并发读取/杀软扫描短暂持锁时 replace 会抛 + # WinError 5/32,replace_with_retry 做短退避重试(POSIX 行为不变) + replace_with_retry(temp_path, target_file) finally: if temp_path and temp_path.exists(): try: diff --git a/utils/media_store.py b/utils/media_store.py index 7cf720cb..e8b4ee36 100644 --- a/utils/media_store.py +++ b/utils/media_store.py @@ -13,6 +13,8 @@ from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +from utils.atomic_io import replace_with_retry + DEFAULT_MIME_BY_KIND: Dict[str, str] = { "image": "image/png", @@ -128,7 +130,8 @@ class MediaStore: json.dump(payload, fh, ensure_ascii=False, indent=2) fh.flush() os.fsync(fh.fileno()) - os.replace(str(temp_path), str(target_file)) + # Windows 瞬时持锁(并发读取/杀软扫描)重试,POSIX 行为不变 + replace_with_retry(temp_path, target_file) finally: if temp_path and temp_path.exists(): try: @@ -194,7 +197,8 @@ class MediaStore: fh.write(payload) fh.flush() os.fsync(fh.fileno()) - os.replace(str(temp_path), str(blob_path)) + # Windows 瞬时持锁重试,POSIX 行为不变 + replace_with_retry(temp_path, blob_path) finally: if temp_path and temp_path.exists(): try: