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", "")) _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_dirweb模式下 runtime_dir 指向 web 预设目录 # 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 _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
View File

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

View File

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

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

View File

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

View File

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

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: 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() 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"
) )

View File

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

View File

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

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()}" 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),
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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/32replace_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:

View File

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