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:
JOJO 2026-07-31 11:31:42 +08:00
parent 3bb47ff857
commit 493ac160eb
29 changed files with 868 additions and 129 deletions

107
_bootstrap.bat Normal file
View 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 选择系统 Pythonpython -> 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

View File

@ -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_dirweb模式下 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
View File

@ -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
# 检查是否包含向上遍历

View File

@ -321,21 +321,19 @@ class BackgroundCommandManager:
except subprocess.TimeoutExpired:
status = "timeout"
message = f"命令执行超时 ({timeout}秒)"
try:
os.killpg(process.pid, signal.SIGINT)
except Exception:
pass
# 跨平台终止POSIX killpg(SIGINT→SIGKILL)Windows CTRL_BREAK→taskkill
self._terminate_pid(process.pid)
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
try:
os.killpg(process.pid, signal.SIGKILL)
process.kill()
except Exception:
try:
process.kill()
except Exception:
pass
process.wait(timeout=2)
pass
try:
process.wait(timeout=2)
except Exception:
pass
return_code = process.returncode if process.returncode is not None else -1
t_out.join(timeout=1)
@ -400,6 +398,26 @@ class BackgroundCommandManager:
normalized = self._coerce_pid(pid)
if not normalized:
return False
if os.name == "nt":
# Windows 上 os.kill(pid, 0) 不是“存活探测”,非 CTRL 事件信号会直接
# TerminateProcess 杀掉被检查的进程!必须用 OpenProcess 查询。
try:
import ctypes
kernel32 = ctypes.windll.kernel32
# PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
handle = kernel32.OpenProcess(0x1000, False, normalized)
if not handle:
return False
try:
exit_code = ctypes.c_ulong(0)
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
return False
# STILL_ACTIVE = 259
return exit_code.value == 259
finally:
kernel32.CloseHandle(handle)
except Exception:
return False
try:
os.kill(normalized, 0)
except ProcessLookupError:
@ -414,6 +432,28 @@ class BackgroundCommandManager:
normalized = self._coerce_pid(pid)
if not normalized:
return False
if os.name == "nt":
# Windows无 killpg/SIGKILL。先尝试 CTRL_BREAK_EVENT 优雅中断
# (子进程以 start_new_session=True 启动,即独立进程组),
# 超时后用 taskkill /F /T 强制终止整棵进程树,避免孙进程残留。
try:
os.kill(normalized, signal.CTRL_BREAK_EVENT)
except Exception:
pass
deadline = time.time() + 2.0
while time.time() < deadline:
if not self._is_pid_alive(normalized):
return True
time.sleep(0.05)
try:
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(normalized)],
capture_output=True,
timeout=5,
)
except Exception:
pass
return not self._is_pid_alive(normalized)
try:
os.killpg(normalized, signal.SIGINT)
except Exception:

View 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.txtbash 会把反斜杠当作转义符\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
)

View File

@ -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:

View File

@ -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):

View File

@ -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

View File

@ -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=cmdmac/Linux 同为 POSIX shell创建时快照切换由运行期通知补充
Docker 模式保持容器语义描述
"""
mode = (sandbox_mode or TERMINAL_SANDBOX_MODE or "").lower()
if IS_HOST_MODE:
runtime_label = "宿主机模式"
# host 模式下区分 sandbox / direct
if mode == "direct":
exec_label = "直接执行(完全访问权限)"
rules = (
"- 当前为宿主机直接执行模式\n"
"- 仅在必须时执行高权限操作,保持最小化命令范围\n"
"- 涉及删除/覆盖/系统级变更前,先说明风险再执行"
)
else:
exec_label = "沙箱执行"
rules = (
"- 所有命令默认在系统 OS 沙箱中执行\n"
"- 若操作受系统权限限制:先提供安全替代方案;若仍无法满足,说明需要更高权限\n"
"- 不要通过复杂绕过手段规避沙箱限制"
)
else:
runtime_label = "Docker 容器模式"
exec_label = "容器内执行"
rules = (
"- 所有命令在 Docker 容器内执行\n"
"- 工作区挂载在容器内 /workspace 路径下\n"
"- 网络可能受限,仅允许 localhost"
)
from modules.execution_env_text import build_sub_agent_env_section
return build_sub_agent_env_section(workspace_path, mode)
return (
f"## 执行环境\n\n"
f"- 运行环境:{runtime_label}\n"
f"- 执行方式:{exec_label}\n\n"
f"### 当前规则\n{rules}\n"
"## 执行环境\n\n"
"- 运行环境Docker 容器模式\n"
"- 执行方式:容器内执行\n\n"
"### 当前规则\n"
"- 所有命令在 Docker 容器内执行\n"
"- 工作区挂载在容器内 /workspace 路径下\n"
"- 网络可能受限,仅允许 localhost\n"
)

View File

@ -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.

View File

@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio
import json
import shutil
import threading
import time
from pathlib import Path, PurePosixPath
@ -81,7 +82,13 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
self._running_tasks: Dict[str, asyncio.Task] = {}
self._event_loop: Optional[asyncio.AbstractEventLoop] = None
self._loop_thread: Optional[threading.Thread] = None
# _ensure_event_loop 并发创建保护(并发 create_sub_agent 时避免创建多个事件循环)
self._loop_lock = threading.Lock()
self._state_lock = threading.Lock()
# 子智能体隔离待办存储agent_id -> todo dict。
# 子智能体的 todo_create/todo_update_task 不得写入主智能体的 todo_list
# 否则会串到主对话前端快捷菜单显示。
self._sub_agent_todos: Dict[int, Dict[str, Any]] = {}
# agent_id -> SubAgentTask 映射(供多智能体消息注入使用)
self._sub_agent_instances: Dict[int, Any] = {}
@ -103,35 +110,54 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
"""确保有一个独立的后台事件循环供子智能体使用。"""
if self._event_loop is not None and not self._event_loop.is_closed():
return self._event_loop
# 加锁 + 双重检查:并发 create_sub_agent 时避免创建多个事件循环,
# 导致任务散落在不同 loop 上、状态回调与恢复逻辑错乱
with self._loop_lock:
if self._event_loop is not None and not self._event_loop.is_closed():
return self._event_loop
loop = asyncio.new_event_loop()
self._event_loop = loop
loop = asyncio.new_event_loop()
self._event_loop = loop
def run_loop():
asyncio.set_event_loop(loop)
try:
loop.run_forever()
finally:
def run_loop():
asyncio.set_event_loop(loop)
try:
loop.close()
except Exception:
pass
loop.run_forever()
finally:
try:
loop.close()
except Exception:
pass
thread = threading.Thread(target=run_loop, name="sub-agent-loop", daemon=True)
thread.start()
self._loop_thread = thread
return loop
thread = threading.Thread(target=run_loop, name="sub-agent-loop", daemon=True)
thread.start()
self._loop_thread = thread
return loop
async def _create_task(self, coro):
"""在事件循环内部把协程包装为 Task。"""
return asyncio.create_task(coro)
def _run_coro(self, coro):
"""在后台事件循环中调度一个协程并返回 asyncio.Task。"""
"""在后台事件循环中调度一个协程并返回 asyncio.Task。
调度失败如循环线程繁忙超时时必须取消提交并关闭协程
否则协程可能在循环空闲后被幽灵执行而调用方已按失败处理
造成状态不一致曾实测超时返回失败后任务仍运行并交付结果
"""
loop = self._ensure_event_loop()
# 先提交创建 Task 的协程,阻塞等待拿到 Task 句柄
future = asyncio.run_coroutine_threadsafe(self._create_task(coro), loop)
return future.result(timeout=10)
try:
return future.result(timeout=60)
except Exception:
# 取消尚未执行的调度请求,并关闭协程避免 never-awaited/幽灵执行
future.cancel()
try:
coro.close()
except Exception:
pass
raise
def set_terminal(self, terminal: "WebTerminal") -> None:
"""注入主终端引用,用于工具执行代理。"""
@ -143,7 +169,54 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
def set_host_execution_mode(self, mode: str) -> None:
normalized = str(mode or "").strip().lower()
self.host_execution_mode = "direct" if normalized == "direct" else "sandbox"
target = "direct" if normalized == "direct" else "sandbox"
changed = target != self.host_execution_mode
self.host_execution_mode = target
if changed:
# 存活子智能体的工具调用走主终端工具链,脚下环境已实时切换;
# 注入纯上下文通知告知「语言」变化Windows 下 bash↔cmd不触发新一轮工作。
try:
self.notify_execution_mode_changed(target)
except Exception:
logger.exception("[SubAgent] 执行环境变更通知失败")
def notify_execution_mode_changed(self, mode: str) -> int:
"""执行环境切换后,向存活的多智能体子智能体注入上下文通知。
纯通知语义task.inject_notification不唤醒 idle不触发新一轮工作
运行中的子智能体在下一轮模型调用前的安全点看到
传统子智能体按既定语义不通知提示词为创建时快照任务周期短
返回成功注入的子智能体数
"""
normalized = str(mode or "").strip().lower()
if normalized not in {"sandbox", "direct"}:
return 0
from modules.execution_env_text import build_sub_agent_mode_switch_notice
text = "[系统通知|执行环境变更]\n" + build_sub_agent_mode_switch_notice(normalized)
target_cid = (
getattr(getattr(self.terminal, "context_manager", None), "current_conversation_id", None)
or getattr(self, "owner_conversation_id", None)
)
injected = 0
for inst in list(self._sub_agent_instances.values()):
try:
if not getattr(inst, "multi_agent_mode", False):
continue
inst_task = getattr(inst, "_task", None)
if inst_task is None or inst_task.done():
continue
record = self.tasks.get(getattr(inst, "task_id", "")) or {}
record_cid = record.get("conversation_id")
if target_cid and record_cid and record_cid != target_cid:
continue
inst.inject_notification(text)
injected += 1
except Exception:
logger.exception("[SubAgent] 注入执行环境变更通知失败")
if injected:
logger.info(f"[SubAgent] 执行环境切换为 {normalized},已通知 {injected} 个多智能体子智能体")
return injected
# ------------------------------------------------------------------
# 公共方法
@ -204,6 +277,8 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
try:
deliverables_path = self._resolve_deliverables_dir(deliverables_dir, multi_agent_mode=multi_agent_mode)
except ValueError as exc:
# 回滚已创建的任务目录,避免残留状态(交付目录校验失败时 deliverables 不会创建)
shutil.rmtree(task_root, ignore_errors=True)
return {"success": False, "error": str(exc)}
task_file = task_root / "task.txt"
@ -225,7 +300,8 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
if system_prompt:
final_system_prompt = system_prompt
else:
final_system_prompt = build_system_prompt(prompt_workspace)
# 快照当前执行环境写入提示词;后续切换由 notify_execution_mode_changed 补充告知
final_system_prompt = build_system_prompt(prompt_workspace, execution_mode=self.host_execution_mode)
system_prompt_file.write_text(final_system_prompt, encoding="utf-8")
# timeout_seconds 为 None 表示永久子智能体(不会被时间终结)
@ -255,11 +331,10 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
"compress_threshold_tokens": compress_threshold_tokens,
"container_name": None,
}
self.tasks[task_id] = task_record
self._mark_agent_id_used(conversation_id, agent_id)
self._save_state()
# 多智能体模式:为该会话创建或复用 MultiAgentState
# 注意状态提交self.tasks / _mark_agent_id_used / _save_state必须在
# 调度成功之后进行,否则调度失败会留下幽灵记录,且 reconcile 会在
# “记录已存在但 _running_tasks 无句柄”的窗口期把任务误标为 terminated
multi_agent_state = None
if multi_agent_mode:
multi_agent_state = self.get_or_create_multi_agent_state(conversation_id)
@ -276,6 +351,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
try:
multi_agent_state.register_instance(inst)
except ValueError:
shutil.rmtree(task_root, ignore_errors=True)
return {"success": False, "error": f"agent_id {agent_id} 已在该会话中使用"}
sub_agent = SubAgentTask(
@ -297,15 +373,39 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
state_id=id(multi_agent_state) if multi_agent_state else None,
)
task_coro = sub_agent.run()
asyncio_task = self._run_coro(task_coro)
try:
asyncio_task = self._run_coro(task_coro)
except Exception as exc:
# 调度失败:完整回滚已创建的资源,避免幽灵任务/残留状态
try:
task_coro.close()
except Exception:
pass
if multi_agent_state is not None:
try:
multi_agent_state.agents.pop(agent_id, None)
multi_agent_state.task_id_to_agent_id.pop(task_id, None)
except Exception:
pass
shutil.rmtree(task_root, ignore_errors=True)
logger.exception(f"[SubAgent] 子智能体调度失败: agent_id={agent_id}, task_id={task_id}")
return {"success": False, "error": f"子智能体调度失败(事件循环繁忙),请稍后重试: {exc}"}
# 调度成功后再提交状态self.tasks 记录与 _running_tasks 句柄同步出现,
# reconcile_task_states 任何时刻看到该记录都能拿到运行句柄,不会误标 terminated
self.tasks[task_id] = task_record
self._mark_agent_id_used(conversation_id, agent_id)
sub_agent._task = asyncio_task
self._running_tasks[task_id] = asyncio_task
# 缓存 sub_agent 实例供给多智能体模式 Poli注入使用
self._sub_agent_instances[agent_id] = sub_agent
self._save_state()
def _on_done(fut):
try:
self._running_tasks.pop(task_id, None)
# 清理该子智能体的隔离待办存储
self._sub_agent_todos.pop(agent_id, None)
# 多智能体模式下 failed 视为可复活状态,保留实例引用供后续 send_message_to_sub_agent 重新激活
if multi_agent_mode:
final_task = self.tasks.get(task_id) or {}
@ -845,11 +945,55 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
# ------------------------------------------------------------------
# 工具执行代理
# ------------------------------------------------------------------
async def execute_tool_for_sub_agent(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
def _execute_sub_agent_todo(self, tool_name: str, arguments: Dict[str, Any], agent_id: Optional[int]) -> Dict[str, Any]:
"""子智能体待办工具:写入该 agent 隔离的存储。
子智能体的 todo_create/todo_update_task 不得写入主智能体的 todo_list
否则会覆盖主对话的待办列表并串到前端快捷菜单显示
"""
from modules.todo_manager import TodoManager
class _TodoContext:
"""适配 TodoManager 所需的最小 context_manager 接口。"""
def __init__(self, store: Dict[str, Any]):
self._store = store
@property
def todo_list(self):
return self._store.get("todo")
def set_todo_list(self, todo):
self._store["todo"] = todo
key = agent_id if agent_id is not None else -1
store = self._sub_agent_todos.setdefault(key, {})
todo_manager = TodoManager(_TodoContext(store))
if tool_name == "todo_create":
return todo_manager.create_todo_list(
overview=arguments.get("overview", ""),
tasks=arguments.get("tasks", []),
)
if tool_name == "todo_update_task":
task_indices = arguments.get("task_indices")
if task_indices is None:
task_indices = arguments.get("task_index")
return todo_manager.update_task_status(
task_indices=task_indices,
completed=arguments.get("completed", True),
)
if tool_name == "todo_get":
return {"success": True, "todo_list": todo_manager.get_snapshot()}
return {"success": False, "error": f"未知待办工具: {tool_name}"}
async def execute_tool_for_sub_agent(self, tool_name: str, arguments: Dict[str, Any], agent_id: Optional[int] = None) -> Dict[str, Any]:
"""代表子智能体在主进程中执行工具。"""
if not self.terminal:
return {"success": False, "error": "子智能体管理器未绑定终端,无法执行工具"}
# 待办工具走子智能体隔离存储,避免覆盖主智能体待办并串到前端显示
if tool_name in {"todo_create", "todo_update_task", "todo_get"}:
return self._execute_sub_agent_todo(tool_name, arguments, agent_id)
try:
# 多智能体模式常见问答工具已在 SubAgentTask._execute_multi_agent_tool 中处理
# 这里只处理实际通过主进程执行的工具
@ -993,6 +1137,19 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
except Exception as exc:
logger.warning(f"[restore] 更新任务 {task_id} output 文件失败: {exc}")
# 恢复的任务提示词是创建时快照,执行环境可能已变化:
# 注入当前环境告知(纯上下文不触发工作;排序安全由 inject_notification 保证)
try:
from modules.execution_env_text import build_sub_agent_restore_notice
sub_agent.inject_notification(
"[系统通知|执行环境]\n" + build_sub_agent_restore_notice(
self._get_runtime_path(self.project_path), self.host_execution_mode
)
)
except Exception:
logger.warning(f"[restore] 任务 {task_id} 注入执行环境告知失败", exc_info=True)
task_coro = sub_agent.run()
asyncio_task = self._run_coro(task_coro)
sub_agent._task = asyncio_task

View File

@ -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),
)

View File

@ -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,

View File

@ -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_callsappend 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 表示不 属于多智能体工具。"""

View File

@ -49,6 +49,60 @@ if TYPE_CHECKING:
class RunMixin:
"""TerminalOperator run 能力 mixin。"""
async def _interrupt_subprocess(self, process) -> None:
"""超时后的优雅中断。
POSIX killpg(SIGINT) 中断整个进程组Windows 没有 killpg/SIGINT 语义
子进程以 start_new_session=True 启动映射为 CREATE_NEW_PROCESS_GROUP
可用 CTRL_BREAK_EVENT 发送到该进程组失败则直接强杀
"""
if os.name == "nt":
try:
process.send_signal(signal.CTRL_BREAK_EVENT)
except Exception:
try:
process.kill()
except Exception:
pass
return
try:
os.killpg(process.pid, signal.SIGINT)
except Exception:
try:
process.terminate()
except Exception:
pass
async def _kill_subprocess(self, process) -> None:
"""强制终止。
POSIX killpg(SIGKILL)Windows 没有 SIGKILL taskkill /F /T 终止整棵进程树
避免 shell 子进程被杀后孙进程残留为孤儿
"""
if os.name == "nt":
try:
await asyncio.to_thread(
subprocess.run,
["taskkill", "/F", "/T", "/PID", str(process.pid)],
capture_output=True,
timeout=5,
)
return
except Exception:
pass
try:
process.kill()
except Exception:
pass
return
try:
os.killpg(process.pid, signal.SIGKILL)
except Exception:
try:
process.kill()
except Exception:
pass
async def check_environment(self) -> Dict:
"""检查Python环境"""
print(f"{OUTPUT_FORMATS['info']} 检查Python环境...")
@ -61,9 +115,9 @@ class RunMixin:
"working_directory": str(self.project_path)
}
# 获取Python版本
# 获取Python版本(使用已检测到的 python 命令Windows 上通常为 python/py 而非 python3
version_result = await self.run_command(
'python3 --version',
f'{self.python_cmd} --version',
timeout=5
)
if version_result["success"]:
@ -71,7 +125,7 @@ class RunMixin:
# 获取pip版本
pip_result = await self.run_command(
'python3 -m pip --version',
f'{self.python_cmd} -m pip --version',
timeout=5
)
if pip_result["success"]:
@ -79,7 +133,7 @@ class RunMixin:
# 获取已安装的包
packages_result = await self.run_command(
'python3 -m pip list --format=json',
f'{self.python_cmd} -m pip list --format=json',
timeout=10
)
if packages_result["success"]:
@ -245,41 +299,25 @@ class RunMixin:
timed_out = False
try:
await asyncio.wait_for(process.wait(), timeout=timeout)
except asyncio.TimeoutError:
timed_out = True
try:
os.killpg(process.pid, signal.SIGINT)
except Exception:
pass
try:
await asyncio.wait_for(process.wait(), timeout=2)
await asyncio.wait_for(process.wait(), timeout=timeout)
except asyncio.TimeoutError:
timed_out = True
await self._interrupt_subprocess(process)
try:
os.killpg(process.pid, signal.SIGKILL)
except Exception:
process.kill()
await process.wait()
except asyncio.CancelledError:
# 用户主动停止任务或会话断开,立即终止子进程
try:
os.killpg(process.pid, signal.SIGKILL)
except Exception:
try:
process.kill()
except Exception:
pass
# 取消读取任务,避免孤儿任务
stdout_task.cancel()
stderr_task.cancel()
try:
await asyncio.gather(stdout_task, stderr_task, return_exceptions=True)
except Exception:
pass
raise
# 确保读取协程结束(超时场景强制收口,避免永久等待 EOF
await _finish_reader_tasks(force=timed_out)
await asyncio.wait_for(process.wait(), timeout=2)
except asyncio.TimeoutError:
await self._kill_subprocess(process)
await process.wait()
except asyncio.CancelledError:
# 用户主动停止任务或会话断开,立即终止子进程
await self._kill_subprocess(process)
raise
finally:
# 收口 reader 任务(正常/超时/取消路径统一执行),避免泄漏
# pending 任务及 Windows Proactor 下未关闭的管道传输层
# Task was destroyed but it is pending / unclosed transport 告警)
await _finish_reader_tasks(force=timed_out)
# 非超时场景下兜底再读一次,防止剩余缓冲未被读取
if not timed_out:
@ -369,8 +407,8 @@ class RunMixin:
"""
print(f"{OUTPUT_FORMATS['terminal']} 安装包: {package}")
# 使用通用 python3 占位,由 run_command 根据执行环境重写
command = f'python3 -m pip install {package}'
# 使用已检测到的 python 命令Windows 上通常为 python/py 而非 python3
command = f'{self.python_cmd} -m pip install {package}'
result = await self.run_command(command, timeout=120)

View File

@ -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:

View File

@ -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 / findWindows 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}

View File

@ -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,

View File

@ -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:

View File

@ -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-8core.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,

View File

@ -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 = (

View File

@ -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
View 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
View 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
View File

@ -0,0 +1,46 @@
"""原子写文件工具temp file + os.replace 的 Windows 加固版。
背景Windows 上目标文件被任何句柄以默认共享模式打开时msvcrt 默认共享读/
不含 FILE_SHARE_DELETEos.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)

View File

@ -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

View File

@ -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}")

View File

@ -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/32replace_with_retry 做短退避重试POSIX 行为不变)
replace_with_retry(temp_path, target_file)
finally:
if temp_path and temp_path.exists():
try:

View File

@ -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: