fix(windows): 平台适配与稳定性修复(进程终止/编码/原子写/事件竞态/子智能体)
- 进程终止: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 启动脚本
This commit is contained in:
parent
3bb47ff857
commit
493ac160eb
107
_bootstrap.bat
Normal file
107
_bootstrap.bat
Normal file
@ -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
|
||||
@ -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]}
|
||||
|
||||
11
main.py
11
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
|
||||
|
||||
# 检查是否包含向上遍历
|
||||
|
||||
@ -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)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
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:
|
||||
|
||||
107
modules/execution_env_text.py
Normal file
107
modules/execution_env_text.py
Normal file
@ -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
|
||||
)
|
||||
@ -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:
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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/<user>/projects/<project_id>/data -> users/<user>/personal/mutiagents/agents
|
||||
|
||||
@ -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 = (
|
||||
from modules.execution_env_text import build_sub_agent_env_section
|
||||
|
||||
return build_sub_agent_env_section(workspace_path, mode)
|
||||
return (
|
||||
"## 执行环境\n\n"
|
||||
"- 运行环境:Docker 容器模式\n"
|
||||
"- 执行方式:容器内执行\n\n"
|
||||
"### 当前规则\n"
|
||||
"- 所有命令在 Docker 容器内执行\n"
|
||||
"- 工作区挂载在容器内 /workspace 路径下\n"
|
||||
"- 网络可能受限,仅允许 localhost"
|
||||
)
|
||||
return (
|
||||
f"## 执行环境\n\n"
|
||||
f"- 运行环境:{runtime_label}\n"
|
||||
f"- 执行方式:{exec_label}\n\n"
|
||||
f"### 当前规则\n{rules}\n"
|
||||
"- 网络可能受限,仅允许 localhost\n"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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] = {}
|
||||
|
||||
@ -101,6 +108,11 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
|
||||
# ------------------------------------------------------------------
|
||||
def _ensure_event_loop(self) -> asyncio.AbstractEventLoop:
|
||||
"""确保有一个独立的后台事件循环供子智能体使用。"""
|
||||
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
|
||||
|
||||
@ -127,11 +139,25 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
|
||||
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()
|
||||
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
|
||||
|
||||
@ -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),
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 表示不 属于多智能体工具。"""
|
||||
|
||||
@ -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"]:
|
||||
@ -244,41 +298,25 @@ class RunMixin:
|
||||
pass
|
||||
|
||||
timed_out = False
|
||||
try:
|
||||
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
|
||||
await self._interrupt_subprocess(process)
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=2)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except Exception:
|
||||
process.kill()
|
||||
await self._kill_subprocess(process)
|
||||
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
|
||||
await self._kill_subprocess(process)
|
||||
raise
|
||||
|
||||
# 确保读取协程结束(超时场景强制收口,避免永久等待 EOF)
|
||||
finally:
|
||||
# 收口 reader 任务(正常/超时/取消路径统一执行),避免泄漏
|
||||
# pending 任务及 Windows Proactor 下未关闭的管道传输层
|
||||
# (Task was destroyed but it is pending / unclosed transport 告警)
|
||||
await _finish_reader_tasks(force=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)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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 [
|
||||
|
||||
31
setup.bat
Normal file
31
setup.bat
Normal file
@ -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%
|
||||
44
start.bat
Normal file
44
start.bat
Normal file
@ -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%
|
||||
46
utils/atomic_io.py
Normal file
46
utils/atomic_io.py
Normal file
@ -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)
|
||||
@ -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
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user