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", ""))
|
_data_dir = str(getattr(self, "data_dir", ""))
|
||||||
_custom_dir = infer_custom_roles_dir(_data_dir)
|
_custom_dir = infer_custom_roles_dir(_data_dir)
|
||||||
# host模式下 custom_dir 就是 runtime_dir;web模式下 runtime_dir 指向 web 预设目录
|
# 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
|
_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)
|
role = load_preset_role(role_id, runtime_dir=_runtime_dir, custom_dir=_custom_dir)
|
||||||
if not role:
|
if not role:
|
||||||
@ -2145,7 +2146,8 @@ class MainTerminalToolsExecutionMixin:
|
|||||||
_data_dir = str(getattr(self, "data_dir", ""))
|
_data_dir = str(getattr(self, "data_dir", ""))
|
||||||
from modules.multi_agent.role_store import infer_custom_roles_dir
|
from modules.multi_agent.role_store import infer_custom_roles_dir
|
||||||
_custom_dir = infer_custom_roles_dir(_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
|
_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)}
|
existing_ids = {r.role_id for r in list_roles(runtime_dir=_runtime_dir, custom_dir=_custom_dir)}
|
||||||
if role_id in existing_ids:
|
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
|
from modules.multi_agent.role_store import list_roles, infer_custom_roles_dir
|
||||||
_data_dir = str(getattr(self, "data_dir", ""))
|
_data_dir = str(getattr(self, "data_dir", ""))
|
||||||
_custom_dir = infer_custom_roles_dir(_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
|
_runtime_dir = None if _is_web else _custom_dir
|
||||||
roles = list_roles(runtime_dir=_runtime_dir, custom_dir=_custom_dir)
|
roles = list_roles(runtime_dir=_runtime_dir, custom_dir=_custom_dir)
|
||||||
result = {"success": True, "roles": [r.to_dict() for r in roles]}
|
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:
|
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:
|
for forbidden_root in FORBIDDEN_ROOT_PATHS:
|
||||||
expanded = os.path.expanduser(forbidden_root)
|
expanded = os.path.normcase(os.path.expanduser(forbidden_root))
|
||||||
if resolved_path == expanded or resolved_path == forbidden_root:
|
if resolved_path == expanded or resolved_path == os.path.normcase(forbidden_root):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 检查是否在系统目录
|
# 检查是否在系统目录
|
||||||
for forbidden in FORBIDDEN_PATHS:
|
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
|
return True
|
||||||
|
|
||||||
# 检查是否包含向上遍历
|
# 检查是否包含向上遍历
|
||||||
|
|||||||
@ -321,21 +321,19 @@ class BackgroundCommandManager:
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
status = "timeout"
|
status = "timeout"
|
||||||
message = f"命令执行超时 ({timeout}秒)"
|
message = f"命令执行超时 ({timeout}秒)"
|
||||||
try:
|
# 跨平台终止:POSIX killpg(SIGINT→SIGKILL),Windows CTRL_BREAK→taskkill
|
||||||
os.killpg(process.pid, signal.SIGINT)
|
self._terminate_pid(process.pid)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
try:
|
||||||
process.wait(timeout=2)
|
process.wait(timeout=2)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
try:
|
|
||||||
os.killpg(process.pid, signal.SIGKILL)
|
|
||||||
except Exception:
|
|
||||||
try:
|
try:
|
||||||
process.kill()
|
process.kill()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
process.wait(timeout=2)
|
process.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return_code = process.returncode if process.returncode is not None else -1
|
return_code = process.returncode if process.returncode is not None else -1
|
||||||
|
|
||||||
t_out.join(timeout=1)
|
t_out.join(timeout=1)
|
||||||
@ -400,6 +398,26 @@ class BackgroundCommandManager:
|
|||||||
normalized = self._coerce_pid(pid)
|
normalized = self._coerce_pid(pid)
|
||||||
if not normalized:
|
if not normalized:
|
||||||
return False
|
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:
|
try:
|
||||||
os.kill(normalized, 0)
|
os.kill(normalized, 0)
|
||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
@ -414,6 +432,28 @@ class BackgroundCommandManager:
|
|||||||
normalized = self._coerce_pid(pid)
|
normalized = self._coerce_pid(pid)
|
||||||
if not normalized:
|
if not normalized:
|
||||||
return False
|
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:
|
try:
|
||||||
os.killpg(normalized, signal.SIGINT)
|
os.killpg(normalized, signal.SIGINT)
|
||||||
except Exception:
|
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
|
return False
|
||||||
|
|
||||||
def _host_allowed_roots(self, access: str) -> List[Path]:
|
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()
|
raw_items = get_macos_writable_paths() if access == "write" else get_macos_readable_paths()
|
||||||
for raw in raw_items:
|
for raw in raw_items:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
from config import HOST_WORKSPACES_FILE
|
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}$")
|
_WORKSPACE_ID_RE = re.compile(r"^[a-zA-Z0-9._-]{1,64}$")
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
_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)
|
json.dump(data, fp, ensure_ascii=False, indent=2)
|
||||||
fp.flush()
|
fp.flush()
|
||||||
os.fsync(fp.fileno())
|
os.fsync(fp.fileno())
|
||||||
os.replace(tmp_path, path)
|
# Windows 瞬时持锁(并发读取/杀软扫描)重试,POSIX 行为不变
|
||||||
|
replace_with_retry(tmp_path, path)
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
if os.path.exists(tmp_path):
|
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:
|
if not data_dir:
|
||||||
return _host_runtime_dir()
|
return _host_runtime_dir()
|
||||||
data_path = str(Path(data_dir).expanduser().resolve())
|
data_path = str(Path(data_dir).expanduser().resolve())
|
||||||
if "/web/users/" in data_path:
|
# Windows resolve 后路径用反斜杠,统一归一化再匹配
|
||||||
|
if "/web/users/" in data_path.replace("\\", "/"):
|
||||||
# web 模式:按用户隔离
|
# web 模式:按用户隔离
|
||||||
dp = Path(data_path).resolve()
|
dp = Path(data_path).resolve()
|
||||||
# users/<user>/projects/<project_id>/data -> users/<user>/personal/mutiagents/agents
|
# 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:
|
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()
|
mode = (sandbox_mode or TERMINAL_SANDBOX_MODE or "").lower()
|
||||||
if IS_HOST_MODE:
|
if IS_HOST_MODE:
|
||||||
runtime_label = "宿主机模式"
|
from modules.execution_env_text import build_sub_agent_env_section
|
||||||
# host 模式下区分 sandbox / direct
|
|
||||||
if mode == "direct":
|
return build_sub_agent_env_section(workspace_path, mode)
|
||||||
exec_label = "直接执行(完全访问权限)"
|
return (
|
||||||
rules = (
|
"## 执行环境\n\n"
|
||||||
"- 当前为宿主机直接执行模式\n"
|
"- 运行环境:Docker 容器模式\n"
|
||||||
"- 仅在必须时执行高权限操作,保持最小化命令范围\n"
|
"- 执行方式:容器内执行\n\n"
|
||||||
"- 涉及删除/覆盖/系统级变更前,先说明风险再执行"
|
"### 当前规则\n"
|
||||||
)
|
|
||||||
else:
|
|
||||||
exec_label = "沙箱执行"
|
|
||||||
rules = (
|
|
||||||
"- 所有命令默认在系统 OS 沙箱中执行\n"
|
|
||||||
"- 若操作受系统权限限制:先提供安全替代方案;若仍无法满足,说明需要更高权限\n"
|
|
||||||
"- 不要通过复杂绕过手段规避沙箱限制"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
runtime_label = "Docker 容器模式"
|
|
||||||
exec_label = "容器内执行"
|
|
||||||
rules = (
|
|
||||||
"- 所有命令在 Docker 容器内执行\n"
|
"- 所有命令在 Docker 容器内执行\n"
|
||||||
"- 工作区挂载在容器内 /workspace 路径下\n"
|
"- 工作区挂载在容器内 /workspace 路径下\n"
|
||||||
"- 网络可能受限,仅允许 localhost"
|
"- 网络可能受限,仅允许 localhost\n"
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"## 执行环境\n\n"
|
|
||||||
f"- 运行环境:{runtime_label}\n"
|
|
||||||
f"- 执行方式:{exec_label}\n\n"
|
|
||||||
f"### 当前规则\n{rules}\n"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,8 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Set
|
from typing import Any, Dict, List, Optional, Set
|
||||||
|
|
||||||
|
from utils.atomic_io import replace_with_retry
|
||||||
|
|
||||||
|
|
||||||
class ShallowVersioningError(RuntimeError):
|
class ShallowVersioningError(RuntimeError):
|
||||||
"""Raised when shallow versioning fails."""
|
"""Raised when shallow versioning fails."""
|
||||||
@ -151,7 +153,8 @@ class ShallowVersioningManager:
|
|||||||
for snapshot in self._snapshots
|
for snapshot in self._snapshots
|
||||||
)
|
)
|
||||||
tmp_path.write_text(payload, encoding="utf-8")
|
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:
|
def _upsert_snapshot(self, snapshot: ShallowSnapshot) -> None:
|
||||||
"""Insert or replace the snapshot for its message_id in memory.
|
"""Insert or replace the snapshot for its message_id in memory.
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
@ -81,7 +82,13 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
|
|||||||
self._running_tasks: Dict[str, asyncio.Task] = {}
|
self._running_tasks: Dict[str, asyncio.Task] = {}
|
||||||
self._event_loop: Optional[asyncio.AbstractEventLoop] = None
|
self._event_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
self._loop_thread: Optional[threading.Thread] = None
|
self._loop_thread: Optional[threading.Thread] = None
|
||||||
|
# _ensure_event_loop 并发创建保护(并发 create_sub_agent 时避免创建多个事件循环)
|
||||||
|
self._loop_lock = threading.Lock()
|
||||||
self._state_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 映射(供多智能体消息注入使用)
|
# agent_id -> SubAgentTask 映射(供多智能体消息注入使用)
|
||||||
self._sub_agent_instances: Dict[int, Any] = {}
|
self._sub_agent_instances: Dict[int, Any] = {}
|
||||||
|
|
||||||
@ -101,6 +108,11 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def _ensure_event_loop(self) -> asyncio.AbstractEventLoop:
|
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():
|
if self._event_loop is not None and not self._event_loop.is_closed():
|
||||||
return self._event_loop
|
return self._event_loop
|
||||||
|
|
||||||
@ -127,11 +139,25 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
|
|||||||
return asyncio.create_task(coro)
|
return asyncio.create_task(coro)
|
||||||
|
|
||||||
def _run_coro(self, coro):
|
def _run_coro(self, coro):
|
||||||
"""在后台事件循环中调度一个协程并返回 asyncio.Task。"""
|
"""在后台事件循环中调度一个协程并返回 asyncio.Task。
|
||||||
|
|
||||||
|
调度失败(如循环线程繁忙超时)时必须取消提交并关闭协程,
|
||||||
|
否则协程可能在循环空闲后被“幽灵执行”,而调用方已按失败处理,
|
||||||
|
造成状态不一致(曾实测:超时返回失败后任务仍运行并交付结果)。
|
||||||
|
"""
|
||||||
loop = self._ensure_event_loop()
|
loop = self._ensure_event_loop()
|
||||||
# 先提交创建 Task 的协程,阻塞等待拿到 Task 句柄
|
# 先提交创建 Task 的协程,阻塞等待拿到 Task 句柄
|
||||||
future = asyncio.run_coroutine_threadsafe(self._create_task(coro), loop)
|
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:
|
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:
|
def set_host_execution_mode(self, mode: str) -> None:
|
||||||
normalized = str(mode or "").strip().lower()
|
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:
|
try:
|
||||||
deliverables_path = self._resolve_deliverables_dir(deliverables_dir, multi_agent_mode=multi_agent_mode)
|
deliverables_path = self._resolve_deliverables_dir(deliverables_dir, multi_agent_mode=multi_agent_mode)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
# 回滚已创建的任务目录,避免残留状态(交付目录校验失败时 deliverables 不会创建)
|
||||||
|
shutil.rmtree(task_root, ignore_errors=True)
|
||||||
return {"success": False, "error": str(exc)}
|
return {"success": False, "error": str(exc)}
|
||||||
|
|
||||||
task_file = task_root / "task.txt"
|
task_file = task_root / "task.txt"
|
||||||
@ -225,7 +300,8 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
|
|||||||
if system_prompt:
|
if system_prompt:
|
||||||
final_system_prompt = system_prompt
|
final_system_prompt = system_prompt
|
||||||
else:
|
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")
|
system_prompt_file.write_text(final_system_prompt, encoding="utf-8")
|
||||||
|
|
||||||
# timeout_seconds 为 None 表示永久子智能体(不会被时间终结)
|
# timeout_seconds 为 None 表示永久子智能体(不会被时间终结)
|
||||||
@ -255,11 +331,10 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
|
|||||||
"compress_threshold_tokens": compress_threshold_tokens,
|
"compress_threshold_tokens": compress_threshold_tokens,
|
||||||
"container_name": None,
|
"container_name": None,
|
||||||
}
|
}
|
||||||
self.tasks[task_id] = task_record
|
|
||||||
self._mark_agent_id_used(conversation_id, agent_id)
|
|
||||||
self._save_state()
|
|
||||||
|
|
||||||
# 多智能体模式:为该会话创建或复用 MultiAgentState
|
# 多智能体模式:为该会话创建或复用 MultiAgentState
|
||||||
|
# 注意:状态提交(self.tasks / _mark_agent_id_used / _save_state)必须在
|
||||||
|
# 调度成功之后进行,否则调度失败会留下幽灵记录,且 reconcile 会在
|
||||||
|
# “记录已存在但 _running_tasks 无句柄”的窗口期把任务误标为 terminated
|
||||||
multi_agent_state = None
|
multi_agent_state = None
|
||||||
if multi_agent_mode:
|
if multi_agent_mode:
|
||||||
multi_agent_state = self.get_or_create_multi_agent_state(conversation_id)
|
multi_agent_state = self.get_or_create_multi_agent_state(conversation_id)
|
||||||
@ -276,6 +351,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
|
|||||||
try:
|
try:
|
||||||
multi_agent_state.register_instance(inst)
|
multi_agent_state.register_instance(inst)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
shutil.rmtree(task_root, ignore_errors=True)
|
||||||
return {"success": False, "error": f"agent_id {agent_id} 已在该会话中使用"}
|
return {"success": False, "error": f"agent_id {agent_id} 已在该会话中使用"}
|
||||||
|
|
||||||
sub_agent = SubAgentTask(
|
sub_agent = SubAgentTask(
|
||||||
@ -297,15 +373,39 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
|
|||||||
state_id=id(multi_agent_state) if multi_agent_state else None,
|
state_id=id(multi_agent_state) if multi_agent_state else None,
|
||||||
)
|
)
|
||||||
task_coro = sub_agent.run()
|
task_coro = sub_agent.run()
|
||||||
|
try:
|
||||||
asyncio_task = self._run_coro(task_coro)
|
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
|
sub_agent._task = asyncio_task
|
||||||
self._running_tasks[task_id] = asyncio_task
|
self._running_tasks[task_id] = asyncio_task
|
||||||
# 缓存 sub_agent 实例供给多智能体模式 Poli注入使用
|
# 缓存 sub_agent 实例供给多智能体模式 Poli注入使用
|
||||||
self._sub_agent_instances[agent_id] = sub_agent
|
self._sub_agent_instances[agent_id] = sub_agent
|
||||||
|
self._save_state()
|
||||||
|
|
||||||
def _on_done(fut):
|
def _on_done(fut):
|
||||||
try:
|
try:
|
||||||
self._running_tasks.pop(task_id, None)
|
self._running_tasks.pop(task_id, None)
|
||||||
|
# 清理该子智能体的隔离待办存储
|
||||||
|
self._sub_agent_todos.pop(agent_id, None)
|
||||||
# 多智能体模式下 failed 视为可复活状态,保留实例引用供后续 send_message_to_sub_agent 重新激活
|
# 多智能体模式下 failed 视为可复活状态,保留实例引用供后续 send_message_to_sub_agent 重新激活
|
||||||
if multi_agent_mode:
|
if multi_agent_mode:
|
||||||
final_task = self.tasks.get(task_id) or {}
|
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:
|
if not self.terminal:
|
||||||
return {"success": False, "error": "子智能体管理器未绑定终端,无法执行工具"}
|
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:
|
try:
|
||||||
# 多智能体模式常见问答工具已在 SubAgentTask._execute_multi_agent_tool 中处理
|
# 多智能体模式常见问答工具已在 SubAgentTask._execute_multi_agent_tool 中处理
|
||||||
# 这里只处理实际通过主进程执行的工具
|
# 这里只处理实际通过主进程执行的工具
|
||||||
@ -993,6 +1137,19 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"[restore] 更新任务 {task_id} output 文件失败: {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()
|
task_coro = sub_agent.run()
|
||||||
asyncio_task = self._run_coro(task_coro)
|
asyncio_task = self._run_coro(task_coro)
|
||||||
sub_agent._task = asyncio_task
|
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()}"
|
system_info = f"{platform.system()} {platform.release()}"
|
||||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
return _format_template(
|
return _format_template(
|
||||||
@ -72,4 +78,5 @@ def build_system_prompt(workspace_path: str) -> str:
|
|||||||
workspace_path=workspace_path,
|
workspace_path=workspace_path,
|
||||||
system_info=system_info,
|
system_info=system_info,
|
||||||
current_time=current_time,
|
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)
|
ma_debug("refresh_task_runtime_state_idle_no_output_file", task_id=task_id)
|
||||||
return {"status": "idle", "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):
|
if self._should_force_cleanup_stale_task(task):
|
||||||
return self._mark_task_terminated(
|
return self._mark_task_terminated(
|
||||||
task,
|
task,
|
||||||
|
|||||||
@ -119,6 +119,10 @@ class SubAgentTask:
|
|||||||
self._idle = False
|
self._idle = False
|
||||||
self._pending_answer_question_id: Optional[str] = None
|
self._pending_answer_question_id: Optional[str] = None
|
||||||
self._answered_question_ids: Set[str] = set()
|
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:
|
def emit(self, type_: str, data: Dict[str, Any]) -> None:
|
||||||
"""输出一行 JSONL 到 progress 文件并缓存。"""
|
"""输出一行 JSONL 到 progress 文件并缓存。"""
|
||||||
@ -291,6 +295,9 @@ class SubAgentTask:
|
|||||||
pending_answer_question_id=self._pending_answer_question_id,
|
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)
|
assistant_message, reasoning, tool_calls, usage = await self._call_model(client, model_key, tools)
|
||||||
if usage:
|
if usage:
|
||||||
self._apply_usage(usage)
|
self._apply_usage(usage)
|
||||||
@ -486,6 +493,59 @@ class SubAgentTask:
|
|||||||
if self.multi_agent_state:
|
if self.multi_agent_state:
|
||||||
self.multi_agent_state.mark_status(self.agent_id, "idle")
|
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:
|
def inject_message(self, message_text: str) -> None:
|
||||||
"""外部向子智能体上下文插入 user 消息,并唤醒 idle 状态。"""
|
"""外部向子智能体上下文插入 user 消息,并唤醒 idle 状态。"""
|
||||||
self.messages.append({"role": "user", "content": message_text})
|
self.messages.append({"role": "user", "content": message_text})
|
||||||
@ -646,7 +706,7 @@ class SubAgentTask:
|
|||||||
result = await self._execute_multi_agent_tool(name, args)
|
result = await self._execute_multi_agent_tool(name, args)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
return result
|
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]]:
|
async def _execute_multi_agent_tool(self, name: str, args: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
"""处理多智能体模式专属的通信工具。返回 None 表示不 属于多智能体工具。"""
|
"""处理多智能体模式专属的通信工具。返回 None 表示不 属于多智能体工具。"""
|
||||||
|
|||||||
@ -49,6 +49,60 @@ if TYPE_CHECKING:
|
|||||||
class RunMixin:
|
class RunMixin:
|
||||||
"""TerminalOperator run 能力 mixin。"""
|
"""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:
|
async def check_environment(self) -> Dict:
|
||||||
"""检查Python环境"""
|
"""检查Python环境"""
|
||||||
print(f"{OUTPUT_FORMATS['info']} 检查Python环境...")
|
print(f"{OUTPUT_FORMATS['info']} 检查Python环境...")
|
||||||
@ -61,9 +115,9 @@ class RunMixin:
|
|||||||
"working_directory": str(self.project_path)
|
"working_directory": str(self.project_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
# 获取Python版本
|
# 获取Python版本(使用已检测到的 python 命令,Windows 上通常为 python/py 而非 python3)
|
||||||
version_result = await self.run_command(
|
version_result = await self.run_command(
|
||||||
'python3 --version',
|
f'{self.python_cmd} --version',
|
||||||
timeout=5
|
timeout=5
|
||||||
)
|
)
|
||||||
if version_result["success"]:
|
if version_result["success"]:
|
||||||
@ -71,7 +125,7 @@ class RunMixin:
|
|||||||
|
|
||||||
# 获取pip版本
|
# 获取pip版本
|
||||||
pip_result = await self.run_command(
|
pip_result = await self.run_command(
|
||||||
'python3 -m pip --version',
|
f'{self.python_cmd} -m pip --version',
|
||||||
timeout=5
|
timeout=5
|
||||||
)
|
)
|
||||||
if pip_result["success"]:
|
if pip_result["success"]:
|
||||||
@ -79,7 +133,7 @@ class RunMixin:
|
|||||||
|
|
||||||
# 获取已安装的包
|
# 获取已安装的包
|
||||||
packages_result = await self.run_command(
|
packages_result = await self.run_command(
|
||||||
'python3 -m pip list --format=json',
|
f'{self.python_cmd} -m pip list --format=json',
|
||||||
timeout=10
|
timeout=10
|
||||||
)
|
)
|
||||||
if packages_result["success"]:
|
if packages_result["success"]:
|
||||||
@ -244,41 +298,25 @@ class RunMixin:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
timed_out = False
|
timed_out = False
|
||||||
|
try:
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(process.wait(), timeout=timeout)
|
await asyncio.wait_for(process.wait(), timeout=timeout)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
timed_out = True
|
timed_out = True
|
||||||
try:
|
await self._interrupt_subprocess(process)
|
||||||
os.killpg(process.pid, signal.SIGINT)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(process.wait(), timeout=2)
|
await asyncio.wait_for(process.wait(), timeout=2)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
try:
|
await self._kill_subprocess(process)
|
||||||
os.killpg(process.pid, signal.SIGKILL)
|
|
||||||
except Exception:
|
|
||||||
process.kill()
|
|
||||||
await process.wait()
|
await process.wait()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
# 用户主动停止任务或会话断开,立即终止子进程
|
# 用户主动停止任务或会话断开,立即终止子进程
|
||||||
try:
|
await self._kill_subprocess(process)
|
||||||
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
|
raise
|
||||||
|
finally:
|
||||||
# 确保读取协程结束(超时场景强制收口,避免永久等待 EOF)
|
# 收口 reader 任务(正常/超时/取消路径统一执行),避免泄漏
|
||||||
|
# pending 任务及 Windows Proactor 下未关闭的管道传输层
|
||||||
|
# (Task was destroyed but it is pending / unclosed transport 告警)
|
||||||
await _finish_reader_tasks(force=timed_out)
|
await _finish_reader_tasks(force=timed_out)
|
||||||
|
|
||||||
# 非超时场景下兜底再读一次,防止剩余缓冲未被读取
|
# 非超时场景下兜底再读一次,防止剩余缓冲未被读取
|
||||||
@ -369,8 +407,8 @@ class RunMixin:
|
|||||||
"""
|
"""
|
||||||
print(f"{OUTPUT_FORMATS['terminal']} 安装包: {package}")
|
print(f"{OUTPUT_FORMATS['terminal']} 安装包: {package}")
|
||||||
|
|
||||||
# 使用通用 python3 占位,由 run_command 根据执行环境重写
|
# 使用已检测到的 python 命令(Windows 上通常为 python/py 而非 python3)
|
||||||
command = f'python3 -m pip install {package}'
|
command = f'{self.python_cmd} -m pip install {package}'
|
||||||
|
|
||||||
result = await self.run_command(command, timeout=120)
|
result = await self.run_command(command, timeout=120)
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"""User and workspace management utilities for multi-user support."""
|
"""User and workspace management utilities for multi-user support."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@ -210,6 +211,15 @@ class UserManager:
|
|||||||
link_path.symlink_to(target_path)
|
link_path.symlink_to(target_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
if not link_path.exists() and target_path.exists():
|
if not link_path.exists() and target_path.exists():
|
||||||
|
# Windows 无管理员权限/未开开发者模式时 symlink 常失败。
|
||||||
|
# 优先退回硬链接:同卷内无需特权,且两侧指向同一文件内容,
|
||||||
|
# 保持「项目间共享用户状态」的语义,不会像 copy 那样静默分叉。
|
||||||
|
try:
|
||||||
|
os.link(target_path, link_path)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 跨卷/文件系统不支持硬链接时最后退回复制(可能分叉,仅兜底)。
|
||||||
try:
|
try:
|
||||||
shutil.copy2(target_path, link_path)
|
shutil.copy2(target_path, link_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@ -102,7 +102,7 @@
|
|||||||
你拥有以下工具能力:
|
你拥有以下工具能力:
|
||||||
- read_file: 读取文件内容
|
- read_file: 读取文件内容
|
||||||
- write_file / edit_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: 搜索和提取网页内容
|
- web_search / extract_webpage: 搜索和提取网页内容
|
||||||
- read_mediafile: 读取图片/视频文件
|
- read_mediafile: 读取图片/视频文件
|
||||||
- finish_task: 完成任务并退出(必须调用)
|
- finish_task: 完成任务并退出(必须调用)
|
||||||
@ -115,6 +115,8 @@
|
|||||||
4. **不要等待输入**:你是自主运行的,不会收到用户的进一步指令
|
4. **不要等待输入**:你是自主运行的,不会收到用户的进一步指令
|
||||||
5. **注意时间限制**:超时会被强制终止,优先完成核心工作
|
5. **注意时间限制**:超时会被强制终止,优先完成核心工作
|
||||||
|
|
||||||
|
{execution_env_section}
|
||||||
|
|
||||||
# 当前环境
|
# 当前环境
|
||||||
|
|
||||||
- 工作区路径: {workspace_path}
|
- 工作区路径: {workspace_path}
|
||||||
|
|||||||
@ -396,7 +396,8 @@ def get_task_events(task_id: str):
|
|||||||
offset = int(request.args.get("from", 0))
|
offset = int(request.args.get("from", 0))
|
||||||
except Exception:
|
except Exception:
|
||||||
offset = 0
|
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
|
next_offset = events[-1]["idx"] + 1 if events else offset
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|||||||
@ -38,6 +38,7 @@ from modules.host_sandbox_policy import (
|
|||||||
save_policy,
|
save_policy,
|
||||||
get_macos_deny_read_paths,
|
get_macos_deny_read_paths,
|
||||||
get_macos_deny_read_regexes,
|
get_macos_deny_read_regexes,
|
||||||
|
get_windows_deny_read_paths,
|
||||||
)
|
)
|
||||||
from modules.user_manager import UserWorkspace
|
from modules.user_manager import UserWorkspace
|
||||||
from core.web_terminal import WebTerminal
|
from core.web_terminal import WebTerminal
|
||||||
@ -84,18 +85,44 @@ def _sync_workspace_terminal_mode(username: str, workspace, kind: str, mode: str
|
|||||||
pass
|
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]:
|
def _path_conflicts_with_deny_list(path: str) -> Optional[str]:
|
||||||
"""检查用户授权路径是否与内置 deny 列表冲突,返回错误信息或 None。"""
|
"""检查用户授权路径是否与内置 deny 列表冲突,返回错误信息或 None。"""
|
||||||
if not path:
|
if not path:
|
||||||
return None
|
return None
|
||||||
expanded = os.path.abspath(os.path.expanduser(path))
|
expanded = os.path.abspath(os.path.expanduser(path))
|
||||||
for deny_path in get_macos_deny_read_paths():
|
deny_paths = list(get_macos_deny_read_paths())
|
||||||
deny_expanded = os.path.abspath(os.path.expanduser(deny_path))
|
deny_regexes = list(get_macos_deny_read_regexes())
|
||||||
deny_lower = deny_expanded.lower().rstrip("/")
|
if os.name == "nt":
|
||||||
expanded_lower = expanded.lower().rstrip("/")
|
# Windows:补充 Windows deny 列表与系统目录(此前完全不校验)
|
||||||
if expanded_lower == deny_lower or expanded_lower.startswith(deny_lower + "/"):
|
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}"
|
return f"禁止授权敏感路径: {path}"
|
||||||
for pattern in get_macos_deny_read_regexes():
|
for pattern in deny_regexes:
|
||||||
try:
|
try:
|
||||||
if re.search(pattern, expanded) or re.search(pattern, path):
|
if re.search(pattern, expanded) or re.search(pattern, path):
|
||||||
return f"禁止授权敏感文件: {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", []),
|
"readable_extra_paths": data.get("macos_readable_extra_paths", []),
|
||||||
"deny_read_paths": data.get("macos_deny_read_paths", []),
|
"deny_read_paths": data.get("macos_deny_read_paths", []),
|
||||||
"deny_read_regexes": data.get("macos_deny_read_regexes", []),
|
"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'])
|
@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()]
|
readable_extra = [str(x).strip() for x in readable_items if str(x).strip()]
|
||||||
if "/" in writable or "/" in readable_extra:
|
if "/" in writable or "/" in readable_extra:
|
||||||
return jsonify({"success": False, "error": "禁止授权根目录 /"}), 400
|
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:
|
for p in writable + readable_extra:
|
||||||
conflict = _path_conflicts_with_deny_list(p)
|
conflict = _path_conflicts_with_deny_list(p)
|
||||||
if conflict:
|
if conflict:
|
||||||
|
|||||||
@ -41,7 +41,12 @@ def _run_project_git(project_path: Path, args: list[str]) -> tuple[bool, str]:
|
|||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
[git_bin, "-c", "core.quotePath=false", "-C", str(project_path), *args],
|
[git_bin, "-c", "core.quotePath=false", "-C", str(project_path), *args],
|
||||||
cwd=str(project_path),
|
cwd=str(project_path),
|
||||||
|
# git 输出固定为 UTF-8(core.quotePath=false 时中文路径亦为 UTF-8 字节);
|
||||||
|
# Windows text=True 默认按 GBK 解码会让 _readerthread 抛 UnicodeDecodeError,
|
||||||
|
# 导致输出被截断、侧边栏拿不到数据,必须显式指定编码。
|
||||||
text=True,
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
timeout=2,
|
timeout=2,
|
||||||
@ -59,7 +64,11 @@ def _run_project_git_raw(project_path: Path, args: list[str], timeout: int = 4)
|
|||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
[git_bin, "-c", "core.quotePath=false", "-C", str(project_path), *args],
|
[git_bin, "-c", "core.quotePath=false", "-C", str(project_path), *args],
|
||||||
cwd=str(project_path),
|
cwd=str(project_path),
|
||||||
|
# 同上:固定按 UTF-8 解码;git show 出的文件内容可能是 GBK 等旧编码,
|
||||||
|
# errors=replace 保证个别坏字节不会炸掉整个读取线程。
|
||||||
text=True,
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
|
|||||||
@ -234,7 +234,8 @@ def get_task_api(task_id: str):
|
|||||||
offset = int(request.args.get("from", 0))
|
offset = int(request.args.get("from", 0))
|
||||||
except Exception:
|
except Exception:
|
||||||
offset = 0
|
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
|
next_offset = events[-1]["idx"] + 1 if events else offset
|
||||||
elapsed_ms = (time.time() - started_at) * 1000.0
|
elapsed_ms = (time.time() - started_at) * 1000.0
|
||||||
should_log = (
|
should_log = (
|
||||||
|
|||||||
@ -213,6 +213,17 @@ class TaskManager:
|
|||||||
return None
|
return None
|
||||||
return rec
|
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]:
|
def list_tasks(self, username: str, workspace_id: Optional[str] = None) -> List[TaskRecord]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return [
|
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:
|
def _read_os_release_pretty(self) -> str:
|
||||||
"""读取 Linux 发行版信息 / Read Linux distro from os-release."""
|
"""读取 Linux 发行版信息 / Read Linux distro from os-release."""
|
||||||
|
if sys.platform != "linux":
|
||||||
|
return ""
|
||||||
path = Path("/etc/os-release")
|
path = Path("/etc/os-release")
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return ""
|
return ""
|
||||||
@ -155,7 +157,7 @@ class RuntimeMixin:
|
|||||||
return self._run_command(["sysctl", "-n", "hw.model"])
|
return self._run_command(["sysctl", "-n", "hw.model"])
|
||||||
if system == "Windows":
|
if system == "Windows":
|
||||||
model = self._run_command(
|
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:
|
if model:
|
||||||
return model
|
return model
|
||||||
|
|||||||
@ -65,7 +65,9 @@ def _debug_conversation_save_trace(conversation_id: str, file_path, data: Dict):
|
|||||||
fn = str(fr.filename)
|
fn = str(fr.filename)
|
||||||
if repo_root and not fn.startswith(str(repo_root)):
|
if repo_root and not fn.startswith(str(repo_root)):
|
||||||
continue
|
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
|
continue
|
||||||
rel = fn[len(str(repo_root)) + 1:] if repo_root and fn.startswith(str(repo_root)) else fn
|
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}")
|
frames.append(f"{rel}:{fr.lineno}:{fr.name}")
|
||||||
|
|||||||
@ -9,6 +9,8 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from utils.atomic_io import replace_with_retry
|
||||||
try:
|
try:
|
||||||
from config import DATA_DIR, HOST_WORKSPACES_FILE
|
from config import DATA_DIR, HOST_WORKSPACES_FILE
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@ -113,7 +115,9 @@ class IndexMixin:
|
|||||||
json.dump(payload, fh, ensure_ascii=False, indent=2)
|
json.dump(payload, fh, ensure_ascii=False, indent=2)
|
||||||
fh.flush()
|
fh.flush()
|
||||||
os.fsync(fh.fileno())
|
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:
|
finally:
|
||||||
if temp_path and temp_path.exists():
|
if temp_path and temp_path.exists():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -13,6 +13,8 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from utils.atomic_io import replace_with_retry
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_MIME_BY_KIND: Dict[str, str] = {
|
DEFAULT_MIME_BY_KIND: Dict[str, str] = {
|
||||||
"image": "image/png",
|
"image": "image/png",
|
||||||
@ -128,7 +130,8 @@ class MediaStore:
|
|||||||
json.dump(payload, fh, ensure_ascii=False, indent=2)
|
json.dump(payload, fh, ensure_ascii=False, indent=2)
|
||||||
fh.flush()
|
fh.flush()
|
||||||
os.fsync(fh.fileno())
|
os.fsync(fh.fileno())
|
||||||
os.replace(str(temp_path), str(target_file))
|
# Windows 瞬时持锁(并发读取/杀软扫描)重试,POSIX 行为不变
|
||||||
|
replace_with_retry(temp_path, target_file)
|
||||||
finally:
|
finally:
|
||||||
if temp_path and temp_path.exists():
|
if temp_path and temp_path.exists():
|
||||||
try:
|
try:
|
||||||
@ -194,7 +197,8 @@ class MediaStore:
|
|||||||
fh.write(payload)
|
fh.write(payload)
|
||||||
fh.flush()
|
fh.flush()
|
||||||
os.fsync(fh.fileno())
|
os.fsync(fh.fileno())
|
||||||
os.replace(str(temp_path), str(blob_path))
|
# Windows 瞬时持锁重试,POSIX 行为不变
|
||||||
|
replace_with_retry(temp_path, blob_path)
|
||||||
finally:
|
finally:
|
||||||
if temp_path and temp_path.exists():
|
if temp_path and temp_path.exists():
|
||||||
try:
|
try:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user