From 8770df8d84d3d7b127b6242b0593bcca49581de8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 21:17:35 +0200 Subject: [PATCH 01/57] =?UTF-8?q?docs:=20=E6=8C=89=20dev=20=E5=9F=BA?= =?UTF-8?q?=E7=BA=BF=E9=87=8D=E5=86=99=E8=B7=A8=E4=BB=93=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 登记决策 D12(AUTO-MAS 侧配合改造基线由 dev_v2 改为 dev 3c422093), D4 标注为已由 D12 取代并保留原文追溯;第 5.1 节按接入契约 P-1~P-8 重写, 新增 MaaFW 专项 TODO-PY-9~13。 Co-Authored-By: Claude Opus 5 --- ...73\345\212\241\346\213\206\345\210\206.md" | 113 ++++++++++++++---- 1 file changed, 88 insertions(+), 25 deletions(-) diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index 66fb85a..e64b6db 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -43,7 +43,7 @@ | D1 | 首版 CI/CD 只构建并发布**未签名** exe | 用户 2026-07-27 | | D2 | ~~版本索引签名方案定为 ed25519 自研小工具,延后到 M8 实施~~(已作废,被 D6 取代) | 用户 2026-07-27 | | D3 | `auto-mas-runtime.exe` 本体首版**不做**代码签名;SignPath 接入列为延后任务 T7.5 | 用户 2026-07-27 | -| D4 | AUTO-MAS 侧配合改造以 **dev_v2 分支**为基线(health 接口、PluginManager、pyproject.toml 均已在该分支) | 用户 2026-07-27 | +| D4 | **历史决策:AUTO-MAS 侧配合改造以 dev_v2 分支为基线**(health 接口、PluginManager、pyproject.toml 均已在该分支);该基线**已由 D12 取代**,保留本行用于追溯 | 用户 2026-07-27 | | D5 | 架构基线为 `doc/架构设计.md`(与 AUTO-MAS 仓库 `docs/superpowers/specs/2026-07-27-auto-mas-runtime-architecture.md` 同源) | 探索确认 | | D6 | **版本索引与验签体系整体移除,不实现**(含 Commit 钉扎、`cmd/indextool`、内置公钥)。信任模型=HTTPS + `release/<完整版本>` 分支命名约定(发布后不可移动)+ 克隆后仓库版本文件校验;该安全等级由用户确认接受 | 用户 2026-07-27 | | D7 | **目标平台扩展为 Windows + Linux + macOS**:Windows 为首要平台(首版正式发布仍仅 Windows),Linux 只适配少数最易适配的主流发行版,macOS 范围待定(清单与范围见 D-open-10);既有 M0~M9 的 Windows 契约、设计与验收全部不变,跨平台适配立项为 M11(设计先行)。详见 `doc/架构设计.md`「平台支持策略」 | 用户 2026-08-04 | @@ -51,6 +51,7 @@ | D9 | **历史决策:接入 Sentry + PostHog 遥测与错误观测**:曾规划官方 Cloud 首发和可替换 host/DSN/project key;具体方案已由 2026-08-12 的当前决策调整,保留本行用于追溯 | 用户 2026-08-11 | | D10 | **历史决策:M12 改用 Sentry + 自建 Umami**:曾移除 PostHog 并设计 Umami endpoint/website ID;该方案已由 D11 取消,仅保留本行用于追溯 | 用户 2026-08-12 | | D11 | **当前 M12 仅保留 Sentry**:取消 Umami provider、endpoint、website ID、事件 schema 与发布配置;T12.3 不执行,T12.5/T12.6 仅覆盖 Sentry;保留 Sentry DSN 缺失 no-op、`AUTO_MAS_TELEMETRY=disabled`/`--offline` 零网络、白名单净化、失败静默和 500 ms flush 语义 | 用户 2026-08-12 | +| D12 | **AUTO-MAS 侧配合改造基线由 dev_v2 改为 dev(`3c422093`)**,取代 D4。原因:dev_v2 自 2026-08-06 停更,与 dev 在 2026-07-29 分叉后各走了四百多个提交;原清单点名的 `app/plugins/uv_backend.py`、`PluginManager`、`pluginBootstrapService.ts` 在 dev 上都不存在(dev 没有插件系统)。第 5.1 节按 dev 现状重写,第 4 章左列与 5.2/5.3 仍为 dev_v2 时期内容,待后续单独重排 | 用户 2026-08-31 | D6 的推论(重要):`workspace sync` 按内部模板 `release/<完整版本>` 解析分支,校验「远端来源 + 分支名 + 仓库版本文件」,不做 Commit 钉扎;`VERSION_INDEX_DOWNLOAD_FAILED`、`VERSION_INDEX_SIGNATURE_INVALID`、`VERSION_NOT_FOUND`、`GIT_COMMIT_MISMATCH` 错误码与 `workspace.index` stage 已从协议移除;`doc/架构设计.md` 已同步修订(见其「分支信任模型」章节)。 @@ -908,6 +909,8 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 ## 4. 新旧职责映射(迁移对照) +> 本表左列写于 dev_v2 基线时期,尚未按 D12 重排。其中 `pluginBootstrapService.ts` / `PluginManager` 一行**不适用于 dev 基线**(dev 无插件系统,见 5.1 TODO-PY-7);其余各行的替代关系不受基线变更影响。 + | AUTO-MAS 现有实现(dev_v2 基线) | 替代者 | | --- | --- | | `frontend/electron/services/environmentService.ts` `PythonInstaller`(embed 包解压) | Runtime `bootstrap` 内 `uv python install`(T5.3) | @@ -923,41 +926,100 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 --- -## 5. AUTO-MAS 侧配合改造 TODO(基线:dev_v2 分支,决策 D4) +## 5. AUTO-MAS 侧配合改造 TODO(基线:dev 分支 `3c422093`,决策 D12) > 本章任务在 AUTO-MAS 仓库的本地检出中执行。编号规则:TODO-PY(Python 后端)、TODO-EL(Electron/Vue)、TODO-CI(发布 CI)。 > 与架构文档分阶段计划的对应:TODO-PY ≈ 阶段 1,TODO-EL ≈ 阶段 5/6,TODO-CI ≈ 阶段 0。 > 每条完成后同样打勾并注明日期 + AUTO-MAS 侧 commit。 +> +> **基线(D12):** AUTO-MAS `dev@3c422093`,外加尚未合入的 MaaFW 内置层分支 `work/maafw-embedded-20260830@1561bc55`。 +> 5.1 的文件与行号取自《AUTO-MAS 侧接入契约(dev 基线)》——该文档写于 `dev@18dfad77`,行号已按 `3c422093` 全量重校; +> 本章引用的 Runtime 侧行号对应本仓库 `40ef464`。 +> 5.2 与 5.3 尚未按 D12 重排,见各节开头说明。 ### 5.1 Python 后端(阶段 1) -- [ ] **TODO-PY-1 health 接口扩展与字面量对齐** - - 位置:`app/api/core.py`(`BackendHealthOut` / `get_health`) - - 内容:增加 `protocol` / `version` / `commit` 字段;supervised managed 模式分别回显 `AUTO_MAS_RUNTIME_PROTOCOL` / `AUTO_MAS_EXPECTED_VERSION` / `AUTO_MAS_EXPECTED_COMMIT`,不使用 GitPython 重新推断;失败字面量固定为 `"failed"`,状态全集为 `starting/running/ready/failed/cancelled`;无监督兼容启动返回自身协议/版本诊断值和空 Commit;`version` 值来源治理:`res/version.json`、`app/core/config.py` 的 `VERSION` 硬编码、`pyproject.toml` 的 `version`(现滞后为 5.2.0)三处收敛为单一来源。 -- [ ] **TODO-PY-2 移除 UAC 自提权(监督模式硬冲突)** - - 位置:`main.py`(`is_admin()` / `ShellExecuteW(..., "runas", ...)` 分支) - - 内容:`AUTO_MAS_SUPERVISED=1` 时**禁止**自提权重启——提权产生的新进程会脱离 Runtime 的 Job Object,监督与清理全部失效;权限不足时记录明确错误并以非零状态退出,由桌面端安装包的 `requireAdministrator` 保证初始权限(配合 TODO-EL-9)。未受监督兼容路径可暂时保留旧行为。 -- [ ] **TODO-PY-3 uv 注入消费与自装逻辑移除** - - 位置:`app/plugins/uv_backend.py` - - 内容:`_find_uv()` 改为**优先读取** `AUTO_MAS_UV_EXE` 环境变量(现状:Electron 已注入、`_set_cached_uv()` 会写该变量,但查找逻辑从不读取);删除 `install_uv()`(后端用 PowerShell 在线装 uv,违反架构约束「后端不得自行下载或升级 uv」);注入缺失/不可用时通过健康状态与日志上报插件系统初始化失败,不得自装。 -- [ ] **TODO-PY-4 工作目录与 repo/ 布局改造** - - 位置:`main.py`(sys.path、静态资源挂载)、`app/core/config.py`(目录创建、GitPython 路径)等按 `Path.cwd()` 解析源内资源的位置 - - 内容:目标布局=cwd 为 AUTO-MAS 根目录、源码位于 `repo/` 子目录(Runtime 以该布局启动 `uv run --project repo`)。源内资源(`res/images`、`res/sounds`、`res/html`、模板等)改为相对源码位置(`__file__`)解析;用户数据(`config/ data/ debug/ history/ script/`)与插件目录(`plugins/`)保持相对 cwd(即留在根目录,仓库替换边界之外);替代现行 Electron `copyToRoot` 把 `.git/app/res/main.py` 复制进根目录的部署方式。 +> 与接入契约第 02 节 P 段编号的对应:TODO-PY-1→P-1、-2→P-2、-3→P-4、-4→P-5、-5→P-7、-6→P-6、-7→不适用、-8→P-3。 +> 编号沿用原清单,避免既有引用(`doc/契约补充-v1.md`「跨仓库落实点」等)悬空;内容与落点已整体换成 dev 现状。 + +- [ ] **TODO-PY-1 health 三字段与版本单源** + - 位置:`app/api/core.py:51-72`(`BackendHealthOut` / `get_health`)、`.github/workflows/check-version-json.yml:80-90`、`pyproject.toml` + - 内容:`BackendHealthOut` 增加 `protocol`(int)/ `version`(str)/ `commit`(str)。受监督 managed 下 `version` 与 `commit` 原样回显 `AUTO_MAS_EXPECTED_VERSION` / `AUTO_MAS_EXPECTED_COMMIT`,不用 GitPython 反推;`protocol` 返回**后端自身支持的协议版本**(固定 `1`)而非回显注入值,见 [协议 v1 契约补充 增补 1](./契约补充-v1-增补1.md#c7受监督标记对端口与开发环境标记的优先级) 对 C2 第 1 条的修订。development 下返回自身协议、治理后的版本和空 `commit`。 + - 状态字面量已合规(`starting/running/ready/failed/cancelled`,见 `main.py:181-253`),不需要改。版本一致性 CI 已经卡住三处,唯独 `pyproject.toml` 不在校验里(现为 `5.5.0b2`),本次一并纳入——Runtime 校验的是 `repo/res/version.json`,而 `uv sync` 读的是 `pyproject.toml`,两者脱节会让「装上的版本」和「报告的版本」不一致。 +- [ ] **TODO-PY-2 受监督时禁止自提权** + - 位置:`main.py:75-94,151` + - 内容:`AUTO_MAS_SUPERVISED=1` 时不得走 `ShellExecuteW(..., "runas", ...)`——提权产生的新进程会脱离 Job Object,Runtime 会误判后端已退出,而真后端在外面占着 36163。权限不足时**记 warning 并继续运行**,不再要求非零退出(见 [增补 1](./契约补充-v1-增补1.md#c7受监督标记对端口与开发环境标记的优先级) 对 C4 第 1 条的修订:Runtime 本就不把管理员作为健康条件,硬退出会让非管理员终端下的 development 联调整体不可用)。 + - 全仓 `AUTO_MAS_SUPERVISED` 当前 0 命中,本条是纯新增实现,没有存量分支可改。 +- [ ] **TODO-PY-3 uv 一律走注入路径,禁止自装** + - 位置:`app/task/MaaFW/tools/core/automas_maafw_agent_env/env.py:687-691`(**仅此一处**) + - 内容:原清单点名的 `app/plugins/uv_backend.py` 在 dev 不存在,本条语义整体保留、落点换成 MaaFW 的 uv 发现链。运行池那处(`.../automas_maafw_runtime_pool/installer.py:795-814`)**已由 PR #478 做好注入优先**——`AUTO_MAS_UV_EXE` 排在最前,其后才是 bootstrap 同级目录 → 便携 uv → `shutil.which("uv")`;内置层跑的正是运行池这条,所以受管模式的主路径不会因为找不到 uv 而失败。剩下的 `agent_env/env.py` 仍只有 `Path.cwd()/environment/python/Scripts/uv.exe` → `shutil.which("uv")` 两级,需改为优先读 `AUTO_MAS_UV_EXE`,注入缺失时经健康状态与日志显式报错,不得自行下载或安装 uv。 + - 要留意的是回退链本身在受管模式下整体失效:`environment/` 不存在,Runtime 不把自己的 uv 加进 PATH(宿主 PATH 原样继承,只覆盖 `UV_*` 与监督键,见 `internal/uv/runner.go:486`),而 pip 兜底同样走不通——受管 venv 默认不带 pip。所以注入缺失必须显式失败,不能静默降级。 + - 若第一层 runner 将来被清理,本条会自然消失;动手前先确认该落点还有没有活着的调用方。 +- [ ] **TODO-PY-4 源内资源与用户数据分离** + - 依赖:[增补 1 C6](./契约补充-v1-增补1.md#c6后端进程工作目录与入口路径)(后端 cwd = app-root) + - 位置:`main.py:34` 的 `os.chdir(current_dir)`,以及按 `Path.cwd()` 解析源内资源的 17 处 + - 内容:`os.chdir(current_dir)` 让 cwd 恒等于源码目录,今天两套路径完全重合,接入后必须拆开:受监督时跳过该 chdir,由 Runtime 把 cwd 设为 app-root。实际要动的比想象少——9 处 `res/` 改为相对 `__file__` 解析(静态挂载两处、jinja loader、通知图标、`res/MaaFW` bundle、MaaEnd 模板与图像根、`res/version.json`、okww 词表);8 处 `environment/` 全部作废(便携 python ×3、便携 uv ×2、便携 git、hpatchz 缓存;前五处并入 TODO-PY-3,hpatchz 缓存需另找落点);其余 `config/` `data/` `debug/` `history/` 保持相对 cwd 不动。 + - dev 全仓 `Path.cwd()` / `os.getcwd()` 共 196 处 / 55 文件,真正要迁的只有上述 17 处。这个量级差正是 C6 选「Runtime 改 cwd」而不是「Python 逐个改锚点」的依据。 - [ ] **TODO-PY-5 移除整包更新职责** - - 位置:`app/services/update.py`(`_UpdateHandler` 全部)、`app/api/update.py`(`/download` `/install` `/cancel-download` `/switch-to-cnb`)、`main.py`(启动时清理 `AUTO-MAS-Setup.exe` 残留的逻辑) - - 内容:下载更新包、解压、注册表清理、拉起 Inno Setup 安装器的职责整体移除(桌面整包更新归 Electron 更新流程,后端源码更新归 Runtime);`/api/update/check`(MirrorChyan 版本查询)是否保留给前端展示为产品决策(见 D-open-4);Electron 切换完成前(TODO-EL-7 灰度期)允许旧入口保持兼容,正式切换后删除。 -- [ ] **TODO-PY-6 锁定环境前置(架构文档 TODO 清单落地)** - - 位置:仓库根 - - 内容:新增 `.python-version`(固定 Python 补丁版本,与 uv.lock 的 `requires-python == 3.12.*` 一致);`pyproject.toml` 的 `version` 字段与 `res/version.json` 对齐并纳入发布流程维护;确认 `uv.lock` 与 `pyproject.toml` 一致(dev 分支现存在 lock 与 manifest 脱节问题);核查 `.gitignore` 不再忽略两文件;明确「开发分支更新锁文件、发布分支只消费锁文件」的维护流程并写入贡献文档。 -- [ ] **TODO-PY-7 插件 bootstrap 收归 PluginManager** - - 位置:`app/plugins/manager.py`;参照 dev_v2 的 `frontend/electron/services/pluginBootstrapService.ts` - - 内容:把 Electron `PluginBootstrapService` 的系统插件 bootstrap 语义(`SYSTEM_BOOTSTRAP_PACKAGES`、解析 `pyproject.toml` `[tool.auto-mas.plugin-bootstrap]`、装到 `plugins/pypi/site-packages`、状态文件)移入 Python `PluginManager` 启动流程;uv 调用统一走注入的 `AUTO_MAS_UV_EXE`(TODO-PY-3);插件失败不进入 Runtime 的 `environment_broken`,由后端自行决定阻止初始化/禁用/降级并经健康状态与日志上报。 -- [ ] **TODO-PY-8 close 与开发模式行为对齐** - - 位置:`app/api/core.py`(`close`)、`app/services/system.py`(`KillSelf`) - - 内容:`AUTO_MAS_SUPERVISED=1` 的优先级高于 `AUTO_MAS_DEV=1`;受监督时(含 development 模式)close 必须真实设置 `Config.server.should_exit`,仅「未受监督且启用 AUTO_MAS_DEV 的裸开发进程」可保留忽略逻辑。 + - 位置:`app/services/update.py`(668 行)、`app/api/update.py` 的 `/download` `/install` `/cancel-download` `/switch-to-cnb`、`app/core/config.py:318-333` + - 内容:下载更新包、解压、注册表清理、拉起 Inno Setup 安装器的职责整体下线,后端源码更新归 `workspace sync`;`/api/update/check` 是否保留给前端展示仍是产品决策(D-open-4)。Electron 灰度期(TODO-EL-7)允许旧入口保持兼容,正式切换后删除。 + - 附注两点:拉起安装器那处用的 `detached_flags` 同样逃不出 Job(见 [增补 1 C8](./契约补充-v1-增补1.md#c8job-object-逃逸与游戏模拟器的进程归属)),在 Runtime 下它本来也活不到安装那一步;`app/core/config.py:318-333` 是全仓唯一的 GitPython 用法,只喂 `/api/info` 的版本展示,里面还有一次 `origin.fetch()`——受管模式下 `repo/` 是单分支浅克隆,这次 fetch 既无意义又慢,顺手改成读注入的身份字段。 +- [ ] **TODO-PY-6 锁定环境入库** + - 位置:仓库根、`.github/workflows/`、`CONTRIBUTING.md` + - 内容:提交 `uv.lock` 与 `.python-version`(建议对齐 Runtime 夹具分支的 `3.12.13`);把 `pyproject.toml` 的 `version` 并进版本闸门(TODO-PY-1);发布前加 `uv lock --check`,但不许由该闸门切发布分支。 + - **已实测**:dev 的 `pyproject.toml` 直接 `uv lock` → 105 包 / 1.17 s / 171 KB;Runtime 实际执行的 `uv sync --locked --no-default-groups --no-install-workspace` dry-run 通过;`.gitignore` 没有忽略这两个文件,不需要先删规则。 + - `requirements.txt` 暂时留着——Electron 旧链路还在读它,灰度期两份必须手工保持一致,切换完成后再删。另需给贡献文档补开发环境搭建说明:现在 `CONTRIBUTING.md` / `AGENTS.md` 里一条环境搭建指令都没有,而 development 模式硬要求 `/.venv` 已存在(Runtime 只消费不创建,见 C5)。 + - 锁文件在哪个索引上生成由 [增补 1 C10](./契约补充-v1-增补1.md#c10主项目依赖的镜像轮换) 定稿:**在 PyPI 上生成**,国内可达性由 Runtime 侧的锁内 URL 改写轮换解决,不靠换索引生成锁。 +- [ ] **TODO-PY-7 (不适用于 dev 基线)插件 bootstrap 收归 PluginManager** + - 处置:**不适用**。dev 没有插件系统——无 `app/plugins/`、无 `app/core/plugins/`、`plugins/` 下无内容、Electron 也没有 `pluginBootstrapService.ts`。保留编号只为避免既有引用悬空。若 dev_v2 的插件系统日后并回 dev,本条与 TODO-PY-3 一起重算。 + - 注意:`doc/架构设计.md`「插件环境职责边界」描述的是**能力边界**(Runtime 不读插件声明、不解析插件依赖、不维护插件安装清单),该边界与本条是否适用无关,不随之作废;它同时是 TODO-PY-13 里「Runtime 只提供基础设施」的依据。 +- [ ] **TODO-PY-8 受监督标记的优先级** + - 位置:`app/api/core.py:75-84,116-135`、`main.py:43,114,142`、`.env.example` + - 内容:`AUTO_MAS_SUPERVISED=1` 的优先级高于 `AUTO_MAS_DEV` 与 `AUTO_MAS_ENV=development`:close 必须真实设置 `Config.server.should_exit`;端口固定回到 36163,忽略 `AUTO_MAS_HTTP_PORT` 与任何开发环境判据(见 [增补 1 C7](./契约补充-v1-增补1.md#c7受监督标记对端口与开发环境标记的优先级))。只有「未受监督且显式开了开发标记」的裸进程保留现有的忽略行为。 + - 背景:dev 已合入的 PR #451 让开发环境走 36164,判据是仓库根的 `.env` 或 `AUTO_MAS_ENV`,与契约 C1 直接冲突;同一个标记还让 `/api/core/close` 只做轻量清理、不设 `should_exit`。 + - **这条要连着 `.env.example` 的说明一起改**,否则开发者照着文档建了 `.env`,第一次用 development 模式联调就会莫名其妙失败。 + +#### MaaFW 专项(内置层合入前后都要满足) + +> 对应接入契约第 03 节 M-1~M-5 与第 04 节 T-1~T-4。编号继续用 `TODO-PY-` 前缀(MaaFW 属于 Python 后端,阶段归属不变),每条额外标注**落点**。 +> 内置层分支 `work/maafw-embedded-20260830@1561bc55` 尚未合入 dev,且**没有删掉第一层 runner**,所以下列落点在两条分支上都成立;第一层清理另开 PR 时需同步核对。 + +- [ ] **TODO-PY-9 Agent venv 的基解释器会被 Runtime 删掉** + - 落点:AUTO-MAS + - 位置:`app/task/MaaFW/tools/core/automas_maafw_agent_env/env.py:694-697`、`Config.clean_maafw_agent_venvs()` + - 内容:隔离 venv 的引导解释器顺序是「便携 python → `sys.executable` → PATH python」。受管模式下便携版没了,落到 `sys.executable`,也就是 `runtime/environment/venv/Scripts/python.exe`——而这正是 Runtime `dependencies rebuild` / `repair` 会整个删掉重建的目录。重建之后,`config/maafw_agent_venvs/` 下所有 venv 的 `pyvenv.cfg` 都指向一个不存在的 home。 + - 要求:Agent venv 在使用前必须校验基解释器仍然存在,失效则重建;`Config.clean_maafw_agent_venvs()` 已有回收入口,把这条判据加进去即可。uv 兜底路径(`uv venv --seed`)同样要改吃 `AUTO_MAS_UV_EXE`(TODO-PY-3)。 +- [ ] **TODO-PY-10 让出 `runtime/` 目录名** + - 落点:AUTO-MAS + - 位置:`app/task/MaaFW/tools/embedded/runner_task.py:1072`(`Path.cwd()/"runtime"/"maafw_runner_jobs"`)、`app/task/HSR/tools/sra_runtime.py:733-744`(`config_path.parent/"runtime"/"hsr"`)、`.gitignore` + - 内容:Runtime 把 `/runtime/` 当自己的地盘(`tools/uv`、`environment/python`、`environment/venv`、`cache/`),后端已经在同名目录下写 MaaFW 的 job 落盘和 HSR 的 sra-config。**注意这个冲突是 C6 引入的**:今天后端 cwd 是 `repo/`,两者其实在 `repo/runtime/` 与 `/runtime/` 上分开;cwd 改成 app-root 之后才真正同名同层。 + - 要求:把这两处挪进 `data/`,`runtime/` 整个让给 Runtime,并带一次旧路径迁移。今天双方不会互删(cleanup 只碰 `runtime/cache` 下三个具体子目录和 `repo/**/__pycache__`),但同名不同义迟早出事,趁没发布改最便宜。 +- [ ] **TODO-PY-11 DirectExe 拉起的 PC 游戏在 Job 里** + - 落点:两侧(与 T13.2 成对生效) + - 依赖:[增补 1 C8](./契约补充-v1-增补1.md#c8job-object-逃逸与游戏模拟器的进程归属) + - 位置:`app/utils/platform/windows/process.py:8`(`creation_flags = CREATE_NO_WINDOW`)、`app/task/general/manager.py:219`、`app/task/MaaFW/tools/controller/game_lifecycle.py:232-236` + - 内容:按 C8 定稿——游戏与模拟器**不随后端退出**。AUTO-MAS 只在拉起游戏/模拟器时追加 `CREATE_BREAKAWAY_FROM_JOB`,自己的 worker / Agent 子进程不带(仍在 Job 内,随后端一起回收);Runtime 侧同步给 Job 加 `JOB_OBJECT_LIMIT_BREAKAWAY_OK`(T13.2),**两边缺一不可**。`DETACHED_PROCESS` 解决不了这件事,它只脱离控制台、不脱离 Job。 + - 这不是 MaaFW 专有问题:`app/task/general/manager.py:219` 用同一个 `ProcessManager` 拉起 general 类型的游戏进程,dev 今天就已经成立;MaaFW 的 `DirectExe` 只是又多一个调用方。因此本条即使内置层不合入也要做。 + - 验收里必须单独跑一次「跑着游戏关 AUTO-MAS」,这是用户最容易踩到、也最容易归因错的一种表现。 +- [ ] **TODO-PY-12 停止链路要在关闭预算内收敛** + - 落点:AUTO-MAS(测量数据同时是 T13.3 的输入) + - 依赖:[增补 1 C9](./契约补充-v1-增补1.md#c9关闭预算参数化) + - 内容:MaaFW 的停止链路最长——停 Agent 子进程、回收 worker、落 job 状态、写项目 manifest。dev 刚合的 PR #491 就是在修「中止任务后残留 Agent 进程」,说明这条链路本身还在收敛中。受监督下它被压在关闭预算里,超时即 Job 硬杀,manifest 和 `.staging` 目录可能停在半路。 + - 要求:实测「MaaFW 任务运行中收到 close」的耗时,作为 T13.3 调默认值的输入数据。若超预算,优先把「回包 + 开始退出」与「任务清理」解耦,而不是让 Runtime 无限等。同时测冷启动到 `backgroundStatus=ready` 的耗时(含 MCP 挂载、活动关卡网络请求、历史清理、ArknightWin32 初始化、主定时器、可选 Koishi 连接),对照 60 秒就绪预算;`app/core/config.py` 的 `get_stage` 活动关卡请求未显式设超时,是这段里最可疑的一项。 +- [ ] **TODO-PY-13 运行池接受管基础设施** + - 落点:两侧(与 T13.5 成对;Runtime 侧提供目录与镜像源,AUTO-MAS 侧消费) + - 依赖:[增补 1 C11](./契约补充-v1-增补1.md#c11maafw-运行池的基础设施共享与镜像源下发)、内置层已合 + - 位置:`.../automas_maafw_runtime_pool/installer.py`、`pool.py`、`cache.py` + - 现状(契约 M-5):MaaFW 在 `config/maafw_runtime_pool/` 下自己维护解释器池和 uv 缓存,接入后会与 Runtime 的受管 Python 并存——两份 Python、两份 uv 缓存、两套互不相通的镜像开关;池目录在 `config/` 下按 Runtime 的分类属于「用户业务数据,自动更新绝不能删除」,可里面其实是可重建的 venv、解释器和缓存,坏掉之后没有任何受管的修复入口。 + - 要求(契约 T-1~T-4 的 AUTO-MAS 侧):池的 `--cache-dir` 改指 `AUTO_MAS_UV_CACHE_DIR`、`--install-dir` 改指 `AUTO_MAS_UV_PYTHON_INSTALL_DIR`;镜像改吃 Runtime 下发的 `AUTO_MAS_MIRROR_PACKAGE_INDEX` / `AUTO_MAS_MIRROR_PYTHON` 有序列表并按序重试(现在只有单值 `AUTO_MAS_UV_INDEX_URL` / `AUTO_MAS_UV_PYTHON_INSTALL_MIRROR`,没有轮换);池目录按新分类拆开,venv/解释器/缓存纳入 `repair` / `cleanup`,manifest 与信任基线仍归用户数据。 + - **必须保留的两条**:身份探针里真的 `import ctypes`(代码注释记着真机漏过一次,探测全绿、worker 才在 `maa/library.py` 第 1 行炸掉);`UV_LINK_MODE=hardlink` 要求缓存与环境同卷,合并目录时要一起验。 + - 已经不用担心的:Runtime 注入的 `UV_CACHE_DIR` / `UV_PYTHON_INSTALL_DIR` 不会劫持池——池对两者都传显式命令行 flag,命令行覆盖环境变量。待验证:Runtime 注入的 `UV_MANAGED_PYTHON=1` 会不会影响池的「项目自带解释器」那条路——池的 `_clean_process_environment` 只剔除 `PYTHONHOME` / `PYTHONUSERBASE` / `PYTHONPATH` / `PIP_*`,`UV_*` 不在剔除名单里。 ### 5.2 Electron / Vue(阶段 5/6) +> 基线同 5.1(D12),本次未重写;待 R 段(Runtime 侧 M13)定稿后重排。 +> dev 与 dev_v2 在 Electron 侧的差异比 Python 侧小,但 `pluginBootstrapService.ts` 等 dev_v2 专有落点同样不适用于 dev。 + - [ ] **TODO-EL-1 Runtime 客户端模块(新增)** - 内容:以「可执行文件路径 + 参数数组」spawn `auto-mas-runtime.exe`(严禁拼 shell 字符串);`hello` 握手与协议版本校验;NDJSON 逐行解析;按 `operationId` 聚合 `log` 事件(分 stdout/stderr);生成调用侧错误:`RUNTIME_NOT_FOUND / RUNTIME_SPAWN_FAILED / RUNTIME_HANDSHAKE_TIMEOUT / RUNTIME_PROTOCOL_ERROR / RUNTIME_PROTOCOL_MISMATCH / RUNTIME_EXITED_UNEXPECTEDLY`;TypeScript 判别联合类型定义事件全集。 - [ ] **TODO-EL-2 初始化编排替换** @@ -1040,6 +1102,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 | 日期 | 变更 | 操作者 | | --- | --- | --- | +| 2026-08-31 | 登记决策 **D12** 并按 dev 基线重写第 5.1 节:AUTO-MAS 侧配合改造基线由 dev_v2 改为 `dev@3c422093`(dev_v2 自 2026-08-06 停更,两条分支自 07-29 分叉后各走四百余提交),D4 标注为已由 D12 取代并保留原文追溯。5.1 的 TODO-PY-1~8 编号沿用、内容与落点整体换成 dev 现状:**TODO-PY-3** 目标文件 `app/plugins/uv_backend.py` 在 dev 不存在,落点改为 MaaFW 的 `automas_maafw_agent_env/env.py:687-691`(运行池那处已由 AUTO-MAS PR #478 做好 `AUTO_MAS_UV_EXE` 注入优先);**TODO-PY-7 不适用于 dev 基线**(dev 无插件系统:无 `app/plugins/`、无 `app/core/plugins/`、`plugins/` 下无内容、Electron 无 `pluginBootstrapService.ts`),保留编号避免引用悬空,dev_v2 插件系统若并回则与 TODO-PY-3 一起重算;其余各条按契约第 02 节 P-1~P-8 收窄或扩写。5.1 末尾新增「MaaFW 专项」小节 **TODO-PY-9~13**(取自契约第 03 节 M-1~M-5 与第 04 节 T-1~T-4,逐条标注落在 AUTO-MAS、Runtime 还是两侧)。第 4 章加注左列仍为 dev_v2 时期内容且插件一行不适用;5.2 加注基线同 5.1、待 R 段定稿后重排;5.3 未改动。本次只改文档,不涉及任何 Go 代码或已冻结契约 | Claude | | 2026-08-27 | 完成 T7.6(实现 `8a2bbfd`):后续 Release 的裸 EXE 在现有 `auto-mas-runtime` 名称后追加 release tag,格式为 `auto-mas-runtime-.exe`;不追加日期、时间或 Commit hash;package、checksum、publish 与 smoke 全链路消费同一个动态文件名,安装后的稳定运行名仍为 `auto-mas-runtime.exe`;红/绿灯、100 次发布契约复跑、PowerShell AST、真实 EXE 和标准门均取得本地证据,未创建 tag/Release 或 push | Codex | | 2026-08-12 | 按用户要求修订 T7.2/T7.3 后续发布契约:Release 不再生成 zip,直接发布 `auto-mas-runtime.exe` 与覆盖该 EXE 的 `SHA256SUMS.txt`;smoke 直接下载并运行 EXE。既有 `v0.1.0-beta.2` ZIP 验收记录保留为历史证据 | Codex | | 2026-08-11 | M7 完成:T7.2/T7.3 在 `9d7cb9a` 收口为原生 Node.js 24 的 upload v7/download v8/release v3;测试 prerelease `v0.1.0-beta.2` 的 annotated tag、Release run `31407585577`、三 job、资产 checksum、严格 NDJSON 和无弃用注释远端验收全绿;T7.4 保持跳过,T7.5 原样迁入延后任务并保留 D3/D-open-7;设计归档、计划删除,最终标准门与独立双视角复审通过 | Codex | From 788eb1ee9d0667cfbf1f389d727d697c57df6a28 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 21:21:30 +0200 Subject: [PATCH 02/57] =?UTF-8?q?docs:=20=E5=AE=9A=E7=A8=BF=20dev=20?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF=E6=8E=A5=E5=85=A5=E7=9A=84=E5=85=AD=E6=9D=A1?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E5=A2=9E=E8=A1=A5=20(C6~C11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 doc/契约补充-v1-增补1.md:后端工作目录、受监督标记对端口的优先级、 Job Object 逃逸、关闭预算参数化、主项目依赖的锁内 URL 改写轮换、 MaaFW 运行池的基础设施共享与镜像源下发;并修订 C2 第 1 条与 C4 第 1 条。 协议版本保持 v1。 Co-Authored-By: Claude Opus 5 --- doc/README.md | 5 +- ...5\205\205-v1-\345\242\236\350\241\2451.md" | 296 ++++++++++++++++++ ...347\272\246\350\241\245\345\205\205-v1.md" | 8 + 3 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 "doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" diff --git a/doc/README.md b/doc/README.md index b7d98a0..4914ee9 100644 --- a/doc/README.md +++ b/doc/README.md @@ -8,12 +8,13 @@ | 文档 | 用途 | | --- | --- | | [架构设计](./架构设计.md) | 系统边界、对外契约、目录安全、命令树和验收标准 | -| [协议 v1 契约补充](./契约补充-v1.md) | 后端端口、身份注入、健康检查等具体契约 | +| [协议 v1 契约补充](./契约补充-v1.md) | 后端端口、身份注入、健康检查等具体契约(C1~C5) | +| [协议 v1 契约补充 增补 1](./契约补充-v1-增补1.md) | 对 v1 的增量修订:工作目录、受监督优先级、Job 逃逸、关闭预算、依赖镜像、运行池基础设施(C6~C11) | | [任务拆分](./任务拆分.md) | 任务依赖、验收、决策和当前进度 | | [代码审查清单](./代码审查清单.md) | 自动化门禁之外的架构边界检查 | | [T1.3 生命周期设计](./设计-T1.3-生命周期状态机.md) | 协议测试直接读取的生命周期契约夹具 | -权威优先级仍为:`契约补充-v1.md` > `架构设计.md` > `任务拆分.md`。 +权威优先级仍为:`契约补充-v1-增补1.md` > `契约补充-v1.md` > `架构设计.md` > `任务拆分.md`。 ## 按用途浏览 diff --git "a/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" "b/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" new file mode 100644 index 0000000..875ca00 --- /dev/null +++ "b/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" @@ -0,0 +1,296 @@ +# AUTO-MAS Runtime 协议 v1 契约补充 增补 1 + +## 文档状态 + +- 日期:2026-08-31 +- 状态:已定稿 +- 适用协议:Runtime protocol v1(**不升级协议版本**) +- 上级文档:[协议 v1 契约补充](./契约补充-v1.md) +- 架构基线:[架构设计](./架构设计.md) +- 实现基线:Runtime `40ef464`;AUTO-MAS `dev@3c422093` 与 MaaFW 内置层 `work/maafw-embedded-20260830@1561bc55`(决策 D12) + +本文档是对 [契约补充-v1](./契约补充-v1.md) 的**增量修订**,定稿六项此前未冻结或与实现矛盾的对外契约,并修订 C2、C4 各一条。 +`契约补充-v1.md` 开头已规定「对外字段、字面量或环境变量发生变化时必须升级或增量修订共同契约和测试」,本文档走的正是增量修订这条路: +六项都不新增、删除或改名协议字段,也不改变任何 `stage` / `state` / 错误码字面量,因此协议版本保持 `1`。 + +优先级:**本文档 > `契约补充-v1.md` > `架构设计.md` > `任务拆分.md`**。本文档与 `契约补充-v1.md` 冲突时以本文档为准;未被本文档触及的条目一律沿用原文。 + +行号出处:Runtime 侧行号在本仓库 `40ef464` 上逐条核对;AUTO-MAS 侧行号取自《AUTO-MAS 侧接入契约(dev 基线)》,该文档写于 `dev@18dfad77`、行号已按 `3c422093` 全量重校。 + +## 决策总览 + +| 编号 | 事项 | v1 结论 | +| --- | --- | --- | +| C6 | 后端进程工作目录 | managed 下子进程 cwd = app-root,入口传绝对路径 `/repo/main.py`;development 不变 | +| C7 | 受监督标记的优先级 | 扩展到端口:受监督时固定 36163,忽略一切开发环境标记;并修订 C2 第 1 条与 C4 第 1 条 | +| C8 | Job Object 逃逸 | 游戏与模拟器**不随后端退出**;Runtime 开 `BREAKAWAY_OK`,AUTO-MAS 只在拉起游戏/模拟器时带 `CREATE_BREAKAWAY_FROM_JOB` | +| C9 | 关闭预算 | `backend supervise` 新增关闭超时选项,默认值保持 5 秒 | +| C10 | 主项目依赖的镜像 | 锁文件在 PyPI 上生成;`dependencies sync` 改写锁内下载地址前缀参与镜像轮换,**不覆盖包索引** | +| C11 | MaaFW 运行池的归属 | 只统一基础设施:Runtime 开放共享 uv 缓存与受管 Python 目录、下发有序镜像源;「装什么」仍留在后端 | + +--- + +## C6:后端进程工作目录与入口路径 + +### 结论 + +1. **managed 模式**:Runtime 创建的后端子进程工作目录固定为 **app-root**(`--app-root` 指向的 AUTO-MAS 根目录),不再是 uv 的 project dir;入口参数改传绝对路径 `/repo/main.py`,不再传裸 `main.py`。`--project` 仍指向 `/repo`。 +2. **development 模式**:不变,cwd 仍为 `--repo` 指定的源码目录,入口沿用现有形态。 +3. **Python 侧配合**:受监督时(`AUTO_MAS_SUPERVISED=1`)跳过 `main.py` 的 `os.chdir(current_dir)`,让 cwd 保持 Runtime 设定的值;源内资源改为相对 `__file__` 解析,用户数据继续相对 cwd 解析。 + +### 依据 + +架构文档本就要求 app-root,实现偏离了文档: + +- `doc/架构设计.md`「目录模型」:「后端始终从固定的 `repo` 路径启动,注意需设定其工作目录为项目根目录」;「插件环境职责边界 → Runtime 只负责」第 3 条:「把后端工作目录设为 AUTO-MAS 项目根目录」; +- `internal/uv/managed.go:83` — `Dir: resolved.ProjectDir`,`RunOptions` 没有独立的工作目录字段,managed 下 `ProjectDir` 即 `/repo`; +- `internal/backend/supervisor.go:203-207` — argv 为 `run --project --no-sync main.py`,`ProjectDir` 同样是 `s.layout.RepoDir()`。 + +按现实现,后端会把 `config/ data/ history/ script/ debug/` 建在 `repo/` 里,而 `workspace sync` 每次整体替换该目录——**首次自动更新就会静默删掉全部用户数据**。这同时违反架构文档「用户配置、数据库、日志、下载内容和其他需要跨版本保留的数据必须位于 `repo` 外部」和红线第 6 条。 + +### 为什么改 Runtime 而不是改 Python + +AUTO-MAS `dev` 全仓 `Path.cwd()` / `os.getcwd()` 共 196 处、分布在 55 个文件里,其中真正需要迁移的只有 17 处(9 处 `res/` 资源 + 8 处已作废的 `environment/`)。让其余各处继续指向根目录,改动面比逐个改锚点小一个量级。 + +### 后果与配套 + +- `repo/` 成为纯源码目录,`workspace sync` 的整体替换语义不再有数据损失风险; +- cwd 改到 app-root 后,后端的 `runtime/` 与 Runtime 的 `/runtime/` 才**真正同名同层**(此前分别是 `repo/runtime/` 与 `/runtime/`,并不冲突)。因此本条直接触发 `任务拆分.md` TODO-PY-10「让出 `runtime/` 目录名」,两者必须一起落地; +- `--no-sync` 与「后端启动阶段不得隐式修改环境或访问包索引」不受影响。 + +### 落点 + +Runtime `T13.1`(`internal/uv/managed.go`、`internal/backend/supervisor.go`、`internal/backend/control.go` 与对应 E2E);AUTO-MAS `TODO-PY-4`、`TODO-PY-10`。 + +--- + +## C7:受监督标记对端口与开发环境标记的优先级 + +### 结论 + +`AUTO_MAS_SUPERVISED=1` 的优先级高于**一切**开发环境标记,不限于关闭行为: + +1. **端口**:受监督时后端固定监听 `36163`,忽略 `AUTO_MAS_HTTP_PORT` 以及任何开发环境判据(仓库根的 `.env`、`AUTO_MAS_ENV=development`)。C1 的固定端口在受监督路径上无例外。 +2. **关闭**:沿用 C4 第 2 条,受监督 development 收到 `POST /api/core/close` 必须真实设置 `Config.server.should_exit`。 +3. 只有「未受监督且显式启用开发标记」的裸进程保留现有的端口与忽略关闭行为。 + +Runtime 侧无需改代码——它本就只连 `127.0.0.1:36163`、只注入 `AUTO_MAS_SUPERVISED=1`,本条是把既有行为写成契约。AUTO-MAS 侧按 `TODO-PY-8` 实现。 + +### 依据 + +AUTO-MAS 已合入的 PR #451 让开发环境走 36164,判据是仓库根的 `.env` 或 `AUTO_MAS_ENV`(`main.py:43,114,142`、`.env.example`),与 C1 直接冲突;同一个标记还让 `/api/core/close` 只做轻量清理、不设 `should_exit`(`app/api/core.py:75-84,116-135`)。开发者一旦按 `.env.example` 建了 `.env`,第一次 development 联调就会因为 Runtime 连 36163、后端听 36164 而失败。 + +### 修订 C4 第 1 条:受监督且权限不足时不再要求非零退出 + +原文要求「受监督且当前权限不足时……必须记录明确错误并以非零状态退出」。**修订为:记录 warning 并继续运行**,`ShellExecuteW(..., "runas", ...)` 自提权仍然禁止(提权产生的新进程会脱离 Job Object,Runtime 会误判后端已退出,而真后端在外面占着 36163)。 + +理由: + +1. 与 hosted 路径一致——Runtime 本就**不把是否管理员作为健康成功条件**(C4 第 4 条,本次不变); +2. 硬退出会让非管理员终端下的 development 联调整体不可用,而 development 的目的正是监督开发者已有的环境; +3. 权限不足的真实后果由具体功能在需要时报错,比在启动期一刀切更准确。 + +C4 第 1 条中「不得自提权」的部分保持不变,只有失败处置从「非零退出」改为「warning 并继续」。 + +### 修订 C2 第 1 条:`protocol` 返回后端自身支持的协议版本 + +原文要求「把三项 Runtime 注入值解析并原样返回」。**修订为**: + +- `version` 与 `commit` 仍原样回显 `AUTO_MAS_EXPECTED_VERSION` / `AUTO_MAS_EXPECTED_COMMIT`,不调用 GitPython 或 Git 命令重新推断(这部分不变); +- `protocol` 返回**后端自身支持的协议版本**(当前固定整数 `1`),不回显 `AUTO_MAS_RUNTIME_PROTOCOL`。 + +理由:今天两者相同,回显与自报在线上无差别;但将来协议升级时,只有自报才能检出不兼容——Runtime 注入 `2` 而后端仍只支持 `1` 时,回显会伪造出一个「兼容」的假象。 + +C2 第 3 条的 Runtime 侧比较逻辑**不变**(三项仍精确比较,任一缺失、类型错误或不相等都返回 `BACKEND_IDENTITY_MISMATCH`),但 `protocol` 一项的语义从「回显核对」变为「协议兼容性检出」。C2 第 4 条 development 下的规则本就是「返回自身支持的协议整数」,本次修订使两种模式在该字段上语义一致。 + +### 落点 + +Runtime:无代码改动,仅契约登记与测试断言口径(`T13.1` 的 E2E 顺带覆盖);AUTO-MAS `TODO-PY-1`、`TODO-PY-2`、`TODO-PY-8`。 + +--- + +## C8:Job Object 逃逸与游戏、模拟器的进程归属 + +### 结论 + +**保持今天的用户可见行为:关闭 AUTO-MAS 时,正在运行的模拟器与 PC 游戏不跟着关。** 为此两侧同时改,缺一不可: + +1. **Runtime**:Job Object 的 `LimitFlags` 增加 `JOB_OBJECT_LIMIT_BREAKAWAY_OK`,与既有 `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` 并存; +2. **AUTO-MAS**:**只在**拉起游戏与模拟器时追加 `CREATE_BREAKAWAY_FROM_JOB`;自己的 worker、Agent 与其他子进程**不带**该标志,仍留在 Job 内,随后端一起回收。 + +`DETACHED_PROCESS` 不能替代 `CREATE_BREAKAWAY_FROM_JOB`——它只脱离控制台,不脱离 Job Object。文档与实现都不得把两者混用。 + +`BREAKAWAY_OK` 只是**允许**子进程在显式请求时脱离,不改变任何未请求脱离的进程的归属:Runtime 「进程树只能通过自己创建的 Job Object 回收」「禁止按进程名批量终止」两条约束不变(红线第 5 条),`details.pid` 与 uv/Python 受管树的证明方式也不变。 + +### 依据 + +- `internal/process/job_windows.go:35` — `info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`,没有任何 breakaway 位; +- AUTO-MAS 拉起模拟器、脚本和 PC 游戏时只带 `CREATE_NO_WINDOW`:`app/utils/platform/windows/process.py:8`、`app/task/general/manager.py:219`(general 类型游戏进程)、`app/task/MaaFW/tools/controller/game_lifecycle.py:232-236`(内置层的 `DirectExe`)。 + +**这不是 MaaFW 引入的问题。** `app/task/general/manager.py:219` 用的是同一个 `ProcessManager`,在 dev 上今天就成立;MaaFW 的 `DirectExe` 只是又多一个调用方,而且还在未合入的内置层分支上。因此本条是**全脚本类型的通用契约**,不能挂在 MaaFW 名下推迟处理。 + +### 后果 + +不改的话,Runtime 收 Job 时会连带杀掉所有被 AUTO-MAS 拉起的模拟器与游戏——这对 PC 游戏用户是明显的体验回退,且极容易被归因成游戏自身崩溃。改了之后,脱离出去的游戏进程不再受 Runtime 回收,其生命周期由 AUTO-MAS 自身的任务逻辑负责。 + +### 落点 + +Runtime `T13.2`(`internal/process/job_windows.go` 与对应 Windows E2E);AUTO-MAS `TODO-PY-11`。两侧任何一侧单独上线都不会产生预期行为,验收必须跨仓联合执行「跑着游戏关 AUTO-MAS」。 + +--- + +## C9:关闭预算参数化 + +### 结论 + +1. `backend supervise` 新增关闭超时选项,**默认值保持 5 秒**,行为与今天完全一致; +2. 取值为正整数秒,合法范围 `1`~`120`,越界按参数错误处理并映射 `INVALID_ARGUMENT`(退出码 2,不可重试); +3. 默认值**待 AUTO-MAS 侧实测数据到位后再调**,本次不改; +4. 就绪预算(总启动超时 60 秒、轮询 500 毫秒、单次请求 2 秒、连续成功 2 次)本次**不参数化**,保持编译期常量。 + +关闭流程的其余语义不变:超时后仍关闭 Job Object 兜底,确认进程树已清空即输出 `BACKEND_FORCE_TERMINATED` 警告并正常完成关闭。 + +### 依据 + +- `internal/backend/control.go:20` — `defaultShutdownTimeout = 5 * time.Second`,编译期常量,CLI 无任何开关(`defaultRestartDelay = 2 * time.Second` 同理); +- `internal/health/checker.go:21-24` — `defaultTotalTimeout = 60 * time.Second` 等同样是常量。 + +AUTO-MAS 的 `_shutdown_backend()` 要跑 `TaskManager.stop_task("ALL")` 加完整 teardown;MaaFW 正在跑任务时还要停 Agent 子进程、回收 worker、落 job 状态、写项目 manifest(dev 刚合的 PR #491 正是在修「中止任务后残留 Agent 进程」,说明这条链路本身还在收敛)。**5 秒够不够没人量过**——这正是本条只加开关、不动默认值的原因:在有数据之前改默认值等于用猜测替换猜测。 + +配合 C8,关闭超时的代价不只是「关得糙」:超时会走 Job 硬杀,而 breakaway 之后游戏虽然安全,AUTO-MAS 自己的 worker 与 Agent 仍会被硬杀,manifest 和 `.staging` 目录可能停在半路。 + +### 落点 + +Runtime `T13.3`(`internal/backend/control.go`、`internal/cli/backend.go` 与参数校验测试);数据来源为 AUTO-MAS `TODO-PY-12`。 + +--- + +## C10:主项目依赖的镜像轮换 + +### 结论 + +**锁文件在 PyPI 上生成;`dependencies sync` 通过改写锁文件副本内的下载地址前缀参与镜像轮换,而不是覆盖包索引。** + +1. **锁文件生成**:AUTO-MAS 发布 CI 在 PyPI 上生成 `uv.lock`,不使用任何国内镜像生成锁。 +2. **`uv lock --check` 阶段不变**:对 `repo/uv.lock` 的**原锁**执行,不带任何包索引覆盖参数;在线沿用项目与锁文件 sources,显式离线只注入 `UV_OFFLINE=1`。锁一致性校验的对象始终是原锁。 +3. **`uv sync` 阶段改为镜像改写轮换**,按既有轮换规则依次尝试 `KindPackageIndex` 目录中的源,每个源执行: + 1. 读取 `repo/uv.lock` 到内存,做两处**纯字符串前缀改写**: + - `https://pypi.org/simple` → `<镜像 base>/simple` + - `https://files.pythonhosted.org/packages/` → `<镜像 base>/packages/` + 2. 把改写后的锁与 `repo/pyproject.toml` 一起放进受管根内的**临时项目目录**; + 3. `UV_PROJECT_ENVIRONMENT` 指向真实受管 venv(临时目录只提供项目元数据,不产生第二个环境); + 4. 执行 `uv sync --frozen --no-default-groups --no-install-workspace`; + 5. 失败则换下一个源,回到第 1 步。 +4. **回退**:全部镜像失败后,用**原锁**(PyPI 直连)再执行一次;`--mirror-only` 时不做这次回退,按既有语义映射 `MIRROR_EXHAUSTED`。`--offline` 时完全不改写,沿用原锁并只注入 `UV_OFFLINE=1`。 +5. **不改写 `repo/` 内的任何文件。** 原锁与 `pyproject.toml` 始终只读;「不允许正式安装过程隐式生成或修改锁文件」这条约束因此保持成立。 +6. **临时项目目录**必须位于受管根内、由 Runtime 创建并在操作结束时删除,归入数据分类中的「可丢弃缓存」,不得落在 `repo/`、用户数据目录或受管根之外。 +7. `dependencies.sync` 的事件 `details` 必须报告本次实际使用的 package-index 源 key(回退原锁时报告官方源 key)。不新增 `stage`、`state` 或错误码。 + +### 镜像 base 的推导与新增源的准入 + +镜像 base = 目录中该源 `baseURL` 去掉结尾 `simple/` 之后的前缀。例如 `https://mirrors.aliyun.com/pypi/simple/` → base `https://mirrors.aliyun.com/pypi`,改写后的两个前缀分别是 `https://mirrors.aliyun.com/pypi/simple` 与 `https://mirrors.aliyun.com/pypi/packages/`。 + +**新增 package-index 源必须同时提供 `/simple/` 与 `/packages/` 两种布局**,否则不得进入轮换目录——只有 simple 索引可用的源无法参与本机制。 + +### 安全性由 uv 自身保证 + +改写只动**下载位置**,锁内每个 artifact 的 `sha256` 原样保留;uv 在 `--frozen` 安装时对每个 artifact 校验 hash,镜像若返回了不同的字节就会直接拒装。因此「换源」在本机制下不可能改变实际安装的内容,这与轮换规则第 7 条「切换源不能改变目标版本、uv 版本、Python 版本或锁文件」在**语义上**一致——该条对本机制的准确表述是:**不改变锁文件所固定的包集合、版本与哈希**。 + +### 依据 + +- `internal/uv/dependencies.go:234-242` 明确拒绝为主项目依赖覆盖包索引(`INVALID_ARGUMENT`「锁定依赖不支持覆盖包索引」)。**这条拒绝逻辑保留**:本机制不是覆盖索引,`--locked` 校验仍对原锁做。显式 `--mirror package-index=` 继续返回 `INVALID_ARGUMENT`;轮换按目录的默认顺序自动进行,不由调用方指定首选源。 +- `internal/mirror/defaults.go` 定义了 4 个 `KindPackageIndex` 源(`aliyun` / `tsinghua` / `ustc` / 官方 `pypi`),而唯一的消费方拒绝使用它们——这套镜像能力现在是**死代码**,本条正是它的正当用途。 +- 2026-07-29 AUTO-MAS 曾把 uv 整体回滚(`5af4f219` 被 `cee68e27` 回滚,同日连带回滚锁文件 gitignore 与 uv 下载镜像 PR #310)。回滚原因是**没给用户做镜像多路重试,而多数用户网络特殊**。不解决这条就是重蹈覆辙;而 `uv.lock` 现在是 managed 模式的硬前提(没有它就没有 `uv sync --locked`),所以这次要决的不是「要不要用 uv」,而是怎么让受限网络下的用户装得上。 + +### 实验依据(2026-08-31,uv 0.12.1) + +1. `--locked` + `--default-index <镜像>` 直接报「锁需更新」——**证明不能靠换索引解决**; +2. 清华、阿里云、中科大、腾讯云四家对 `/packages/` 的布局完全一致:同一 sdist 均 HTTP 200、字节数相同; +3. 小项目的 PyPI 锁改写到清华后,`--frozen` 真装成功,`-v` 日志显示实际从 `pypi.tuna.tsinghua.edu.cn/packages/...` 拉取; +4. 篡改锁内一个 `sha256` 后 `--frozen` 拒装、exit 1——**证明 hash 校验确实生效**; +5. 真实 AUTO-MAS dev 的 `pyproject.toml` 放在无 README / LICENSE 的临时目录 + 阿里云改写锁,`--frozen --dry-run` 通过——**证明临时项目目录只需 `pyproject.toml` 与改写后的锁两个文件**(`--no-install-workspace` 排除了根项目本身,因此不需要 README、LICENSE 或源码)。 + +### 落点 + +Runtime `T13.4`;AUTO-MAS `TODO-PY-6`(锁文件在 PyPI 上生成、入库与 `uv lock --check` 闸门)。 +可选加速项「Full 安装包预置 uv 缓存 + `cleanup` 区分预置与运行期缓存」降级为 `T13.6`,优先级低于 T13.4。 + +--- + +## C11:MaaFW 运行池的基础设施共享与镜像源下发 + +### 结论 + +**整体接管不可行,只统一基础设施;「装什么」仍然留在后端。** + +Runtime 向后端开放: + +1. **共享 uv 缓存目录**——经 `AUTO_MAS_UV_CACHE_DIR` 注入,主项目与 MaaFW 运行池共用一份 wheel 缓存; +2. **共享受管 Python 安装目录**——经 `AUTO_MAS_UV_PYTHON_INSTALL_DIR` 注入,两边的 `uv python install` 共用一份解释器; +3. **解析后的有序镜像源列表**——经 `AUTO_MAS_MIRROR_PACKAGE_INDEX` 与 `AUTO_MAS_MIRROR_PYTHON` 注入,后端**自行按序重试**; +4. **池目录的重新分类**——venv、解释器、缓存归「可重建」,纳入 `repair` / `cleanup`;manifest 与信任基线归「用户业务数据」,自动流程绝不删除。 + +Runtime **不**读取 MaaFW 项目的依赖声明,**不**解析池依赖,**不**维护池的安装清单,**不**决定装哪个 Python 版本或哪些包,也**不**新增池专用命令、stage 或错误码。 + +### 为什么不整体接管 + +两套模型在四个维度上结构性相反: + +| | Runtime 主项目 | MaaFW 运行池 | +| --- | --- | --- | +| 环境数量 | 1 个受管 venv | N 个,取决于用户装了多少 MaaFW 项目 | +| Python 版本 | `.python-version` 钉死一个补丁版本 | 每个项目自带约束,池同时支持两个 minor | +| 依赖声明 | CI 生成的 `uv.lock` | 项目 `interface.json` 的 `runtime.python.requires`,加项目自带 `requirements*.txt` 里正则匹配出的约束 | +| 解析时机 | CI 时,装机零解析 | **运行时**,在线解析 | +| 安装方式 | `uv sync --locked` | `uv pip install` 自由解析,装完记一份 `resolvedRequirements` 快照 | + +让 Runtime 接管等于把它改造成通用包管理器:要去解析第三方项目的依赖声明、同时供养多个 Python 版本、维护 N 份安装清单。这与「插件环境职责边界」写死的「Runtime 不读取插件声明,不解析插件依赖,不维护插件安装清单」正面冲突,也踩红线第 4 条。 + +### 并行两套的代价(本条要消除的) + +- 两份 Python:Runtime 的 `runtime/environment/python` 与池的 `/python`; +- 两份 uv 缓存:`runtime/cache/uv` 与 `/cache/uv`,wheel 不复用。合并时注意 `UV_LINK_MODE=hardlink` 要求缓存与环境**同卷**; +- 两套镜像开关互不相通:Runtime 有 `KindUV` 5 源、`KindPython` 2 源、`KindPackageIndex` 4 源的多路轮换;池只有单值环境变量(`AUTO_MAS_UV_INDEX_URL` / `AUTO_MAS_UV_PYTHON_INSTALL_MIRROR`),没有轮换; +- 数据分类错位:池在 `config/` 下,按 Runtime 的分类属于「用户业务数据,自动更新绝不能删除」,可里面其实是可重建的 venv、解释器和缓存,坏掉之后没有任何受管的修复入口。 + +### 与 C10 的关系 + +池的安装**不是 locked**(`uv pip install` 自由解析),用镜像不破坏任何不变量,因此镜像源列表可以直接下发给后端按序重试;这与 C10 对主项目采取的「改写锁内 URL」是两种不同机制,各自适配各自的约束,都不涉及为锁定依赖覆盖包索引。 + +### 落点 + +Runtime `T13.5`;AUTO-MAS `TODO-PY-13`。`架构设计.md`「插件环境职责边界」已增补「Runtime 提供基础设施,不决定装什么」的表述。 + +--- + +## 新增注入环境变量 + +以下四个变量由 Runtime 在启动后端时注入,与 C2 的五个变量同属受监督进程的环境契约。**变量名与取值格式属于已冻结的对外契约**,改动须先改文档(红线第 2 条)。 + +| 环境变量 | managed 模式 | development 模式 | 取值 | +| --- | --- | --- | --- | +| `AUTO_MAS_UV_CACHE_DIR` | 必填 | 必填 | 受管 uv 缓存目录的规范化绝对路径 | +| `AUTO_MAS_UV_PYTHON_INSTALL_DIR` | 必填 | 必填 | 受管 Python 安装目录的规范化绝对路径 | +| `AUTO_MAS_MIRROR_PACKAGE_INDEX` | 必填 | 必填 | Python 包索引的有序源列表 | +| `AUTO_MAS_MIRROR_PYTHON` | 必填 | 必填 | Python 分发源的有序源列表 | + +命名与格式规则: + +1. 目录类变量沿用 `AUTO_MAS_UV_EXE` 的 `AUTO_MAS_UV_*` 家族,与它对应的 uv 参数一一对照:`AUTO_MAS_UV_CACHE_DIR` → `--cache-dir`,`AUTO_MAS_UV_PYTHON_INSTALL_DIR` → `--install-dir`; +2. 镜像类变量用 `AUTO_MAS_MIRROR_`,`` 取 `internal/mirror` 已冻结的 `Kind` 字面量并把连字符换成下划线、转大写:`package-index` → `PACKAGE_INDEX`,`python` → `PYTHON`。**`KindUV` 与 `KindGit` 不注入**——后端没有下载 uv 或 Git 的职责; +3. 列表以 `;` 分隔,按 Runtime 解析后的**尝试顺序**排列,官方源在末位(`--mirror-only` 时不含官方源,`--offline` 时注入空串)。每项是绝对 HTTPS URL,其中不得出现未编码的 `;`; +4. 变量名**刻意不叫** `AUTO_MAS_UV_INDEX_URLS`:AUTO-MAS 侧已有单值的 `AUTO_MAS_UV_INDEX_URL`,只差一个字母的两个变量是排障陷阱。同理,Runtime 不通过直接注入 `UV_INDEX` 等 `UV_*` 变量下发镜像——运行池的 `_clean_process_environment` 不剔除 `UV_*`,那样做等于劫持池未显式覆盖的 uv 行为; +5. 后端读到列表后**自行按序重试**。Runtime 不代替后端执行池的安装,也不感知重试结果。 + +## 跨仓库落实点 + +| 编号 | Runtime 侧 | AUTO-MAS 侧 | +| --- | --- | --- | +| C6 | T13.1 | TODO-PY-4、TODO-PY-10 | +| C7 | 无代码改动,测试断言口径随 T13.1 | TODO-PY-1、TODO-PY-2、TODO-PY-8 | +| C8 | T13.2 | TODO-PY-11 | +| C9 | T13.3 | TODO-PY-12(提供实测数据) | +| C10 | T13.4、T13.6(可选加速) | TODO-PY-6 | +| C11 | T13.5 | TODO-PY-13 | + +验收:三关联调(development 一轮 → managed 全链路升降级各一轮 → MaaFW 真跑)见 `doc/任务拆分.md` M13 各任务的「验收」条目;C8 与 C9 的实际后果尚未实机验证,第三关就是为了验它们。 diff --git "a/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1.md" "b/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1.md" index 364d68c..982faa6 100644 --- "a/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1.md" +++ "b/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1.md" @@ -7,9 +7,12 @@ - 适用协议:Runtime protocol v1 - 架构基线:[架构设计](./架构设计.md) - 实现基线:AUTO-MAS `origin/dev_v2@3a4e2a65` +- **增量修订:[增补 1](./契约补充-v1-增补1.md)(2026-08-31,定稿 C6~C11,并修订本文档 C2 第 1 条与 C4 第 1 条)——本文档与增补冲突时以增补为准** 本文档定稿任务 T1.0 的五个原待决项。若本文档与架构设计的概括性描述存在歧义,以本文档中更具体的 v1 规则为准;对外字段、字面量或环境变量发生变化时必须升级或增量修订共同契约和测试。 +实现基线 `dev_v2` 已按决策 D12 改为 `dev@3c422093`;本文档的 C1~C5 结论不受基线变更影响,受影响的具体行为差异全部登记在增补 1。 + ## 决策总览 | 编号 | 事项 | v1 结论 | @@ -60,6 +63,7 @@ Runtime 启动后端时使用以下环境变量: 规则如下: 1. 在 supervised managed 模式下,后端不调用 GitPython 或 Git 命令重新推断身份,而是把三项 Runtime 注入值解析并原样返回;Runtime 在启动进程前已从已验证仓库得出版本和 HEAD。 + **本条已由 [增补 1 C7](./契约补充-v1-增补1.md#c7受监督标记对端口与开发环境标记的优先级) 修订:`version` 与 `commit` 仍原样回显,`protocol` 改为返回后端自身支持的协议版本(当前固定 `1`),不回显注入值——只有自报才能在将来协议升级时检出不兼容。第 3 条的比较逻辑不变。** 2. `protocol` 是 JSON 整数;`version` 和 `commit` 是 JSON 字符串。 3. managed 模式下,Runtime 对三项值做精确比较;任一字段缺失、类型错误或不相等都返回 `BACKEND_IDENTITY_MISMATCH`。 4. development 模式下,后端返回自身支持的协议整数、治理后的单一版本源值和空 `commit`。Runtime 只要求 `protocol` 兼容,不比较 `version` 或 `commit`,不能据此拒绝脏工作区。 @@ -90,7 +94,9 @@ AUTO_MAS_SUPERVISED=1 Python 后端只把精确字符串 `"1"` 识别为受监督模式,并遵守以下规则: 1. 受监督且当前权限不足时,不得通过 `ShellExecuteW(..., "runas", ...)` 创建脱离 Job Object 的新进程;必须记录明确错误并以非零状态退出。 + **本条已由 [增补 1 C7](./契约补充-v1-增补1.md#c7受监督标记对端口与开发环境标记的优先级) 修订:禁止自提权不变,但权限不足的处置改为「记录 warning 并继续运行」,不再要求非零退出——Runtime 本就不把管理员作为健康条件(第 4 条),硬退出会让非管理员终端下的 development 联调整体不可用。** 2. `AUTO_MAS_SUPERVISED=1` 对关闭行为的优先级高于 `AUTO_MAS_DEV=1`。受监督 development 模式收到 `POST /api/core/close` 时必须真实设置 `Config.server.should_exit`。 + **[增补 1 C7](./契约补充-v1-增补1.md#c7受监督标记对端口与开发环境标记的优先级) 把该优先级扩展到端口:受监督时后端固定监听 36163,忽略 `AUTO_MAS_HTTP_PORT` 与一切开发环境标记(`.env`、`AUTO_MAS_ENV`)。** 3. 只有未受监督且显式启用 `AUTO_MAS_DEV` 的裸开发进程可以保留“忽略关闭请求”的兼容行为。 4. Runtime 不把是否管理员作为健康成功条件;权限错误应由进程退出和完整 stderr/log 事件上报。 @@ -126,3 +132,5 @@ Runtime 不创建、删除、移动或改写 development 源码目录中的任 - Python:TODO-PY-1、TODO-PY-2、TODO-PY-3、TODO-PY-8; - Electron/Vue:使用 Runtime `details.baseUrl`,不自行假定 `localhost`; - 测试:managed 覆盖三项身份精确匹配,development 覆盖脏工作区不被版本/Commit 拒绝以及 shutdown 真实生效。 + +C6~C11 的落实点见 [增补 1 · 跨仓库落实点](./契约补充-v1-增补1.md#跨仓库落实点)。 From 707c08e05993189e9945caf1fcf932ef2b84cb73 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 21:24:20 +0200 Subject: [PATCH 03/57] =?UTF-8?q?docs:=20=E5=9B=9E=E5=86=99=20dev=20?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF=E6=8E=A5=E5=85=A5=E5=86=B3=E7=AD=96=E5=88=B0?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按增补 1 的 C6~C11 在目录模型、后端启动、项目依赖同步、镜像与网络策略、 CLI 设计、插件环境职责边界、健康检查与关闭契约、Lite/Full 边界和数据分类 九处补入增补段落,并补全 dev 基线下后端生成目录的分类。 Co-Authored-By: Claude Opus 5 --- ...66\346\236\204\350\256\276\350\256\241.md" | 92 ++++++++++++++++--- 1 file changed, 81 insertions(+), 11 deletions(-) diff --git "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" index d01178c..9cd7884 100644 --- "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" +++ "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -12,6 +12,10 @@ - 修订:2026-08-11 按决策 D9 将 M12 扩展为 Sentry + PostHog 遥测与错误观测, 冻结了数据白名单和失败静默语义;该方案随后经 D10 迁移为 Umami、再由 D11 取消 Umami, 当前仅保留 Sentry,协议 v1 不新增事件或字段 +- 修订:2026-08-31 按决策 D12 与 [协议 v1 契约补充 增补 1](./契约补充-v1-增补1.md)(C6~C11)回写六处: + 后端工作目录与绝对入口路径、Job Object 的 `BREAKAWAY_OK`、关闭超时参数化、 + 主项目依赖的锁内 URL 改写轮换、MaaFW 运行池的基础设施共享与镜像源下发、dev 基线的目录分类补全。 + 协议版本保持 v1,不新增或改名任何字段、stage、state 与错误码 - 当前范围:系统边界、职责划分、CLI 协议、纯 Git 更新、uv 环境、健康检查、进程监督、镜像、插件职责边界、目录安全、错误与状态契约、测试矩阵、验收标准和分阶段迁移 ## 背景 @@ -283,7 +287,7 @@ Runtime 随后: 1. 停止接受新的控制命令; 2. 请求 Python 后端优雅退出; -3. 等待配置的关闭超时; +3. 等待配置的关闭超时(默认 5 秒,可由 `backend supervise` 选项调整,见[增补 1 C9](./契约补充-v1-增补1.md#c9关闭预算参数化)); 4. 仅在超时后终止自己持有的 Python 进程树; 5. 输出最终状态事件; 6. 清理 PID、锁和临时状态; @@ -452,6 +456,15 @@ auto-mas-runtime `--default-index` 改变锁文件语义。`--mirror-only` 仍约束可轮换的 Git、uv 和 Python 分发源;主项目依赖始终从已校验锁文件的精确 artifact URL 获取。 +按 [增补 1 C10](./契约补充-v1-增补1.md#c10主项目依赖的镜像轮换),上述对显式 +`--mirror package-index=` 的拒绝**保留不变**;`dependencies sync` 改为按目录默认顺序 +自动轮换并改写锁副本内的下载地址前缀,调用方不能指定首选包索引源。`--mirror-only` 在该路径上 +的含义是「全部镜像失败后不回退官方 PyPI」,`--offline` 则完全不改写、沿用原锁。 + +`backend supervise` 除 `--mode` / `--repo` 外,另按 +[增补 1 C9](./契约补充-v1-增补1.md#c9关闭预算参数化) 提供关闭超时选项:正整数秒,合法范围 +`1`~`120`,默认 `5`,越界按参数错误映射 `INVALID_ARGUMENT`。 + `bootstrap --version` 是准备阶段的编排命令,固定按以下顺序执行: 1. `environment ensure`:准备并校验 Runtime 固定版本的 uv; @@ -872,6 +885,8 @@ AUTO-MAS/ # 根目录,后端工作目录 后端始终从固定的 `repo` 路径启动,注意需设定其工作目录为项目根目录。临时更新目录只用于保证网络失败或克隆中断不会破坏当前仓库,完成替换后必须删除;它不是第二个长期运行 Slot。 +**2026-08-31 增补([增补 1 C6](./契约补充-v1-增补1.md#c6后端进程工作目录与入口路径)):** 上述「工作目录为项目根目录」是硬要求,不是建议。managed 模式下 Runtime 创建的后端子进程 cwd 固定为 app-root,入口传绝对路径 `/repo/main.py`(不再传裸 `main.py`),`--project` 仍指向 `/repo`;development 模式的 cwd 保持为 `--repo` 目录。Python 后端在受监督时跳过 `main.py` 的 `os.chdir()`,源内资源改为相对 `__file__` 解析,用户数据继续相对 cwd 解析。此前实现把 cwd 设为 uv 的 project dir(managed 下即 `/repo`),会让用户数据建在 `repo/` 里并在首次 `workspace sync` 时被整体替换掉。cwd 改到 app-root 之后,后端自建的 `runtime/` 才与 Runtime 的 `/runtime/` 同名同层,冲突处置见「数据分类」的增补。 + 用户配置、数据库、日志、下载内容和其他需要跨版本保留的数据必须位于 `repo` 外部。上图中的根级持久目录是 `user-data` 概念的实际布局,不再额外 创建名为 `user-data/` 的聚合目录。更新替换 `repo` 时,Runtime 不承诺保留 @@ -1271,11 +1286,35 @@ uv.exe sync ` 正式 Runtime 不得禁用锁文件中的 sources。当前锁文件包含 workspace/editable source 关系,强行忽略 sources 会让用户机器的解析语义与 CI 生成锁文件时不一致。运行环境通过 `--no-install-workspace` 排除源码包本身,锁文件一致性则由发布 CI 的 `uv lock --check` 保证。 Runtime 在同步前先执行不带包索引覆盖参数的 `uv lock --check`;在线模式沿用项目与锁文件 -sources,显式离线只注入 `UV_OFFLINE=1`。随后 `uv sync --locked` 同样不得传 +sources,显式离线只注入 `UV_OFFLINE=1`。随后 `uv sync` 同样不得传 `--default-index` / `UV_DEFAULT_INDEX` 等覆盖项。`uv.lock` 已记录 registry 与精确 artifact URL,替换默认索引会使 uv 正确判定锁需要更新,并破坏 `--locked` 不变量。主项目依赖因此 -不参与 Runtime 的 package-index 轮换;网络失败映射 `DEPENDENCY_SYNC_FAILED`,离线缓存不足 -仍映射 `NETWORK_UNAVAILABLE`。 +**通过锁文件 URL 改写参与 package-index 轮换,而不通过索引覆盖**(见下方增补);网络失败映射 +`DEPENDENCY_SYNC_FAILED`,离线缓存不足仍映射 `NETWORK_UNAVAILABLE`。 + +**2026-08-31 增补([增补 1 C10](./契约补充-v1-增补1.md#c10主项目依赖的镜像轮换)):镜像改写轮换。** +锁文件在 PyPI 上生成,`uv lock --check` 始终对 `repo/uv.lock` 的**原锁**执行、口径不变; +`uv sync` 阶段改为按既有轮换规则依次尝试 `KindPackageIndex` 目录中的源,每个源执行: + +1. 把 `repo/uv.lock` 读进内存,做两处纯字符串前缀改写——`https://pypi.org/simple` → `<镜像 base>/simple`, + `https://files.pythonhosted.org/packages/` → `<镜像 base>/packages/`; +2. 把改写后的锁与 `repo/pyproject.toml` 一起放进受管根内的临时项目目录(`--no-install-workspace` + 排除根项目本身,因此该目录只需这两个文件,不需要 README、LICENSE 或源码); +3. `UV_PROJECT_ENVIRONMENT` 指向真实受管 venv,临时目录只提供项目元数据,不产生第二个环境; +4. 执行 `uv sync --frozen --no-default-groups --no-install-workspace`;失败则换下一个源重复上述步骤。 + +全部镜像失败后用原锁(PyPI 直连)再执行一次;`--mirror-only` 不做这次回退,按既有语义映射 +`MIRROR_EXHAUSTED`;`--offline` 完全不改写,沿用原锁并只注入 `UV_OFFLINE=1`。 +`repo/` 内的锁与 `pyproject.toml` 始终只读,本节第 6 条「不允许正式安装过程隐式生成或修改锁文件」保持成立。 +临时项目目录必须位于受管根内、由 Runtime 创建并在操作结束时删除,归入「可丢弃缓存」。 +`dependencies.sync` 事件的 `details` 报告本次实际使用的源 key;不新增 stage、state 或错误码。 + +安全性由 uv 自身保证:改写只动下载位置,锁内每个 artifact 的 `sha256` 原样保留,uv 在 `--frozen` +安装时逐个校验,镜像返回不同字节即拒装。镜像 base 由目录中该源 `baseURL` 去掉结尾 `simple/` +推导;新增 package-index 源必须同时提供 `/simple/` 与 `/packages/` +两种布局,否则不得进入轮换目录。`internal/uv/dependencies.go` 对显式 +`--mirror package-index=` 的 `INVALID_ARGUMENT` 拒绝**保留**——改写不是覆盖索引,轮换按目录 +默认顺序自动进行,不由调用方指定首选源。 依赖同步是后端启动的独立前置阶段。`uv sync` 失败时: @@ -1317,9 +1356,11 @@ Runtime 在依赖同步成功后执行: uv.exe run ` --project ` --no-sync ` - main.py + /repo/main.py ``` +入口路径与工作目录按 [增补 1 C6](./契约补充-v1-增补1.md#c6后端进程工作目录与入口路径):子进程 cwd 为 app-root,入口传绝对路径;development 模式沿用 `--repo` 目录作为 cwd 与项目目录。 + 使用 `--no-sync` 的原因: 1. 环境已经由独立的 `uv sync --locked` 阶段准备; @@ -1338,6 +1379,8 @@ AUTO-MAS-Runtime Runtime 创建带 `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` 的 Windows Job Object。为避免 uv 在被纳管前已经生成 Python 子进程,必须使用 `CREATE_SUSPENDED` 创建 uv 主进程,先把它分配到 Job Object,再恢复主线程。后续 Python 子进程继承该 Job,形成唯一受管进程树。Python 的 stdout/stderr 通过 `uv run` 的管道进入 Runtime,并继续按照既定 `log` 事件协议传给 Electron。 +**2026-08-31 增补([增补 1 C8](./契约补充-v1-增补1.md#c8job-object-逃逸与游戏模拟器的进程归属)):** Job 的 `LimitFlags` 同时设置 `JOB_OBJECT_LIMIT_BREAKAWAY_OK`,使 AUTO-MAS 拉起的模拟器与 PC 游戏可以在**显式请求**时脱离受管进程树,从而不随后端退出——这是为保持今天的用户可见行为而定的产品决策。`BREAKAWAY_OK` 只是允许脱离,不改变任何未请求脱离的进程的归属:AUTO-MAS 自己的 worker 与 Agent 子进程不带 `CREATE_BREAKAWAY_FROM_JOB`,仍留在 Job 内随后端一起回收。`DETACHED_PROCESS` 只脱离控制台、不脱离 Job,不能替代该标志。两侧缺一不可,Runtime 单独开 `BREAKAWAY_OK` 不产生任何行为变化。 + ### 插件调用 uv 的边界 Runtime 启动后端时通过 `AUTO_MAS_UV_EXE` 注入固定且已校验的 `uv.exe` 路径。Python 后端的 `PluginManager` 自行决定何时安装、更新或卸载插件包,并自行调用该 uv 执行 `uv pip install/uninstall --target`。 @@ -1403,10 +1446,13 @@ type UVResult struct { | Git 仓库源 | 读取发布分支和浅克隆后端源码 | Go Git | | uv 二进制源 | bootstrap 固定版本 `uv.exe` | Runtime 下载器 | | Python 分发源 | `uv python install` 下载受管 Python | uv | -| Python 包索引 | Python 后端的插件包安装;正式主项目仅消费 `uv.lock` 中的 registry/artifact URL | Python 后端;Runtime 只消费锁定 URL | +| Python 包索引 | Python 后端的包安装;正式主项目消费 `uv.lock` 中的 registry/artifact URL,按增补 1 C10 改写其前缀参与轮换 | Python 后端;Runtime 按锁定 URL 与镜像改写 | -前三类源由 Runtime 管理;正式主项目依赖同步使用 `uv.lock` 的精确 URL,不参与包索引 -轮换。Python 后端独立管理插件安装对 Python 包索引的使用。轮换规则适用于对应调用方 +前三类源由 Runtime 管理;正式主项目依赖同步使用 `uv.lock` 的精确 URL,**通过改写锁副本内的 +下载地址前缀参与包索引轮换,不通过索引覆盖**([增补 1 C10](./契约补充-v1-增补1.md#c10主项目依赖的镜像轮换), +机制见「项目依赖同步」)。Python 后端独立管理其余安装对 Python 包索引的使用;Runtime 另按 +[增补 1 C11](./契约补充-v1-增补1.md#c11maafw-运行池的基础设施共享与镜像源下发) 把解析后的有序源列表 +下发给后端自行按序重试,但不代替后端执行安装。轮换规则适用于对应调用方 可安全替换下载位置、且不会改变锁定目标的网络源: 1. 用户明确选择的源优先; @@ -1415,7 +1461,9 @@ type UVResult struct { 4. 连接超时、读取超时、HTTP 状态和完整性失败分别记录; 5. 完整性校验失败的源本次操作不再重试; 6. 所有镜像失败后再尝试官方源,除非用户选择离线或仅镜像模式; -7. 切换源不能改变目标版本及其派生发布分支、uv 版本、Python 版本或锁文件; +7. 切换源不能改变目标版本及其派生发布分支、uv 版本、Python 版本或锁文件——对增补 1 C10 的 + 锁内 URL 改写,该条的准确表述是「不改变锁文件所固定的包集合、版本与哈希」:改写只动下载 + 位置,`sha256` 原样保留并由 uv 逐个校验; 8. 默认禁止关闭 TLS 校验,不自动使用 `--allow-insecure-host`。 按 D6,Runtime 不在换源前解析或钉扎 Git Commit。不同源可能因同步延迟暂时 @@ -1432,9 +1480,10 @@ Runtime 对自己负责的网络操作只根据退出码和自身网络状态机 1. 准备并校验固定版本的 `uv.exe`; 2. 启动后端时注入 `AUTO_MAS_UV_EXE`; -3. 把后端工作目录设为 AUTO-MAS 项目根目录; +3. 把后端工作目录设为 AUTO-MAS 项目根目录([增补 1 C6](./契约补充-v1-增补1.md#c6后端进程工作目录与入口路径)); 4. 在 Git 更新、repair 和 cleanup 中保护根目录下的插件运行包、插件配置和插件数据; -5. 转发 Python 后端产生的插件初始化日志和健康状态。 +5. 转发 Python 后端产生的插件初始化日志和健康状态; +6. **提供基础设施,不决定装什么**([增补 1 C11](./契约补充-v1-增补1.md#c11maafw-运行池的基础设施共享与镜像源下发)):向后端开放共享的受管 uv 缓存目录与受管 Python 安装目录,并把解析后的**有序镜像源列表**下发给后端,由后端自行按序重试。Runtime 提供这些资源,不因此获得任何关于「装哪个 Python 版本、装哪些包」的决定权。 ### Python 后端负责 @@ -1461,6 +1510,8 @@ AUTO-MAS-Runtime Runtime 管理 uv 工具本身,不等于管理插件依赖。Runtime 不读取插件安装清单,不执行插件依赖同步,不提供任何插件同步或重建命令,也不产生插件专用状态或错误码。 +**2026-08-31 增补([增补 1 C11](./契约补充-v1-增补1.md#c11maafw-运行池的基础设施共享与镜像源下发)):** 同一条边界适用于 AUTO-MAS 后端的 MaaFW 运行池——它在环境数量、Python 版本、依赖声明和解析时机四个维度上与主项目模型正相反(N 个环境、多个 minor、运行时在线解析、`uv pip install` 自由解析),整体交给 Runtime 等于把它改造成通用包管理器,与本节边界和红线第 4 条直接冲突。因此**只统一基础设施**:共享 uv 缓存与受管 Python 目录(经 `AUTO_MAS_UV_CACHE_DIR`、`AUTO_MAS_UV_PYTHON_INSTALL_DIR` 注入)、下发有序镜像源(经 `AUTO_MAS_MIRROR_PACKAGE_INDEX`、`AUTO_MAS_MIRROR_PYTHON` 注入),并把池目录重新分类——venv、解释器、缓存归「可重建」并纳入 `repair` / `cleanup`,manifest 与信任基线仍归用户业务数据。变量的取值格式与命名规则见增补 1「新增注入环境变量」。 + 插件依赖失败不进入 `environment_broken`,因为主项目 venv 仍可能完全有效。Python 后端负责生成插件域错误并决定是否继续初始化;Runtime 只根据后端进程退出和 `/api/core/health` 的通用结果输出后端启动错误,同时保留完整 stdout/stderr。需要安装、修复或删除插件时,Electron/Vue 通过 Python 插件 API 操作,不通过 Runtime CLI。 ## 后端健康检查与关闭契约 @@ -1518,6 +1569,12 @@ Runtime 通过 `AUTO_MAS_RUNTIME_PROTOCOL`、`AUTO_MAS_EXPECTED_VERSION` 和 `AU 收到 stdin `shutdown` 后,Runtime 调用 `POST /api/core/close` 请求优雅关闭,然后等待受管进程退出。`AUTO_MAS_SUPERVISED=1` 的优先级高于 `AUTO_MAS_DEV=1`,受监督 development 后端也必须真实退出。接口无法连接、返回失败或超过关闭超时时,Runtime 关闭 Job Object 作为兜底;只要确认进程树已经清空,就输出 `BACKEND_FORCE_TERMINATED` 警告并正常完成关闭。Electron 不直接调用该 HTTP 接口,也不直接终止 Python。 +**2026-08-31 增补:** + +- **受监督优先级扩展到端口**([增补 1 C7](./契约补充-v1-增补1.md#c7受监督标记对端口与开发环境标记的优先级)):受监督时后端固定监听 `36163`,忽略 `AUTO_MAS_HTTP_PORT` 与一切开发环境标记(仓库根 `.env`、`AUTO_MAS_ENV=development`)。C1 的固定端口在受监督路径上无例外,Runtime 侧无需改动。 +- **`protocol` 字段自报**(增补 1 C7 对 C2 第 1 条的修订):上文列出的第 5 项「协议版本兼容」核对的是后端**自身支持**的协议版本,不是注入值的回显;`version` 与 `commit` 仍为回显。第 6、7 项不变。 +- **关闭超时可配**([增补 1 C9](./契约补充-v1-增补1.md#c9关闭预算参数化)):上表的时间参数中,关闭超时由 `backend supervise` 的选项提供,默认仍为 5 秒;就绪侧的四个参数本次不参数化,保持编译期常量。默认值待 AUTO-MAS 侧实测「MaaFW 任务运行中收到 close」的耗时后再决定是否调整。 + 这两个接口是 Runtime 与 Python 后端的共同开发契约。任一侧修改字段、语义或协议版本时必须同步更新另一侧及对应测试。后端 schema 变更后通过 OpenAPI 生成器更新前端客户端,不能手工修改生成文件。 ## 后端异常重启 @@ -1679,6 +1736,8 @@ Full 不内置后端源码压缩包,也不改变“版本映射发布分支” Full 中预置的 uv 和 Python 必须经过与 Runtime 相同的版本校验。不匹配时由 Runtime 重新准备,不能因为文件存在就直接使用。 +**2026-08-31 增补([增补 1 C10](./契约补充-v1-增补1.md#c10主项目依赖的镜像轮换)):** 受限网络地区的首装可达性由 `dependencies sync` 的镜像改写轮换解决,不依赖发行版差异,**Lite 与 Full 在这件事上没有区别**。上表中 Full 的「可选 uv 缓存」保持可选,仅作为加速项:若 CI 按每个发布版本的 `uv.lock` 预生成缓存塞进安装包,首装可以少走一次网络。要启用它需要先让 `cleanup` 区分「随包预置」与「运行期产生」的 uv 缓存,或提供重新播种入口——现在 `cleanup` 无条件删除 `runtime/cache/uv` 且没有补种路径,清理一次就得重新联网。该加速项登记为 `T13.6`,优先级低于 `T13.4`。 + ## Runtime 自身更新 Runtime 不实现自更新。`auto-mas-runtime.exe` 跟随 Electron 桌面应用的更新流程一起发布和替换: @@ -1795,6 +1854,17 @@ app/core/plugins/_generated/ | 更新临时目录 | `repo.update-`、经确认的旧 repo 临时目录 | 仅在状态匹配时删除 | | 外部脚本目录 | 用户配置中的 `RootPath` 等任意外部路径 | Runtime 永不递归删除或移动 | +**2026-08-31 增补(dev 基线复核,决策 D12):** 按 `dev@3c422093` 与 MaaFW 内置层实测,后端在工作目录下还会生成以下条目,一并归类: + +| 条目 | 内容 | 分类与 Runtime 权限 | +| --- | --- | --- | +| `config/maafw_runtime_pool/`、`config/maafw_agent_venvs/` | MaaFW 的解释器池、uv 缓存与隔离 venv | 当前随 `config/` 归用户业务数据、绝不删除;按[增补 1 C11](./契约补充-v1-增补1.md#c11maafw-运行池的基础设施共享与镜像源下发)重新拆分后,其中的 venv/解释器/缓存改归「可重建主环境」并纳入 repair/cleanup,manifest 与信任基线仍归用户业务数据 | +| `data/maafw_project_store/` 等 MaaFW 项目数据 | 项目仓库、运行记录、受管下载与更新缓存 | 随 `data/` 归用户业务数据,绝不删除 | +| `runtime/`(后端侧) | MaaFW 的 `maafw_runner_jobs`、HSR 的 `hsr` | **名字与 Runtime 的 `/runtime/` 冲突**:cwd 按[增补 1 C6](./契约补充-v1-增补1.md#c6后端进程工作目录与入口路径)改到 app-root 之后两者同名同层。处置是后端把这两处挪进 `data/`,`runtime/` 整个归 Runtime(`任务拆分.md` TODO-PY-10) | +| `environment/` | 便携 python/git/hpatchz | 接入后不再产生;hpatchz 缓存需另找落点 | +| `download.temp` | 下载临时文件 | 可丢弃缓存 | +| `changes.json`、`UpdatePack_*.zip`、`AUTO-MAS-Setup.exe`、`AUTO_MAS.exe` | 整包更新残留 | 可丢弃缓存;`任务拆分.md` TODO-PY-5 完成后不再产生,清理逻辑随之下线 | + `debug/` 虽然由后端生成,但包含定位启动和任务问题的日志,不能随着 Git 仓库替换被静默删除。 `logs/runtime/` 只能由日志保留策略删除经过重新验明身份的单个旧日志文件; 创建、轮转、列举和删除都必须固定应用根到日志目录的祖先句柄并拒绝 From f5ac9c96d46166b2ce4df4bd36b475abf701b478 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 21:27:11 +0200 Subject: [PATCH 04/57] =?UTF-8?q?docs:=20=E7=99=BB=E8=AE=B0=20M13=20dev=20?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF=E6=8E=A5=E5=85=A5=E9=85=8D=E5=A5=97=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增里程碑 M13(T13.1~T13.6)对应增补 1 的 C6~C11,补里程碑总览与执行顺序; AGENTS.md 同步权威文档表、决策区间与状态表。 Co-Authored-By: Claude Opus 5 --- AGENTS.md | 14 ++++--- ...73\345\212\241\346\213\206\345\210\206.md" | 38 ++++++++++++++++++- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 18b76e7..5f3256a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ Vue/Electron/Python 的改写、CI/CD 发布流程本身。 --- -## 2. 当前状态(截至 2026-08-13) +## 2. 当前状态(截至 2026-08-31) | 里程碑 | 状态 | | --- | --- | @@ -47,6 +47,7 @@ Vue/Electron/Python 的改写、CI/CD 发布流程本身。 | M10 工程可维护性收敛 | T10.1 文档信息架构已完成;后续阶段见维护设计 | | M11 跨平台适配(Linux/macOS) | 规划中(决策 D7,2026-08-04 立项);仅 T11.1 设计任务可执行 | | M12 遥测与错误观测(Sentry-only) | T12.1、T12.2、T12.4、T12.5、T12.7 已完成;T12.6 本地发布配置已提交,待 Repository secret 与授权后的远端验收;T12.3/Umami 已取消 | +| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;契约与文档已定稿,T13.1~T13.6 全部未开始 | 代码现状: @@ -77,12 +78,13 @@ Git:远端 `origin` = `git@github.com:AUTO-MAS-Project/AUTO-MAS-Runtime.git` | [doc/README.md](doc/README.md) | 文档导航、分层与生命周期规则 | 查找任何项目文档时 | | [doc/架构设计.md](doc/架构设计.md) | 冻结的系统架构:边界、CLI 命令树、NDJSON 协议、错误码/退出码/stage/state 全集、Git 更新流程、uv 策略、目录安全、测试矩阵、验收标准 | 任何涉及对外契约的改动 | | [doc/契约补充-v1.md](doc/契约补充-v1.md) | 协议 v1 的 5 项定稿细节(C1~C5:固定端口 36163、身份注入环境变量、`failed` 字面量、`AUTO_MAS_SUPERVISED=1`、development 检查边界) | 涉及后端启动/健康检查/环境变量 | -| [doc/任务拆分.md](doc/任务拆分.md) | 逐任务清单、依赖、验收项、决策记录 D1~D6、待决项 D-open-*、AUTO-MAS 侧 TODO、变更记录 | **每次开工前**确认自己在做哪个任务 | +| [doc/契约补充-v1-增补1.md](doc/契约补充-v1-增补1.md) | 对 v1 的增量修订(C6~C11:后端工作目录、受监督优先级扩展到端口、Job 逃逸、关闭预算参数化、依赖镜像改写轮换、运行池基础设施共享),并修订 C2 第 1 条与 C4 第 1 条 | 同上;**与 `契约补充-v1.md` 冲突时以本文件为准** | +| [doc/任务拆分.md](doc/任务拆分.md) | 逐任务清单、依赖、验收项、决策记录 D1~D12、待决项 D-open-*、AUTO-MAS 侧 TODO、变更记录 | **每次开工前**确认自己在做哪个任务 | | [doc/代码审查清单.md](doc/代码审查清单.md) | 自动化门禁覆盖不到的架构边界检查 | 提交前自查、审查他人代码 | | `doc/current/M*/` | 尚未完成任务的设计与实施计划 | 执行某个具体任务时 | | `doc/archive/M*/` | 已完成阶段仍有解释价值的设计与审查记录 | 追溯设计背景时 | -**优先级:** 契约补充-v1(更具体) > 架构设计(概括) > 任务拆分(派生清单)。 +**优先级:** 契约补充-v1-增补1(最新增量修订) > 契约补充-v1(更具体) > 架构设计(概括) > 任务拆分(派生清单)。 --- @@ -472,5 +474,7 @@ Go 测试惯例([Go Code Review Comments](https://go.dev/wiki/CodeReviewCommen “退出码为 0”“输出不含 `[no tests to run]`”“出现 `--- PASS:`”,照抄这个模式。 - **注释中文、标识符英文**:注释(含 doc comment)写中文,doc comment 仍以英文标识符开头; Go error 字符串保持英文小写;不要把设计文档写成英文,也不要在同一声明里中英混排。 -- **改协议前先看 `doc/契约补充-v1.md`**:架构设计里的概括描述常被它进一步收紧。 -- **决策已冻结的事项不要重开**:D1~D6 与 C1~C5 是用户已确认的结论;D-open-4~7 才是待决项。 +- **改协议前先看 `doc/契约补充-v1.md` 和 `doc/契约补充-v1-增补1.md`**:架构设计里的概括描述常被它们进一步收紧; + 增补 1 还修订了 C2 第 1 条(`protocol` 由后端自报而非回显)与 C4 第 1 条(受监督但非管理员时记 warning 并继续运行)。 +- **决策已冻结的事项不要重开**:D1~D12 与 C1~C11 是用户已确认的结论(D2/D4/D8/D9/D10 已被后续决策取代,原文只作追溯); + 当前仍待决的是 D-open-4、D-open-5、D-open-7、D-open-10。 diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index e64b6db..f6b5e4c 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -27,7 +27,7 @@ ### 0.3 执行顺序 - 按各任务标注的「依赖」执行;无依赖关系的任务可并行; -- 里程碑整体顺序建议 M0 → M1 → M2 → M3 →(M4 ∥ M5)→ M6 → M7 → M9(M8 编号已随决策 D6 移除,保留空缺);M10 为跨阶段维护;M11 为规划中的跨平台扩展,M12 按 D11 收敛为 Sentry-only 并依 T12.1、T12.2、T12.4~T12.7 实施; +- 里程碑整体顺序建议 M0 → M1 → M2 → M3 →(M4 ∥ M5)→ M6 → M7 → M9(M8 编号已随决策 D6 移除,保留空缺);M10 为跨阶段维护;M11 为规划中的跨平台扩展,M12 按 D11 收敛为 Sentry-only 并依 T12.1、T12.2、T12.4~T12.7 实施;M13 为 D12 与契约补充增补 1 的配套实施,T13.1/T13.2/T13.3 可并行,T13.4 → T13.5 → T13.6 有先后,整体建议排在 M9 联调之前; - AUTO-MAS 侧 TODO 由那边的仓库执行,本仓库任务不阻塞在其上,除非「依赖」中显式标注。 ### 0.4 规模标记 @@ -73,6 +73,7 @@ D6 的推论(重要):`workspace sync` 按内部模板 `release/<完整版 | M10 | 工程可维护性收敛 | —(跨阶段维护) | M2;T10.1 在 M3 前完成 | | M11 | 跨平台适配(Linux 少数发行版 + macOS) | —(2026-08-04 新增范围,见架构设计「平台支持策略」) | T11.1 设计依赖 M3,可提前;实施建议在 M9 首版 Windows 验收后启动 | | M12 | 遥测与错误观测(Sentry-only) | 阶段 7(发布观察)配套 | D11;M3 | +| M13 | dev 基线接入配套(工作目录、Job 逃逸、关闭预算、依赖镜像、运行池基础设施) | 阶段 1/3/4 配套 | D12;契约补充-v1-增补1;M5、M6 | --- @@ -905,6 +906,40 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 managed、development 的 enabled/disabled/offline 黑盒均通过完整环境、NDJSON、stderr、 退出码、PID/端口、事务/临时文件与日志检查;独立复审 Critical/Important/Minor 清零。 +### - [ ] M13 dev 基线接入配套 + +> 2026-08-31 按决策 D12 与 [协议 v1 契约补充 增补 1](./契约补充-v1-增补1.md)(C6~C11)立项。 +> 六项契约已先行定稿并回写 `doc/架构设计.md`,满足红线第 2 条「改动已冻结的对外契约前先改文档」—— +> **本里程碑的任何代码改动都不得再扩大契约面**;实施中若发现契约不足,先修订增补 1 并登记变更记录。 +> T13.1/T13.2/T13.3 互不依赖,可并行;T13.4 → T13.5 → T13.6 有先后(都要动 `internal/mirror` 的消费面)。 +> 每项都与 AUTO-MAS 侧第 5.1 章的某条 `TODO-PY-*` 成对,单侧上线通常观察不到预期行为,验收以跨仓联合为准。 + +- [ ] **T13.1 后端工作目录与绝对入口路径**(M) + - 依赖:M6;契约 [增补 1 C6](./契约补充-v1-增补1.md#c6后端进程工作目录与入口路径);成对 TODO-PY-4、TODO-PY-10 + - 内容:managed 模式下把后端子进程的工作目录从 uv project dir 改为 app-root,入口参数改传绝对路径 `/repo/main.py`。`internal/uv` 的 `RunOptions` 目前没有独立的工作目录字段(`internal/uv/managed.go:83` 直接用 `resolved.ProjectDir`),需要补一个并保证一次性命令(`uv python install`、`uv sync`、`uv pip`)的既有行为字节不变;`internal/backend/supervisor.go` 的 argv 与 `internal/backend/control.go` 的重启路径同步改。development 模式的 cwd 与入口保持不变。 + - 验收:Windows E2E 在临时受管根下证明后端 cwd 为 app-root、入口为绝对路径;**用户数据在一次 `workspace sync` 整体替换后仍然存在**(这是本条的直接验证,也是 C6 要解决的数据损失);development E2E 与既有一次性命令测试无回退;单次自动重启后 cwd 与入口不漂移。 +- [ ] **T13.2 Job Object 允许游戏与模拟器脱离**(M) + - 依赖:M6;契约 [增补 1 C8](./契约补充-v1-增补1.md#c8job-object-逃逸与游戏模拟器的进程归属);成对 TODO-PY-11 + - 内容:`internal/process/job_windows.go:35` 的 `LimitFlags` 增加 `JOB_OBJECT_LIMIT_BREAKAWAY_OK`,与既有 `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` 并存。只放开「显式请求脱离」这一条路径,不改变任何未请求脱离的进程的归属,也不引入按进程名清理(红线第 5 条)。 + - 验收:真实 Windows Job 测试证明——带 `CREATE_BREAKAWAY_FROM_JOB` 创建的子进程能脱离,Job 关闭后仍存活;**不带**该标志的子进程仍随 Job 一起回收;既有进程树清理、`BACKEND_FORCE_TERMINATED` 警告与 `details.pid` 语义无回退。跨仓联合验收单独跑一次「跑着游戏关 AUTO-MAS」。 +- [ ] **T13.3 关闭超时参数化**(S) + - 依赖:M6;契约 [增补 1 C9](./契约补充-v1-增补1.md#c9关闭预算参数化);默认值调整依赖 TODO-PY-12 的实测数据 + - 内容:把 `internal/backend/control.go:20` 的 `defaultShutdownTimeout` 从编译期常量改为 `backend supervise` 的选项,正整数秒、合法范围 `1`~`120`、默认 `5`。越界或非数字按参数错误映射 `INVALID_ARGUMENT`(退出码 2、不可重试)。就绪侧预算(`internal/health/checker.go:21-24`)本次**不动**。**本任务只加开关,不改默认值**——在有实测数据之前改默认值等于用猜测替换猜测。 + - 验收:表驱动测试覆盖合法值、边界 `1` 与 `120`、越界与非数字;不传该选项时的行为与改动前完全一致;用「收到 close 后固定不退出」的假后端证明配置值真实生效(到达 Job 兜底关闭的时刻随配置变化);既有关闭与单次重启契约测试无回退。 +- [ ] **T13.4 `dependencies sync` 的镜像改写与轮换**(L) + - 依赖:M5;契约 [增补 1 C10](./契约补充-v1-增补1.md#c10主项目依赖的镜像轮换);完整验收需 AUTO-MAS TODO-PY-6 产出真实 `uv.lock` 的发布分支 + - 内容:`uv lock --check` 阶段不动(仍对 `repo/uv.lock` 原锁执行、不带包索引覆盖参数);`uv sync` 阶段改为按 `internal/mirror` 的 `KindPackageIndex` 目录与既有轮换规则逐个尝试——读原锁到内存做两处前缀改写(`https://pypi.org/simple` → `<镜像 base>/simple`,`https://files.pythonhosted.org/packages/` → `<镜像 base>/packages/`),与 `repo/pyproject.toml` 一起放进受管根内的临时项目目录,`UV_PROJECT_ENVIRONMENT` 指向真实受管 venv,执行 `uv sync --frozen --no-default-groups --no-install-workspace`;失败换下一个源;全部失败回退原锁(PyPI)。`--mirror-only` 不做这次回退,`--offline` 完全不改写。临时项目目录由 Runtime 创建并在成功与失败路径上都删除,归「可丢弃缓存」。`dependencies.sync` 事件的 `details` 报告本次实际使用的源 key。 + - `internal/uv/dependencies.go:234-242` 对显式 `--mirror package-index=` 的 `INVALID_ARGUMENT` 拒绝**保留**——改写不是覆盖索引,轮换按目录默认顺序自动进行。`internal/mirror/defaults.go` 的 4 个 `KindPackageIndex` 源目前是死代码(唯一消费方拒绝使用),本条正是它的正当用途。 + - 验收:单元覆盖两处前缀改写与镜像 base 推导(含 `https://mirrors.aliyun.com/pypi/simple/` 这种带路径前缀的 base);组件测试用本地 HTTP 假索引覆盖「首选镜像不可达 → 次选成功」与「全部镜像失败 → 回退原锁」两条路径,以及 `--mirror-only` 不回退、`--offline` 不改写;**`repo/uv.lock` 与 `repo/pyproject.toml` 在全部路径上字节不变**(哈希断言);临时目录在成功、失败与取消三种收场下都不残留;`details` 中的源 key 与实际使用的源一致;既有 `INVALID_ARGUMENT` 与 `LOCKFILE_OUTDATED` 测试无回退。 +- [ ] **T13.5 向后端开放受管基础设施与有序镜像源**(M) + - 依赖:T13.4(同样改 `internal/mirror` 的消费面,分开合以免冲突);契约 [增补 1 C11](./契约补充-v1-增补1.md#c11maafw-运行池的基础设施共享与镜像源下发);成对 TODO-PY-13 + - 内容:启动后端时新增注入四个变量——`AUTO_MAS_UV_CACHE_DIR`、`AUTO_MAS_UV_PYTHON_INSTALL_DIR`(受管目录的规范化绝对路径),`AUTO_MAS_MIRROR_PACKAGE_INDEX`、`AUTO_MAS_MIRROR_PYTHON`(`;` 分隔的有序源列表,顺序即 Runtime 解析后的尝试顺序,官方源在末位)。同时把 MaaFW 运行池目录纳入既有分类:venv/解释器/缓存归「可重建」并进入 `repair` / `cleanup`,manifest 与信任基线仍归用户业务数据、绝不删除。Runtime 不读池的依赖声明、不解析池依赖、不维护池的安装清单,不新增池专用命令、stage 或错误码(红线第 4 条)。 + - 验收:managed 与 development 两种模式下四个变量都注入且格式正确;列表顺序与 mirror 解析后的尝试顺序逐项一致,`--mirror-only` 不含官方源、`--offline` 为空串;`UV_*` 的注入面与 T12.7 的保留键清理行为**无任何变化**(宿主 `UV_*` 仍不可重新注入);`cleanup` / `repair` 对池目录新分类的行为有测试覆盖,且 manifest 与信任基线在任何自动流程下都不被删除。 +- [ ] **T13.6 (可选加速)Full 预置 uv 缓存与 cleanup 分类**(S)⏸ 优先级低于 T13.4 + - 依赖:T13.4;AUTO-MAS 发布 CI 侧按发布版本预生成缓存 + - 内容:让 `cleanup` 区分「随包预置」与「运行期产生」的 uv 缓存,或提供重新播种入口——现在 `internal/cleanup/cleanup.go:280` 无条件删除 `runtime/cache/uv` 且没有补种路径,清理一次就得重新联网。这是纯加速项:受限网络地区的首装可达性已由 T13.4 解决,Lite 与 Full 在这件事上没有区别。 + - 验收:删除预置缓存后仍能通过 T13.4 的轮换正常装上;预置缓存存在时不被无条件删除;不改变 T13.4 的任何路径与错误映射。 + --- ## 4. 新旧职责映射(迁移对照) @@ -1102,6 +1137,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 | 日期 | 变更 | 操作者 | | --- | --- | --- | +| 2026-08-31 | 新增 [协议 v1 契约补充 增补 1](./契约补充-v1-增补1.md)(**C6~C11**,协议仍为 v1,不新增或改名任何字段、stage、state 与错误码),并立项里程碑 **M13「dev 基线接入配套」**(T13.1~T13.6)。六条定稿:**C6** managed 下后端子进程 cwd = app-root、入口传绝对路径 `/repo/main.py`(现实现 `internal/uv/managed.go:83` 用 uv 的 project dir,会把用户数据建在 `repo/` 里并被 `workspace sync` 整体替换掉);**C7** 受监督优先级扩展到端口(固定 36163,忽略 `AUTO_MAS_HTTP_PORT` 与 `.env`/`AUTO_MAS_ENV`),并修订 C2 第 1 条(`protocol` 改为后端自报而非回显注入值,`version`/`commit` 仍回显)与 C4 第 1 条(受监督但非管理员时记 warning 并继续运行,不再要求非零退出);**C8** 游戏与模拟器不随后端退出,Runtime 开 `JOB_OBJECT_LIMIT_BREAKAWAY_OK`、AUTO-MAS 只在拉起游戏/模拟器时带 `CREATE_BREAKAWAY_FROM_JOB`(`DETACHED_PROCESS` 不脱离 Job);**C9** `backend supervise` 新增关闭超时选项,默认仍 5 秒,待实测数据再调;**C10** 锁文件在 PyPI 上生成,`dependencies sync` 改为改写锁副本内两处下载地址前缀参与镜像轮换、失败逐个换源、全部失败回退原锁,安全性由锁内 `sha256` 与 uv 的 `--frozen` 校验保证,`dependencies.go:234` 拒绝覆盖包索引的逻辑保留;**C11** MaaFW 运行池只统一基础设施——新增注入 `AUTO_MAS_UV_CACHE_DIR`/`AUTO_MAS_UV_PYTHON_INSTALL_DIR`/`AUTO_MAS_MIRROR_PACKAGE_INDEX`/`AUTO_MAS_MIRROR_PYTHON`,池目录重新分类,「装什么」仍留在后端。按红线第 2 条先改文档:`doc/架构设计.md` 在目录模型、后端启动、项目依赖同步、镜像与网络策略、CLI 设计、插件环境职责边界、健康检查与关闭契约、Lite/Full 边界和数据分类九处补入增补段落,并补全 dev 基线下后端生成目录的分类;`doc/契约补充-v1.md` 顶部与 C2/C4 两条加指向增补的修订标注;`doc/README.md` 权威优先级更新为「增补 1 > 契约补充-v1 > 架构设计 > 任务拆分」。本次不写任何 Go 代码,M13 全部任务保持未开始 | Claude | | 2026-08-31 | 登记决策 **D12** 并按 dev 基线重写第 5.1 节:AUTO-MAS 侧配合改造基线由 dev_v2 改为 `dev@3c422093`(dev_v2 自 2026-08-06 停更,两条分支自 07-29 分叉后各走四百余提交),D4 标注为已由 D12 取代并保留原文追溯。5.1 的 TODO-PY-1~8 编号沿用、内容与落点整体换成 dev 现状:**TODO-PY-3** 目标文件 `app/plugins/uv_backend.py` 在 dev 不存在,落点改为 MaaFW 的 `automas_maafw_agent_env/env.py:687-691`(运行池那处已由 AUTO-MAS PR #478 做好 `AUTO_MAS_UV_EXE` 注入优先);**TODO-PY-7 不适用于 dev 基线**(dev 无插件系统:无 `app/plugins/`、无 `app/core/plugins/`、`plugins/` 下无内容、Electron 无 `pluginBootstrapService.ts`),保留编号避免引用悬空,dev_v2 插件系统若并回则与 TODO-PY-3 一起重算;其余各条按契约第 02 节 P-1~P-8 收窄或扩写。5.1 末尾新增「MaaFW 专项」小节 **TODO-PY-9~13**(取自契约第 03 节 M-1~M-5 与第 04 节 T-1~T-4,逐条标注落在 AUTO-MAS、Runtime 还是两侧)。第 4 章加注左列仍为 dev_v2 时期内容且插件一行不适用;5.2 加注基线同 5.1、待 R 段定稿后重排;5.3 未改动。本次只改文档,不涉及任何 Go 代码或已冻结契约 | Claude | | 2026-08-27 | 完成 T7.6(实现 `8a2bbfd`):后续 Release 的裸 EXE 在现有 `auto-mas-runtime` 名称后追加 release tag,格式为 `auto-mas-runtime-.exe`;不追加日期、时间或 Commit hash;package、checksum、publish 与 smoke 全链路消费同一个动态文件名,安装后的稳定运行名仍为 `auto-mas-runtime.exe`;红/绿灯、100 次发布契约复跑、PowerShell AST、真实 EXE 和标准门均取得本地证据,未创建 tag/Release 或 push | Codex | | 2026-08-12 | 按用户要求修订 T7.2/T7.3 后续发布契约:Release 不再生成 zip,直接发布 `auto-mas-runtime.exe` 与覆盖该 EXE 的 `SHA256SUMS.txt`;smoke 直接下载并运行 EXE。既有 `v0.1.0-beta.2` ZIP 验收记录保留为历史证据 | Codex | From bf18964f03ee91e80c72f358be47f83023b3faf2 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 21:41:49 +0200 Subject: [PATCH 05/57] =?UTF-8?q?docs:=20=E4=BF=AE=E8=AE=A2=20C10=20?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E5=8C=85=E7=B4=A2=E5=BC=95=E9=A6=96=E9=80=89?= =?UTF-8?q?=E4=B8=8E=E6=94=B9=E5=86=99=E5=89=8D=E7=BC=80=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=20(T13.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 显式 --mirror package-index= 不再返回 INVALID_ARGUMENT,改为排在尝试顺序最前、 与自动轮换走同一条锁改写路径;改写用的 simple/packages 前缀改为目录显式声明。 Co-Authored-By: Claude Opus 5 --- ...73\345\212\241\346\213\206\345\210\206.md" | 5 ++- ...5\205\205-v1-\345\242\236\350\241\2451.md" | 42 ++++++++++++++++--- ...66\346\236\204\350\256\276\350\256\241.md" | 16 ++++--- 3 files changed, 49 insertions(+), 14 deletions(-) diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index f6b5e4c..1a9993d 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -929,8 +929,8 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 - [ ] **T13.4 `dependencies sync` 的镜像改写与轮换**(L) - 依赖:M5;契约 [增补 1 C10](./契约补充-v1-增补1.md#c10主项目依赖的镜像轮换);完整验收需 AUTO-MAS TODO-PY-6 产出真实 `uv.lock` 的发布分支 - 内容:`uv lock --check` 阶段不动(仍对 `repo/uv.lock` 原锁执行、不带包索引覆盖参数);`uv sync` 阶段改为按 `internal/mirror` 的 `KindPackageIndex` 目录与既有轮换规则逐个尝试——读原锁到内存做两处前缀改写(`https://pypi.org/simple` → `<镜像 base>/simple`,`https://files.pythonhosted.org/packages/` → `<镜像 base>/packages/`),与 `repo/pyproject.toml` 一起放进受管根内的临时项目目录,`UV_PROJECT_ENVIRONMENT` 指向真实受管 venv,执行 `uv sync --frozen --no-default-groups --no-install-workspace`;失败换下一个源;全部失败回退原锁(PyPI)。`--mirror-only` 不做这次回退,`--offline` 完全不改写。临时项目目录由 Runtime 创建并在成功与失败路径上都删除,归「可丢弃缓存」。`dependencies.sync` 事件的 `details` 报告本次实际使用的源 key。 - - `internal/uv/dependencies.go:234-242` 对显式 `--mirror package-index=` 的 `INVALID_ARGUMENT` 拒绝**保留**——改写不是覆盖索引,轮换按目录默认顺序自动进行。`internal/mirror/defaults.go` 的 4 个 `KindPackageIndex` 源目前是死代码(唯一消费方拒绝使用),本条正是它的正当用途。 - - 验收:单元覆盖两处前缀改写与镜像 base 推导(含 `https://mirrors.aliyun.com/pypi/simple/` 这种带路径前缀的 base);组件测试用本地 HTTP 假索引覆盖「首选镜像不可达 → 次选成功」与「全部镜像失败 → 回退原锁」两条路径,以及 `--mirror-only` 不回退、`--offline` 不改写;**`repo/uv.lock` 与 `repo/pyproject.toml` 在全部路径上字节不变**(哈希断言);临时目录在成功、失败与取消三种收场下都不残留;`details` 中的源 key 与实际使用的源一致;既有 `INVALID_ARGUMENT` 与 `LOCKFILE_OUTDATED` 测试无回退。 + - **2026-09-01 修订**(见 [增补 1 C10](./契约补充-v1-增补1.md#c10主项目依赖的镜像轮换) 顶部的修订提示):`internal/uv/dependencies.go:234-242` 的 `INVALID_ARGUMENT` 拒绝,连同 `internal/cli/errors.go` 的 `rejectPackageIndexOverride`(bootstrap 与 repair 的前置拒绝)一并删除;显式 `--mirror package-index=` 改为把该源排在尝试顺序最前,走同一条改写路径。`internal/mirror/defaults.go` 的 4 个 `KindPackageIndex` 源此前是死代码(唯一消费方拒绝使用),本条正是它的正当用途;每个源的 `simple` / `packages` 改写前缀由目录**显式声明**,不由 `baseURL` 推导(官方源的 artifact 在 `files.pythonhosted.org`,推导会得到不存在的地址)。 + - 验收:单元覆盖两处前缀改写与镜像 base 推导(含 `https://mirrors.aliyun.com/pypi/simple/` 这种带路径前缀的 base);组件测试用本地 HTTP 假索引覆盖「首选镜像不可达 → 次选成功」与「全部镜像失败 → 回退原锁」两条路径,以及 `--mirror-only` 不回退、`--offline` 不改写;**`repo/uv.lock` 与 `repo/pyproject.toml` 在全部路径上字节不变**(哈希断言);临时目录在成功、失败与取消三种收场下都不残留;`details` 中的源 key 与实际使用的源一致;显式 `--mirror package-index=` 时该源第一个被尝试;既有 `LOCKFILE_OUTDATED` 测试无回退。 - [ ] **T13.5 向后端开放受管基础设施与有序镜像源**(M) - 依赖:T13.4(同样改 `internal/mirror` 的消费面,分开合以免冲突);契约 [增补 1 C11](./契约补充-v1-增补1.md#c11maafw-运行池的基础设施共享与镜像源下发);成对 TODO-PY-13 - 内容:启动后端时新增注入四个变量——`AUTO_MAS_UV_CACHE_DIR`、`AUTO_MAS_UV_PYTHON_INSTALL_DIR`(受管目录的规范化绝对路径),`AUTO_MAS_MIRROR_PACKAGE_INDEX`、`AUTO_MAS_MIRROR_PYTHON`(`;` 分隔的有序源列表,顺序即 Runtime 解析后的尝试顺序,官方源在末位)。同时把 MaaFW 运行池目录纳入既有分类:venv/解释器/缓存归「可重建」并进入 `repair` / `cleanup`,manifest 与信任基线仍归用户业务数据、绝不删除。Runtime 不读池的依赖声明、不解析池依赖、不维护池的安装清单,不新增池专用命令、stage 或错误码(红线第 4 条)。 @@ -1137,6 +1137,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 | 日期 | 变更 | 操作者 | | --- | --- | --- | +| 2026-09-01 | T13.4 实施期间按红线第 2 条先改文档,修订 **C10** 两处:其一,显式 `--mirror package-index=` 不再返回 `INVALID_ARGUMENT`,改为把该源排在尝试顺序最前、与自动轮换走同一条锁改写路径——**改写不是覆盖索引**,`uv lock --check` 仍对 `repo/uv.lock` 原锁执行、`uv sync` 消费的是改写后的锁副本而非 `--default-index`,所以「显式指定」与「自动轮换」在实现上是同一件事、只差顺序;定稿时保留拒绝会形成「自动允许、显式拒绝」的不对称,用户想优先用某个已知可达的镜像反而被判参数错误。落地时同步删除 `internal/cli/errors.go` 的 `rejectPackageIndexOverride`(bootstrap 与 repair 的前置拒绝)。其二,改写用的 `simple` / `packages` 两个前缀改为在 `internal/mirror` 目录中**显式声明**,不再由 `baseURL` 去掉结尾 `simple/` 推导——官方源的 artifact 在 `files.pythonhosted.org`,与索引不同 host,推导会得到不存在的 `https://pypi.org/packages/`;三家镜像同 host 只是巧合。同日实测记入 C10:`aliyun` / `tsinghua` 两个前缀均 HTTP 200 且无跳转,`ustc` 的索引 302 到 `mirrors.ustc.edu.cn/pypi/simple`、artifact 302 到清华(可用,与 `tsinghua` 冗余但不冲突),目录中**没有**腾讯云源。`doc/架构设计.md`「项目依赖同步」同步修订,T13.4 条目与验收项同步更新;`--mirror-only`、`--offline`、临时目录生命周期与 `details` 报告口径均不变,协议仍为 v1、不新增字段/stage/state/错误码 | Claude | | 2026-08-31 | 新增 [协议 v1 契约补充 增补 1](./契约补充-v1-增补1.md)(**C6~C11**,协议仍为 v1,不新增或改名任何字段、stage、state 与错误码),并立项里程碑 **M13「dev 基线接入配套」**(T13.1~T13.6)。六条定稿:**C6** managed 下后端子进程 cwd = app-root、入口传绝对路径 `/repo/main.py`(现实现 `internal/uv/managed.go:83` 用 uv 的 project dir,会把用户数据建在 `repo/` 里并被 `workspace sync` 整体替换掉);**C7** 受监督优先级扩展到端口(固定 36163,忽略 `AUTO_MAS_HTTP_PORT` 与 `.env`/`AUTO_MAS_ENV`),并修订 C2 第 1 条(`protocol` 改为后端自报而非回显注入值,`version`/`commit` 仍回显)与 C4 第 1 条(受监督但非管理员时记 warning 并继续运行,不再要求非零退出);**C8** 游戏与模拟器不随后端退出,Runtime 开 `JOB_OBJECT_LIMIT_BREAKAWAY_OK`、AUTO-MAS 只在拉起游戏/模拟器时带 `CREATE_BREAKAWAY_FROM_JOB`(`DETACHED_PROCESS` 不脱离 Job);**C9** `backend supervise` 新增关闭超时选项,默认仍 5 秒,待实测数据再调;**C10** 锁文件在 PyPI 上生成,`dependencies sync` 改为改写锁副本内两处下载地址前缀参与镜像轮换、失败逐个换源、全部失败回退原锁,安全性由锁内 `sha256` 与 uv 的 `--frozen` 校验保证,`dependencies.go:234` 拒绝覆盖包索引的逻辑保留;**C11** MaaFW 运行池只统一基础设施——新增注入 `AUTO_MAS_UV_CACHE_DIR`/`AUTO_MAS_UV_PYTHON_INSTALL_DIR`/`AUTO_MAS_MIRROR_PACKAGE_INDEX`/`AUTO_MAS_MIRROR_PYTHON`,池目录重新分类,「装什么」仍留在后端。按红线第 2 条先改文档:`doc/架构设计.md` 在目录模型、后端启动、项目依赖同步、镜像与网络策略、CLI 设计、插件环境职责边界、健康检查与关闭契约、Lite/Full 边界和数据分类九处补入增补段落,并补全 dev 基线下后端生成目录的分类;`doc/契约补充-v1.md` 顶部与 C2/C4 两条加指向增补的修订标注;`doc/README.md` 权威优先级更新为「增补 1 > 契约补充-v1 > 架构设计 > 任务拆分」。本次不写任何 Go 代码,M13 全部任务保持未开始 | Claude | | 2026-08-31 | 登记决策 **D12** 并按 dev 基线重写第 5.1 节:AUTO-MAS 侧配合改造基线由 dev_v2 改为 `dev@3c422093`(dev_v2 自 2026-08-06 停更,两条分支自 07-29 分叉后各走四百余提交),D4 标注为已由 D12 取代并保留原文追溯。5.1 的 TODO-PY-1~8 编号沿用、内容与落点整体换成 dev 现状:**TODO-PY-3** 目标文件 `app/plugins/uv_backend.py` 在 dev 不存在,落点改为 MaaFW 的 `automas_maafw_agent_env/env.py:687-691`(运行池那处已由 AUTO-MAS PR #478 做好 `AUTO_MAS_UV_EXE` 注入优先);**TODO-PY-7 不适用于 dev 基线**(dev 无插件系统:无 `app/plugins/`、无 `app/core/plugins/`、`plugins/` 下无内容、Electron 无 `pluginBootstrapService.ts`),保留编号避免引用悬空,dev_v2 插件系统若并回则与 TODO-PY-3 一起重算;其余各条按契约第 02 节 P-1~P-8 收窄或扩写。5.1 末尾新增「MaaFW 专项」小节 **TODO-PY-9~13**(取自契约第 03 节 M-1~M-5 与第 04 节 T-1~T-4,逐条标注落在 AUTO-MAS、Runtime 还是两侧)。第 4 章加注左列仍为 dev_v2 时期内容且插件一行不适用;5.2 加注基线同 5.1、待 R 段定稿后重排;5.3 未改动。本次只改文档,不涉及任何 Go 代码或已冻结契约 | Claude | | 2026-08-27 | 完成 T7.6(实现 `8a2bbfd`):后续 Release 的裸 EXE 在现有 `auto-mas-runtime` 名称后追加 release tag,格式为 `auto-mas-runtime-.exe`;不追加日期、时间或 Commit hash;package、checksum、publish 与 smoke 全链路消费同一个动态文件名,安装后的稳定运行名仍为 `auto-mas-runtime.exe`;红/绿灯、100 次发布契约复跑、PowerShell AST、真实 EXE 和标准门均取得本地证据,未创建 tag/Release 或 push | Codex | diff --git "a/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" "b/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" index 875ca00..7cd6432 100644 --- "a/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" +++ "b/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" @@ -8,6 +8,7 @@ - 上级文档:[协议 v1 契约补充](./契约补充-v1.md) - 架构基线:[架构设计](./架构设计.md) - 实现基线:Runtime `40ef464`;AUTO-MAS `dev@3c422093` 与 MaaFW 内置层 `work/maafw-embedded-20260830@1561bc55`(决策 D12) +- 修订:2026-09-01 修订 C10 对显式 `--mirror package-index=` 的处理,并把镜像改写前缀由推导改为显式声明(T13.4 实施期间,见 C10) 本文档是对 [契约补充-v1](./契约补充-v1.md) 的**增量修订**,定稿六项此前未冻结或与实现矛盾的对外契约,并修订 C2、C4 各一条。 `契约补充-v1.md` 开头已规定「对外字段、字面量或环境变量发生变化时必须升级或增量修订共同契约和测试」,本文档走的正是增量修订这条路: @@ -25,7 +26,7 @@ | C7 | 受监督标记的优先级 | 扩展到端口:受监督时固定 36163,忽略一切开发环境标记;并修订 C2 第 1 条与 C4 第 1 条 | | C8 | Job Object 逃逸 | 游戏与模拟器**不随后端退出**;Runtime 开 `BREAKAWAY_OK`,AUTO-MAS 只在拉起游戏/模拟器时带 `CREATE_BREAKAWAY_FROM_JOB` | | C9 | 关闭预算 | `backend supervise` 新增关闭超时选项,默认值保持 5 秒 | -| C10 | 主项目依赖的镜像 | 锁文件在 PyPI 上生成;`dependencies sync` 改写锁内下载地址前缀参与镜像轮换,**不覆盖包索引** | +| C10 | 主项目依赖的镜像 | 锁文件在 PyPI 上生成;`dependencies sync` 改写锁内下载地址前缀参与镜像轮换,**不覆盖包索引**;显式首选源只改变尝试顺序(2026-09-01 修订) | | C11 | MaaFW 运行池的归属 | 只统一基础设施:Runtime 开放共享 uv 缓存与受管 Python 目录、下发有序镜像源;「装什么」仍留在后端 | --- @@ -167,13 +168,19 @@ Runtime `T13.3`(`internal/backend/control.go`、`internal/cli/backend.go` 与 ## C10:主项目依赖的镜像轮换 +> **2026-09-01 修订(T13.4 实施期间):** 两处。其一,显式 `--mirror package-index=` 不再返回 +> `INVALID_ARGUMENT`,改为把该源排在尝试顺序最前、走同一条改写路径(见「依据」第 1 条)。其二,改写用的 +> `simple` / `packages` 两个前缀由目录显式声明,不再由 `baseURL` 推导(见「镜像 base 的显式声明与新增源的准入」)。 +> 其余条款不变。 + ### 结论 **锁文件在 PyPI 上生成;`dependencies sync` 通过改写锁文件副本内的下载地址前缀参与镜像轮换,而不是覆盖包索引。** 1. **锁文件生成**:AUTO-MAS 发布 CI 在 PyPI 上生成 `uv.lock`,不使用任何国内镜像生成锁。 2. **`uv lock --check` 阶段不变**:对 `repo/uv.lock` 的**原锁**执行,不带任何包索引覆盖参数;在线沿用项目与锁文件 sources,显式离线只注入 `UV_OFFLINE=1`。锁一致性校验的对象始终是原锁。 -3. **`uv sync` 阶段改为镜像改写轮换**,按既有轮换规则依次尝试 `KindPackageIndex` 目录中的源,每个源执行: +3. **`uv sync` 阶段改为镜像改写轮换**,按既有轮换规则依次尝试 `KindPackageIndex` 目录中的源;显式 + `--mirror package-index=` 只改变尝试顺序(该源排最前),不改变机制本身。每个源执行: 1. 读取 `repo/uv.lock` 到内存,做两处**纯字符串前缀改写**: - `https://pypi.org/simple` → `<镜像 base>/simple` - `https://files.pythonhosted.org/packages/` → `<镜像 base>/packages/` @@ -186,11 +193,27 @@ Runtime `T13.3`(`internal/backend/control.go`、`internal/cli/backend.go` 与 6. **临时项目目录**必须位于受管根内、由 Runtime 创建并在操作结束时删除,归入数据分类中的「可丢弃缓存」,不得落在 `repo/`、用户数据目录或受管根之外。 7. `dependencies.sync` 的事件 `details` 必须报告本次实际使用的 package-index 源 key(回退原锁时报告官方源 key)。不新增 `stage`、`state` 或错误码。 -### 镜像 base 的推导与新增源的准入 +### 镜像 base 的显式声明与新增源的准入 + +**每个 `KindPackageIndex` 源在目录中显式声明 `simple` 与 `packages` 两个改写前缀,不从 `baseURL` 推导。** +官方源就是推导规则的反例:它的索引在 `https://pypi.org/simple`,artifact 却在 +`https://files.pythonhosted.org/packages/`,「去掉结尾 `simple/`」会得到根本不存在的 +`https://pypi.org/packages/`。三家镜像恰好索引与 artifact 同 host 只是巧合,不能把巧合写成规则。 + +**新增 package-index 源必须先实测同时提供 `/<包名>/` 与 `` +两种布局**,否则不得进入轮换目录——只有 simple 索引可用的源无法参与本机制。 -镜像 base = 目录中该源 `baseURL` 去掉结尾 `simple/` 之后的前缀。例如 `https://mirrors.aliyun.com/pypi/simple/` → base `https://mirrors.aliyun.com/pypi`,改写后的两个前缀分别是 `https://mirrors.aliyun.com/pypi/simple` 与 `https://mirrors.aliyun.com/pypi/packages/`。 +2026-09-01 对目录内四个源的实测(`curl -sL`,欧洲出口,探针为 `six-1.16.0.tar.gz` 的 PyPI 原路径): + +| 源 key | `simple` 前缀 | `packages` 前缀 | 实测 | +| --- | --- | --- | --- | +| `aliyun` | `https://mirrors.aliyun.com/pypi/simple` | `https://mirrors.aliyun.com/pypi/packages/` | 均 HTTP 200,无跳转 | +| `tsinghua` | `https://pypi.tuna.tsinghua.edu.cn/simple` | `https://pypi.tuna.tsinghua.edu.cn/packages/` | 均 HTTP 200,无跳转 | +| `ustc` | `https://pypi.mirrors.ustc.edu.cn/simple` | `https://pypi.mirrors.ustc.edu.cn/packages/` | 均 HTTP 200;索引 302 到 `mirrors.ustc.edu.cn/pypi/simple`,artifact 302 到清华 | +| `pypi`(官方) | `https://pypi.org/simple` | `https://files.pythonhosted.org/packages/` | 即被改写的**源**侧前缀;官方源不作为改写目标 | -**新增 package-index 源必须同时提供 `/simple/` 与 `/packages/` 两种布局**,否则不得进入轮换目录——只有 simple 索引可用的源无法参与本机制。 +`ustc` 的 artifact 实际由清华提供(302 跳转),与 `tsinghua` 源冗余但不冲突,两者都保留,顺序由目录决定。 +目录中当前**没有**腾讯云源;新增它需要按上一段先实测再入目录,不在 T13.4 范围内。 ### 安全性由 uv 自身保证 @@ -198,7 +221,14 @@ Runtime `T13.3`(`internal/backend/control.go`、`internal/cli/backend.go` 与 ### 依据 -- `internal/uv/dependencies.go:234-242` 明确拒绝为主项目依赖覆盖包索引(`INVALID_ARGUMENT`「锁定依赖不支持覆盖包索引」)。**这条拒绝逻辑保留**:本机制不是覆盖索引,`--locked` 校验仍对原锁做。显式 `--mirror package-index=` 继续返回 `INVALID_ARGUMENT`;轮换按目录的默认顺序自动进行,不由调用方指定首选源。 +- `internal/uv/dependencies.go:234-242` 曾拒绝为主项目依赖覆盖包索引(`INVALID_ARGUMENT`「锁定依赖不支持覆盖包索引」)。 + **2026-09-01 修订:这条拒绝逻辑删除**(连同 `internal/cli/errors.go` 里 bootstrap 与 repair 的前置拒绝), + 显式 `--mirror package-index=` 改为把该源排在尝试顺序最前,与自动轮换走同一条改写路径。 + 理由是**改写不是覆盖索引**:`--locked` 校验仍对原锁做,`uv sync` 消费的是改写后的锁副本而不是 + `--default-index`,所以「显式指定」和「自动轮换」在实现上是同一件事、只差顺序。保留拒绝会形成 + 「自动轮换允许、显式指定拒绝」的不对称——用户想优先用某个已知可达的镜像,反而被判参数错误。 + `--mirror-only`、`--offline` 与既有镜像选择校验(不存在的 key、`--mirror-only` 下指定官方源经 + `mirror.BuildPlan` 返回 `ErrPolicyRejected`)语义不变。 - `internal/mirror/defaults.go` 定义了 4 个 `KindPackageIndex` 源(`aliyun` / `tsinghua` / `ustc` / 官方 `pypi`),而唯一的消费方拒绝使用它们——这套镜像能力现在是**死代码**,本条正是它的正当用途。 - 2026-07-29 AUTO-MAS 曾把 uv 整体回滚(`5af4f219` 被 `cee68e27` 回滚,同日连带回滚锁文件 gitignore 与 uv 下载镜像 PR #310)。回滚原因是**没给用户做镜像多路重试,而多数用户网络特殊**。不解决这条就是重蹈覆辙;而 `uv.lock` 现在是 managed 模式的硬前提(没有它就没有 `uv sync --locked`),所以这次要决的不是「要不要用 uv」,而是怎么让受限网络下的用户装得上。 diff --git "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" index 9cd7884..cfbebb8 100644 --- "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" +++ "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -1294,7 +1294,8 @@ URL,替换默认索引会使 uv 正确判定锁需要更新,并破坏 `--loc **2026-08-31 增补([增补 1 C10](./契约补充-v1-增补1.md#c10主项目依赖的镜像轮换)):镜像改写轮换。** 锁文件在 PyPI 上生成,`uv lock --check` 始终对 `repo/uv.lock` 的**原锁**执行、口径不变; -`uv sync` 阶段改为按既有轮换规则依次尝试 `KindPackageIndex` 目录中的源,每个源执行: +`uv sync` 阶段改为按既有轮换规则依次尝试 `KindPackageIndex` 目录中的源(显式 +`--mirror package-index=` 把该源排在最前,不改变机制),每个源执行: 1. 把 `repo/uv.lock` 读进内存,做两处纯字符串前缀改写——`https://pypi.org/simple` → `<镜像 base>/simple`, `https://files.pythonhosted.org/packages/` → `<镜像 base>/packages/`; @@ -1310,11 +1311,14 @@ URL,替换默认索引会使 uv 正确判定锁需要更新,并破坏 `--loc `dependencies.sync` 事件的 `details` 报告本次实际使用的源 key;不新增 stage、state 或错误码。 安全性由 uv 自身保证:改写只动下载位置,锁内每个 artifact 的 `sha256` 原样保留,uv 在 `--frozen` -安装时逐个校验,镜像返回不同字节即拒装。镜像 base 由目录中该源 `baseURL` 去掉结尾 `simple/` -推导;新增 package-index 源必须同时提供 `/simple/` 与 `/packages/` -两种布局,否则不得进入轮换目录。`internal/uv/dependencies.go` 对显式 -`--mirror package-index=` 的 `INVALID_ARGUMENT` 拒绝**保留**——改写不是覆盖索引,轮换按目录 -默认顺序自动进行,不由调用方指定首选源。 +安装时逐个校验,镜像返回不同字节即拒装。 + +**2026-09-01 修订(T13.4 实施期间,见增补 1 C10):** 每个 package-index 源在目录中**显式声明** +`simple` 与 `packages` 两个改写前缀,不再由 `baseURL` 推导——官方源的 artifact 在 +`files.pythonhosted.org`,与它的索引 host 不同,推导会得到不存在的地址;新增源必须先实测两种布局 +都可用才能进入轮换目录。显式 `--mirror package-index=` **不再返回 `INVALID_ARGUMENT`**,改为把 +该源排在尝试顺序最前并走同一条改写路径:改写不是覆盖索引,`--locked` 校验仍对原锁执行,因此显式 +指定与自动轮换本就是同一条路径、只差顺序。 依赖同步是后端启动的独立前置阶段。`uv sync` 失败时: From 8de0da99ec8bb5fedc324616bd69bf8d96f8d7a8 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 21:41:59 +0200 Subject: [PATCH 06/57] =?UTF-8?q?feat(backend):=20managed=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E5=B7=A5=E4=BD=9C=E7=9B=AE=E5=BD=95=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=20app-root=20(T13.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按增补 1 C6:uv RunOptions 新增 WorkingDir(为空回退 ProjectDir), managed 的后端子进程 cwd 固定为 app-root、入口改传绝对路径 /repo/main.py,避免用户数据落在会被 workspace sync 整体替换 的 repo/ 里。development 的 cwd 与裸入口保持不变,一次性 uv 命令 一个都不传新字段,行为字节不变。 --- ...45\345\217\243\350\267\257\345\276\204.md" | 90 +++++++++++++++++++ internal/backend/control.go | 13 ++- internal/backend/control_test.go | 46 ++++++++++ internal/backend/development_test.go | 7 ++ internal/backend/e2e_windows_test.go | 23 +++++ internal/backend/supervisor.go | 7 +- internal/backend/supervisor_test.go | 5 +- internal/uv/managed.go | 2 +- internal/uv/managed_test.go | 75 ++++++++++++++++ internal/uv/runner.go | 16 +++- internal/uv/runner_test.go | 50 +++++++++++ testdata/fakebackend/main.go | 22 ++++- 12 files changed, 344 insertions(+), 12 deletions(-) create mode 100644 "doc/current/M13/\350\256\276\350\256\241-T13.1-\345\220\216\347\253\257\345\267\245\344\275\234\347\233\256\345\275\225\344\270\216\347\273\235\345\257\271\345\205\245\345\217\243\350\267\257\345\276\204.md" diff --git "a/doc/current/M13/\350\256\276\350\256\241-T13.1-\345\220\216\347\253\257\345\267\245\344\275\234\347\233\256\345\275\225\344\270\216\347\273\235\345\257\271\345\205\245\345\217\243\350\267\257\345\276\204.md" "b/doc/current/M13/\350\256\276\350\256\241-T13.1-\345\220\216\347\253\257\345\267\245\344\275\234\347\233\256\345\275\225\344\270\216\347\273\235\345\257\271\345\205\245\345\217\243\350\267\257\345\276\204.md" new file mode 100644 index 0000000..1fa47e8 --- /dev/null +++ "b/doc/current/M13/\350\256\276\350\256\241-T13.1-\345\220\216\347\253\257\345\267\245\344\275\234\347\233\256\345\275\225\344\270\216\347\273\235\345\257\271\345\205\245\345\217\243\350\267\257\345\276\204.md" @@ -0,0 +1,90 @@ +# 设计与计划 T13.1 后端工作目录与绝对入口路径 + +- 契约:[增补 1 C6](../../契约补充-v1-增补1.md#c6后端进程工作目录与入口路径) +- 任务:[任务拆分 T13.1](../../任务拆分.md) +- 状态:设计 + 计划(本文件同时承担四段式的前两段) + +## 目标 + +managed 模式下把 Runtime 创建的后端子进程工作目录从 uv 的 project dir(`/repo`) +改为 **app-root**,入口参数从裸 `main.py` 改为绝对路径 `/repo/main.py`。 +`--project` 仍指向 `/repo`。development 模式的 cwd 与入口**不变**。 + +修的是实现偏离文档:架构设计「目录模型」与「插件环境职责边界」本就要求 cwd = 项目根目录, +而 `internal/uv/managed.go:83` 无条件用 `resolved.ProjectDir`。按现实现,后端会把 +`config/ data/ history/ script/ debug/` 建进 `repo/`,被 `workspace sync` 的整体替换清掉。 + +## 边界(不负责) + +- 不改 development 模式的任何行为(cwd 仍是 `--repo`,入口仍是裸 `main.py`); +- 不改一次性 uv 命令(`uv python install` / `uv sync` / `uv pip`)的既有行为——新字段为空时 + 回退 `ProjectDir`,这些调用方一个都不传新字段; +- 不新增、不改名任何协议字段、`stage`、`state`、错误码或 error `details` 键(M13 不得扩大契约面); +- 不改 Python 侧(`AUTO-MAS` 的 `TODO-PY-4` / `TODO-PY-10` 成对落地,本仓库管不到)。 + +## API 形态 + +`internal/uv`: + +```go +// RunOptions +WorkingDir string // 子进程工作目录;为空回退 ProjectDir +``` + +`resolvedRunOptions` 同步增加 `WorkingDir`,`resolveOptions` 在解析出 `ProjectDir` 之后 +用它做默认值,显式非空值覆盖。`UVRunner.Run` 与 `UVRunner.StartManaged` 都改用 +`resolved.WorkingDir` 作为子进程 `Dir`。 + +`internal/backend`: + +- `supervisor.go` 的 managed 无控制通道路径:argv 末项改 `s.layout.BackendEntryFile()`, + `RunOptions.WorkingDir = s.layout.AppRoot()`; +- `control.go` 的受控启动路径(**managed 与 development 共用同一处 `StartManaged`**, + 与任务描述里「control.go 是 development 路径」的说法不符,以仓库现状为准):按 mode 分支, + managed 用绝对入口 + app-root,development 保持 `"main.py"` + 空 `WorkingDir`; +- `production.go` 的 `productionUV.StartManaged` 整体透传 `uv.ManagedOptions`,无需改动。 + +## 失败语义 + +`WorkingDir` 与其余受管路径一样进 `validateRunnerPaths`:空字符串(回退后仍为空)或含 NUL +时返回 `UV_EXEC_FAILED`,与既有路径校验完全一致,不新增错误码。启动失败的 +`details` 键集合保持不变。 + +## Task 拆分 + +### Task 1:`internal/uv` 的显式工作目录 + +- 文件:`internal/uv/runner.go`、`internal/uv/managed.go`、`internal/uv/managed_test.go`、 + `internal/uv/runner_test.go` +- 红灯:`TestManaged_WorkingDirOverridesProjectDir`(StartManaged 传 `WorkingDir` 后子进程 + `os.Getwd()` 等于该目录)与 `TestRunner_WorkingDirDefaultsToProjectDir`(一次性 `Run` + 不传时 cwd 仍为 ProjectDir、传了则用新值)——字段不存在,编译失败 +- 绿灯:加 `WorkingDir` 字段与回退逻辑 +- 验证:`go test ./internal/uv -run 'WorkingDir' -count=1 -v` + +### Task 2:managed 的 app-root 与绝对入口 + +- 文件:`internal/backend/supervisor.go`、`internal/backend/control.go`、 + `internal/backend/supervisor_test.go`、`internal/backend/control_test.go` +- 红灯:`TestSupervise_ManagedUsesAppRootAndAbsoluteEntry`、 + `TestControl_ManagedUsesAppRootAndAbsoluteEntry`、 + `TestControl_DevelopmentKeepsRepoWorkingDirAndRelativeEntry` +- 绿灯:按 mode 分支设置 argv 末项与 `WorkingDir` +- 验证:`go test ./internal/backend -run 'AppRoot|RelativeEntry' -count=1 -v` + +### Task 3:E2E 的真实 cwd 证明 + +- 文件:`testdata/fakebackend/main.go`(health 响应增加 `workingDir`,仅测试夹具,不是协议)、 + `internal/backend/e2e_windows_test.go` +- 红灯:development E2E 断言子进程 cwd == `--repo`;新增 uv 记录断言 argv 末项形态 +- 绿灯:夹具落地 cwd 文件 + 断言通过 +- 验证:`go test ./internal/backend -run 'BackendE2E' -count=1 -v` + +## 验收对照 + +| 任务拆分验收项 | 覆盖方式 | +| --- | --- | +| 后端 cwd 为 app-root、入口为绝对路径 | Task 2 单测(`StartSpec.Dir`/argv)+ Task 1 真实子进程 `os.Getwd()` | +| development E2E 与既有一次性命令无回退 | Task 1 回退语义 + Task 3 development E2E cwd 断言 | +| 单次自动重启后 cwd 与入口不漂移 | `control.go` 的重启复用同一处 `StartManaged`,Task 2 断言覆盖两次调用 | +| 用户数据在 `workspace sync` 后仍存在 | 由 cwd 移出 `repo/` 直接保证;跨仓联合验收(TODO-PY-4/10)才能端到端证明 | diff --git a/internal/backend/control.go b/internal/backend/control.go index b9aef8e..7bed1d9 100644 --- a/internal/backend/control.go +++ b/internal/backend/control.go @@ -21,6 +21,9 @@ const ( defaultRestartDelay = 2 * time.Second controlDrainTimeout = time.Second backendCloseURL = "http://127.0.0.1:36163/api/core/close" + // developmentEntryArgument 是 development 模式沿用的相对入口:cwd 就是 + // --repo 指定的源码目录,绝对路径只属于 managed(增补 1 C6 第 2 条)。 + developmentEntryArgument = "main.py" ) var ( @@ -1223,12 +1226,18 @@ func (s *ManagedSupervisor) startControlAttempt(ctx context.Context, request Req mode := modeForRequest(request) projectDir := s.layout.RepoDir() projectEnvDir := "" + // managed 的 cwd 与入口按增补 1 C6 固定为 app-root 与绝对入口路径; + // development 保持空 WorkingDir(回退 --repo)与裸入口,行为不变。 + workingDir := s.layout.AppRoot() + entryArgument := s.layout.BackendEntryFile() var identity *uv.SupervisionIdentity pythonPaths := append([]string(nil), s.deps.PythonPaths...) if mode == ModeDevelopment { projectDir = request.DevelopmentRepo projectEnvDir = developmentProjectEnv(projectDir) pythonPaths = []string{developmentPythonPath(projectDir)} + workingDir = "" + entryArgument = developmentEntryArgument } else { identity = &uv.SupervisionIdentity{Version: revision.Version, Commit: revision.Commit} } @@ -1295,8 +1304,8 @@ func (s *ManagedSupervisor) startControlAttempt(ctx context.Context, request Req ) return nil, errors.Join(ctxErr, cleanupErr) } - proc, err := s.deps.UV.StartManaged(ctx, []string{"run", "--project", projectDir, "--no-sync", "main.py"}, uv.ManagedOptions{ - RunOptions: uv.RunOptions{Stage: protocol.StageBackendSpawn, ProjectDir: projectDir, ProjectEnvDir: projectEnvDir}, + proc, err := s.deps.UV.StartManaged(ctx, []string{"run", "--project", projectDir, "--no-sync", entryArgument}, uv.ManagedOptions{ + RunOptions: uv.RunOptions{Stage: protocol.StageBackendSpawn, WorkingDir: workingDir, ProjectDir: projectDir, ProjectEnvDir: projectEnvDir}, Identity: identity, }, s.streamSink(request, logger, gate)) if err != nil || proc == nil { diff --git a/internal/backend/control_test.go b/internal/backend/control_test.go index e7d3437..1f9f68c 100644 --- a/internal/backend/control_test.go +++ b/internal/backend/control_test.go @@ -3,6 +3,7 @@ package backend import ( "context" "errors" + "path/filepath" "sync" "sync/atomic" "testing" @@ -948,6 +949,51 @@ func TestBackend_ShutdownFailedIfTreeUncertain(t *testing.T) { } } +// TestBackend_ControlledManagedUsesAppRootAndAbsoluteEntry 覆盖受控 managed 启动路径 +// (supervisor.go 的无控制路径之外的第二处 StartManaged),并顺带证明单次自动重启 +// 之后 cwd 与入口都不漂移——两代进程走的是同一处参数装配。 +func TestBackend_ControlledManagedUsesAppRootAndAbsoluteEntry(t *testing.T) { + f := newBackendFixture(t) + f.proc.keepAlive = true + second := &fakeProcess{pid: 4343, keepAlive: true} + f.uv.procSequence = []ManagedProcess{f.proc, second} + mailbox := NewControlMailbox(8) + done := make(chan error, 1) + go func() { + req := f.request() + req.Control = mailbox + done <- f.supervisor().Supervise(t.Context(), req) + }() + waitFor(t, f.emitter.running) + wantArgs := []string{"run", "--project", f.layout.RepoDir(), "--no-sync", f.layout.BackendEntryFile()} + if !equalStrings(f.uv.args, wantArgs) { + t.Fatalf("uv args = %#v, want %#v", f.uv.args, wantArgs) + } + if !filepath.IsAbs(f.uv.args[len(f.uv.args)-1]) { + t.Fatalf("uv entry argument = %q, want an absolute path", f.uv.args[len(f.uv.args)-1]) + } + if got, want := f.uv.options.WorkingDir, f.layout.AppRoot(); got != want { + t.Fatalf("managed working dir = %q, want app root %q", got, want) + } + if got, want := f.uv.options.ProjectDir, f.layout.RepoDir(); got != want { + t.Fatalf("managed project dir = %q, want %q", got, want) + } + f.proc.Exit() + waitForStateStatusCount(t, f.emitter, protocol.StateRunning, 2) + if !equalStrings(f.uv.args, wantArgs) { + t.Fatalf("uv args after restart = %#v, want %#v", f.uv.args, wantArgs) + } + if got, want := f.uv.options.WorkingDir, f.layout.AppRoot(); got != want { + t.Fatalf("managed working dir after restart = %q, want app root %q", got, want) + } + if err := mailbox.Submit(context.Background(), protocol.ControlCommand{Command: protocol.ControlCancel, CommandID: "cancel-after-workdir-check"}); err != nil { + t.Fatalf("Submit(cancel) error = %v", err) + } + if err := <-done; !hasBackendCode(err, protocol.CodeOperationCancelled) && !errors.Is(err, context.Canceled) { + t.Fatalf("Supervise() error = %v, want cancellation", err) + } +} + func TestBackend_FirstUnexpectedExitRestartsOnce(t *testing.T) { f := newBackendFixture(t) f.proc.keepAlive = true diff --git a/internal/backend/development_test.go b/internal/backend/development_test.go index 39c8c60..c741485 100644 --- a/internal/backend/development_test.go +++ b/internal/backend/development_test.go @@ -62,6 +62,13 @@ func TestBackendDevelopment_UsesExistingVenvWithoutSync(t *testing.T) { if !equalStrings(f.uv.args, wantArgs) { t.Fatalf("uv args = %#v, want %#v", f.uv.args, wantArgs) } + // C6 只改 managed:development 不传 WorkingDir,cwd 仍由 ProjectDir(--repo)决定。 + if got := f.uv.options.WorkingDir; got != "" { + t.Fatalf("development working dir = %q, want empty so it falls back to the repo", got) + } + if got, want := f.uv.options.ProjectDir, repo; got != want { + t.Fatalf("development project dir = %q, want %q", got, want) + } if f.uv.options.Identity != nil { t.Fatalf("development identity = %#v, want nil", f.uv.options.Identity) } diff --git a/internal/backend/e2e_windows_test.go b/internal/backend/e2e_windows_test.go index b67ffb5..ece0c71 100644 --- a/internal/backend/e2e_windows_test.go +++ b/internal/backend/e2e_windows_test.go @@ -41,6 +41,7 @@ const ( type backendE2EConfig struct { ListenAddress string `json:"listenAddress,omitempty"` PIDFile string `json:"pidFile,omitempty"` + WorkingDirFile string `json:"workingDirFile,omitempty"` GrandchildPIDFile string `json:"grandchildPidFile,omitempty"` SpawnGrandchild bool `json:"spawnGrandchild,omitempty"` GrandchildLifetimeMS int `json:"grandchildLifetimeMs,omitempty"` @@ -238,6 +239,7 @@ type backendE2EFixture struct { repo string configPath string rootPID string + workingDir string grandchildPID string uvExecReady string uvExecRelease string @@ -380,6 +382,7 @@ func newBackendE2EFixture(t *testing.T, configValue backendE2EConfig) *backendE2 } configValue.ListenAddress = "127.0.0.1:36163" configValue.PIDFile = filepath.Join(root, "python.pid") + configValue.WorkingDirFile = filepath.Join(root, "backend.cwd") configValue.GrandchildPIDFile = filepath.Join(root, "grandchild.pid") rootPIDPath := filepath.Join(root, "uv.pid") uvExecReadyPath := "" @@ -434,6 +437,7 @@ func newBackendE2EFixture(t *testing.T, configValue backendE2EConfig) *backendE2 repo: repo, configPath: backendConfigPath, rootPID: rootPIDPath, + workingDir: configValue.WorkingDirFile, grandchildPID: configValue.GrandchildPIDFile, uvExecReady: uvExecReadyPath, uvExecRelease: uvExecReleasePath, @@ -550,6 +554,7 @@ func TestBackendE2E_LifecycleSpawnReadyShutdown(t *testing.T) { t.Fatalf("development tree changed: got %#v, want %#v", got, repositorySnapshot) } assertE2EDevelopmentUVEnvironment(t, fixture) + assertE2EDevelopmentWorkingDir(t, fixture) assertE2EStateSequence(t, fixture.emitter.statesSnapshot(), protocol.StateStartingBackend, protocol.StateRunning, protocol.StateStoppingBackend, protocol.StateStopped) assertE2EPersistentLog(t, running, "lifecycle") assertE2ETimelineBefore(t, fixture.emitter, "state:"+string(protocol.StateStartingBackend), "log:lifecycle ") @@ -578,6 +583,24 @@ func TestBackendE2E_LifecycleSpawnReadyShutdown(t *testing.T) { } } +// assertE2EDevelopmentWorkingDir 证明 development 的 cwd 仍是 --repo:增补 1 C6 +// 只把 managed 的工作目录改到 app-root,development 一个字节都不能变。 +func assertE2EDevelopmentWorkingDir(t *testing.T, fixture *backendE2EFixture) { + t.Helper() + waitE2EFile(t, fixture.workingDir) + payload, err := os.ReadFile(fixture.workingDir) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", fixture.workingDir, err) + } + got := filepath.Clean(strings.TrimSpace(string(payload))) + if want := filepath.Clean(fixture.repo); got != want { + t.Fatalf("development backend cwd = %q, want %q", got, want) + } + if got == filepath.Clean(fixture.layout.AppRoot()) { + t.Fatalf("development backend cwd = %q, want the repo rather than the app root", got) + } +} + func assertE2EDevelopmentUVEnvironment(t *testing.T, fixture *backendE2EFixture) { t.Helper() payload, err := os.ReadFile(fixture.uvRecord) diff --git a/internal/backend/supervisor.go b/internal/backend/supervisor.go index 2893753..ffa50e8 100644 --- a/internal/backend/supervisor.go +++ b/internal/backend/supervisor.go @@ -201,10 +201,13 @@ func (s *ManagedSupervisor) Supervise(ctx context.Context, request Request) (ret sink := s.streamSink(request, logger, gate) processOwned := false proc, err := s.deps.UV.StartManaged(ctx, []string{ - "run", "--project", s.layout.RepoDir(), "--no-sync", "main.py", + "run", "--project", s.layout.RepoDir(), "--no-sync", s.layout.BackendEntryFile(), }, uv.ManagedOptions{ RunOptions: uv.RunOptions{ - Stage: protocol.StageBackendSpawn, + Stage: protocol.StageBackendSpawn, + // cwd 是 app-root 而不是 repo:后端相对 cwd 创建的用户数据必须留在 + // workspace sync 整体替换范围之外(增补 1 C6)。入口随之改传绝对路径。 + WorkingDir: s.layout.AppRoot(), ProjectDir: s.layout.RepoDir(), Line: nil, }, diff --git a/internal/backend/supervisor_test.go b/internal/backend/supervisor_test.go index 87c17b8..e80f183 100644 --- a/internal/backend/supervisor_test.go +++ b/internal/backend/supervisor_test.go @@ -110,9 +110,12 @@ func TestBackendManaged_UsesExactUVArgsAndEnvironment(t *testing.T) { t.Fatalf("Supervise() error = %v, want context.Canceled", err) } - if got, want := f.uv.args, []string{"run", "--project", f.layout.RepoDir(), "--no-sync", "main.py"}; !equalStrings(got, want) { + if got, want := f.uv.args, []string{"run", "--project", f.layout.RepoDir(), "--no-sync", f.layout.BackendEntryFile()}; !equalStrings(got, want) { t.Fatalf("uv args = %#v, want %#v", got, want) } + if got, want := f.uv.options.WorkingDir, f.layout.AppRoot(); got != want { + t.Fatalf("managed working dir = %q, want app root %q", got, want) + } if got, want := f.uv.checkOptions.ProjectDir, f.layout.RepoDir(); got != want { t.Fatalf("managed uv preflight ProjectDir = %q, want %q", got, want) } diff --git a/internal/uv/managed.go b/internal/uv/managed.go index f07b6ce..9db42e8 100644 --- a/internal/uv/managed.go +++ b/internal/uv/managed.go @@ -80,7 +80,7 @@ func (r *UVRunner) StartManaged( managed, err := process.StartManaged(ctx, process.StartSpec{ Executable: r.Executable, Args: append([]string(nil), args...), - Dir: resolved.ProjectDir, + Dir: resolved.WorkingDir, Env: buildEnvironmentWithSupervision(resolved, supervision), Sink: sink, }) diff --git a/internal/uv/managed_test.go b/internal/uv/managed_test.go index e287564..0c074a1 100644 --- a/internal/uv/managed_test.go +++ b/internal/uv/managed_test.go @@ -75,6 +75,81 @@ func TestManaged_UsesRunnerEnvironmentAndArguments(t *testing.T) { } } +// TestManaged_WorkingDirOverridesProjectDir 证明 C6 的显式工作目录字段: +// 受管子进程的 cwd 由 WorkingDir 决定,而不再固定跟随 ProjectDir。 +func TestManaged_WorkingDirOverridesProjectDir(t *testing.T) { + runner := newTestRunner(t) + workingDir := t.TempDir() + recordPath := filepath.Join(t.TempDir(), "managed-workingdir-record.txt") + managed, err := runner.StartManaged(t.Context(), []string{ + "-test.run=^TestFakeUVProcess$", + }, ManagedOptions{ + RunOptions: RunOptions{ + Stage: protocol.StageBackendSpawn, + WorkingDir: workingDir, + Environment: map[string]string{"FAKE_UV_RECORD": recordPath}, + }, + }, nil) + if err != nil { + t.Fatalf("StartManaged() error = %v", err) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + result, err := managed.Wait(ctx) + if err != nil || result.ExitCode != 0 { + t.Fatalf("Wait() = %#v, %v, want exit 0", result, err) + } + if err := managed.WaitEmpty(ctx); err != nil { + t.Fatal(err) + } + if err := managed.Close(); err != nil { + t.Fatal(err) + } + record := readTestRecord(t, recordPath) + if got, want := record["cwd"], filepath.Clean(workingDir); got != want { + t.Fatalf("child cwd = %q, want %q", got, want) + } + if got := record["cwd"]; got == filepath.Clean(runner.ProjectDir) { + t.Fatalf("child cwd = %q, want a directory other than the project dir", got) + } +} + +// TestManaged_WorkingDirDefaultsToProjectDir 锁定 development 的既有行为: +// 不传 WorkingDir 时 cwd 必须仍是 ProjectDir。 +func TestManaged_WorkingDirDefaultsToProjectDir(t *testing.T) { + runner := newTestRunner(t) + projectDir := t.TempDir() + recordPath := filepath.Join(t.TempDir(), "managed-default-workingdir-record.txt") + managed, err := runner.StartManaged(t.Context(), []string{ + "-test.run=^TestFakeUVProcess$", + }, ManagedOptions{ + RunOptions: RunOptions{ + Stage: protocol.StageBackendSpawn, + ProjectDir: projectDir, + Environment: map[string]string{"FAKE_UV_RECORD": recordPath}, + }, + }, nil) + if err != nil { + t.Fatalf("StartManaged() error = %v", err) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + result, err := managed.Wait(ctx) + if err != nil || result.ExitCode != 0 { + t.Fatalf("Wait() = %#v, %v, want exit 0", result, err) + } + if err := managed.WaitEmpty(ctx); err != nil { + t.Fatal(err) + } + if err := managed.Close(); err != nil { + t.Fatal(err) + } + record := readTestRecord(t, recordPath) + if got, want := record["cwd"], filepath.Clean(projectDir); got != want { + t.Fatalf("child cwd = %q, want %q", got, want) + } +} + func TestManaged_ScrubsHostSupervisionEnvironment(t *testing.T) { for _, key := range []string{ autoMASUVExecutable, diff --git a/internal/uv/runner.go b/internal/uv/runner.go index 68a01b5..7a99199 100644 --- a/internal/uv/runner.go +++ b/internal/uv/runner.go @@ -55,7 +55,11 @@ type RunnerConfig struct { // RunOptions 描述单次 uv 调用的可变信息。 type RunOptions struct { - Stage protocol.Stage + Stage protocol.Stage + // WorkingDir 是子进程的工作目录;为空时回退 ProjectDir。 + // managed 后端按增补 1 C6 传 app-root,使用户数据不再落在会被 + // workspace sync 整体替换的 repo/ 里;一次性 uv 命令都不传,行为不变。 + WorkingDir string ProjectDir string PythonInstallDir string ProjectEnvDir string @@ -151,7 +155,7 @@ func (r *UVRunner) Run( runContext, cancelRun := context.WithCancel(ctx) defer cancelRun() command := exec.CommandContext(runContext, r.Executable, args...) - command.Dir = resolved.ProjectDir + command.Dir = resolved.WorkingDir command.Env = buildEnvironment(resolved) // 不使用 command.StdoutPipe/StderrPipe:那两者返回的读端归 exec 所有, // command.Wait() 会在子进程退出后立即关闭它们,导致仍在进行或尚未被调度的 @@ -398,6 +402,7 @@ func normalizeVersionOutput(output string) string { } type resolvedRunOptions struct { + WorkingDir string ProjectDir string PythonInstallDir string ProjectEnvDir string @@ -433,11 +438,18 @@ func (r *UVRunner) resolveOptions(options RunOptions) resolvedRunOptions { if options.CacheDir != "" { values.CacheDir = options.CacheDir } + // WorkingDir 在 ProjectDir 解析完成之后再定,回退值必须是最终的 + // ProjectDir,否则 development 传 ProjectDir 时 cwd 会漂到 runner 默认值。 + values.WorkingDir = values.ProjectDir + if options.WorkingDir != "" { + values.WorkingDir = options.WorkingDir + } return values } func validateRunnerPaths(options resolvedRunOptions) error { for name, path := range map[string]string{ + "working directory": options.WorkingDir, "project directory": options.ProjectDir, "python install directory": options.PythonInstallDir, "project environment directory": options.ProjectEnvDir, diff --git a/internal/uv/runner_test.go b/internal/uv/runner_test.go index e277fb1..87cc84f 100644 --- a/internal/uv/runner_test.go +++ b/internal/uv/runner_test.go @@ -25,6 +25,49 @@ func telemetryEnvironmentKeysForTest() []string { } } +// TestRunner_WorkingDirDefaultsToProjectDir 覆盖一次性 uv 命令:所有既有调用方 +// 都不传 WorkingDir,它们的 cwd 必须逐字节保持为 ProjectDir(C6 只改 managed 后端)。 +func TestRunner_WorkingDirDefaultsToProjectDir(t *testing.T) { + tests := []struct { + name string + workingDir func(t *testing.T) string + want func(t *testing.T, runner *UVRunner, workingDir string) string + }{ + { + name: "empty falls back to project dir", + workingDir: func(*testing.T) string { return "" }, + want: func(_ *testing.T, runner *UVRunner, _ string) string { + return filepath.Clean(runner.ProjectDir) + }, + }, + { + name: "explicit value wins", + workingDir: func(t *testing.T) string { return t.TempDir() }, + want: func(_ *testing.T, _ *UVRunner, workingDir string) string { + return filepath.Clean(workingDir) + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + runner := newTestRunner(t) + workingDir := test.workingDir(t) + recordPath := filepath.Join(t.TempDir(), "run-workingdir-record.txt") + if _, err := runner.Run(t.Context(), []string{"-test.run=^TestFakeUVProcess$"}, RunOptions{ + Stage: protocol.StageDependenciesSync, + WorkingDir: workingDir, + Environment: map[string]string{"FAKE_UV_RECORD": recordPath}, + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + record := readTestRecord(t, recordPath) + if got, want := record["cwd"], test.want(t, runner, workingDir); got != want { + t.Fatalf("child cwd = %q, want %q", got, want) + } + }) + } +} + func TestRunner_ScrubsUnmanagedUVEnvironment(t *testing.T) { t.Setenv("UV_INSECURE_HOST", "unsafe.example") t.Setenv("uv_no_sources", "1") @@ -579,6 +622,13 @@ func TestFakeUVProcess(t *testing.T) { for index, argument := range os.Args { lines = append(lines, fmt.Sprintf("arg%d=%s", index, argument)) } + // cwd 是 T13.1 断言子进程工作目录的唯一真实证据:只有真实子进程 + // 报告的 Getwd 才能证明 StartSpec.Dir 生效,父进程侧断言做不到。 + workingDirectory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + lines = append(lines, "cwd="+filepath.Clean(workingDirectory)) lines = append(lines, os.Environ()...) for _, key := range []string{ uvPythonInstallDirEnv, diff --git a/testdata/fakebackend/main.go b/testdata/fakebackend/main.go index c8c4932..800b369 100644 --- a/testdata/fakebackend/main.go +++ b/testdata/fakebackend/main.go @@ -30,10 +30,13 @@ const ( ) type fakeBackendConfig struct { - ListenAddress string `json:"listenAddress"` - ListenDelayMS int `json:"listenDelayMs"` - ReadyFile string `json:"readyFile"` - PIDFile string `json:"pidFile"` + ListenAddress string `json:"listenAddress"` + ListenDelayMS int `json:"listenDelayMs"` + ReadyFile string `json:"readyFile"` + PIDFile string `json:"pidFile"` + // WorkingDirFile 让假后端报告自己的 os.Getwd(),供 T13.1 端到端断言 + // Runtime 设定的工作目录真的生效;父进程侧的 StartSpec 断言证明不了这件事。 + WorkingDirFile string `json:"workingDirFile"` GrandchildPIDFile string `json:"grandchildPidFile"` SpawnGrandchild bool `json:"spawnGrandchild"` GrandchildLifetimeMS int `json:"grandchildLifetimeMs"` @@ -196,6 +199,17 @@ func runFakeBackend() int { return 98 } } + if config.WorkingDirFile != "" { + workingDirectory, err := os.Getwd() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 97 + } + if err := writeSignalFile(config.WorkingDirFile, []byte(filepath.Clean(workingDirectory)+"\n")); err != nil { + fmt.Fprintln(os.Stderr, err) + return 97 + } + } grandchild, err := startGrandchild(config) if err != nil { From 806cba7a8f5f72949707ef5d4cbbd612a932e7cb Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 21:42:32 +0200 Subject: [PATCH 07/57] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20T13.1=20?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=83=85=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...3\273\345\212\241\346\213\206\345\210\206.md" | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index f6b5e4c..00476d6 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -914,10 +914,24 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 > T13.1/T13.2/T13.3 互不依赖,可并行;T13.4 → T13.5 → T13.6 有先后(都要动 `internal/mirror` 的消费面)。 > 每项都与 AUTO-MAS 侧第 5.1 章的某条 `TODO-PY-*` 成对,单侧上线通常观察不到预期行为,验收以跨仓联合为准。 -- [ ] **T13.1 后端工作目录与绝对入口路径**(M) +- [x] **T13.1 后端工作目录与绝对入口路径**(M) ✅ 2026-09-01 `8de0da9` - 依赖:M6;契约 [增补 1 C6](./契约补充-v1-增补1.md#c6后端进程工作目录与入口路径);成对 TODO-PY-4、TODO-PY-10 - 内容:managed 模式下把后端子进程的工作目录从 uv project dir 改为 app-root,入口参数改传绝对路径 `/repo/main.py`。`internal/uv` 的 `RunOptions` 目前没有独立的工作目录字段(`internal/uv/managed.go:83` 直接用 `resolved.ProjectDir`),需要补一个并保证一次性命令(`uv python install`、`uv sync`、`uv pip`)的既有行为字节不变;`internal/backend/supervisor.go` 的 argv 与 `internal/backend/control.go` 的重启路径同步改。development 模式的 cwd 与入口保持不变。 - 验收:Windows E2E 在临时受管根下证明后端 cwd 为 app-root、入口为绝对路径;**用户数据在一次 `workspace sync` 整体替换后仍然存在**(这是本条的直接验证,也是 C6 要解决的数据损失);development E2E 与既有一次性命令测试无回退;单次自动重启后 cwd 与入口不漂移。 + - 证据:`uv.RunOptions` 新增 `WorkingDir`(为空回退 `ProjectDir`),`UVRunner.Run` 与 + `StartManaged` 共用该字段。`TestManaged_WorkingDirOverridesProjectDir` 与 + `TestRunner_WorkingDirDefaultsToProjectDir` 用真实子进程报告的 `os.Getwd()` 证明生效与回退; + `TestBackendManaged_UsesExactUVArgsAndEnvironment`(无控制路径)与 + `TestBackend_ControlledManagedUsesAppRootAndAbsoluteEntry`(受控路径,含单次自动重启后 + 两代进程的 argv 与 cwd 不漂移)断言 `WorkingDir == AppRoot()`、argv 末项为 + `BackendEntryFile()` 绝对路径;`TestBackendDevelopment_UsesExistingVenvWithoutSync` 锁定 + development 仍传空 `WorkingDir` 与裸 `main.py`;E2E `assertE2EDevelopmentWorkingDir` 由假后端 + 落 `os.Getwd()` 文件证明 development 的真实 cwd 仍是 `--repo`。 + gofmt/vet/build/`go test ./... -count=1`/`git diff --check` 全绿(各 exit 0)。 + **缺口**:仓库既有 E2E fixture 只覆盖 development 模式,managed 的完整 Windows E2E + (需真实 environment 状态 + git revision)本任务未新建,managed 的 cwd 由「backend 单测断言 + 传入 app-root」+「uv 真实子进程证明 WorkingDir 生效」两段接力覆盖;「用户数据在 + `workspace sync` 后仍存在」需跨仓联合验收(TODO-PY-4/TODO-PY-10)才能端到端证明。 - [ ] **T13.2 Job Object 允许游戏与模拟器脱离**(M) - 依赖:M6;契约 [增补 1 C8](./契约补充-v1-增补1.md#c8job-object-逃逸与游戏模拟器的进程归属);成对 TODO-PY-11 - 内容:`internal/process/job_windows.go:35` 的 `LimitFlags` 增加 `JOB_OBJECT_LIMIT_BREAKAWAY_OK`,与既有 `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` 并存。只放开「显式请求脱离」这一条路径,不改变任何未请求脱离的进程的归属,也不引入按进程名清理(红线第 5 条)。 From b34053db2f07ff690e03804f6a5c2daec7a53f01 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 21:47:35 +0200 Subject: [PATCH 08/57] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20T13.4=20?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E5=90=8C=E6=AD=A5=E9=95=9C=E5=83=8F=E6=94=B9?= =?UTF-8?q?=E5=86=99=E8=AE=BE=E8=AE=A1=E4=B8=8E=E8=AE=A1=E5=88=92=20(T13.4?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- ...34\345\203\217\346\224\271\345\206\231.md" | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 "doc/current/M13/\350\256\276\350\256\241-T13.4-\344\276\235\350\265\226\345\220\214\346\255\245\351\225\234\345\203\217\346\224\271\345\206\231.md" diff --git "a/doc/current/M13/\350\256\276\350\256\241-T13.4-\344\276\235\350\265\226\345\220\214\346\255\245\351\225\234\345\203\217\346\224\271\345\206\231.md" "b/doc/current/M13/\350\256\276\350\256\241-T13.4-\344\276\235\350\265\226\345\220\214\346\255\245\351\225\234\345\203\217\346\224\271\345\206\231.md" new file mode 100644 index 0000000..c3e2da1 --- /dev/null +++ "b/doc/current/M13/\350\256\276\350\256\241-T13.4-\344\276\235\350\265\226\345\220\214\346\255\245\351\225\234\345\203\217\346\224\271\345\206\231.md" @@ -0,0 +1,310 @@ +# 设计与计划:T13.4 `dependencies sync` 的镜像改写与轮换 + +- 任务:[T13.4](../../任务拆分.md)(M13,L) +- 契约:[增补 1 C10](../../契约补充-v1-增补1.md#c10主项目依赖的镜像轮换)(含 2026-09-01 两处修订) +- 架构:[架构设计「项目依赖同步」「镜像与网络策略」](../../架构设计.md) +- 基线:`docs/dev-baseline-contract-20260831@f5ac9c9`;文档修订提交 `bf18964` + +--- + +## 1. 目标 + +让受限网络下的用户能装上主项目锁定依赖,同时不破坏 `uv.lock` 的任何不变量。 + +`uv lock --check` 与 `--locked` 的校验对象**始终是 `repo/uv.lock` 原件**;镜像只改变 +artifact 的**下载位置**,不改变包集合、版本与哈希。 + +## 2. 范围与边界 + +### 负责 + +- `internal/mirror`:为 `KindPackageIndex` 源显式声明 `simple` / `packages` 两个改写前缀。 +- `internal/uv`:锁文本前缀改写纯函数;`Sync` 的镜像轮换执行、临时项目目录生命周期、错误映射。 +- `internal/config` / `internal/filesystem`:临时项目目录的路径来源与受控删除类别。 +- `internal/cli`:接线、`result.details` 字段、每次尝试的 `progress` 事件;删除显式包索引首选的拒绝。 +- `testdata/fakeuv`:识别 `--frozen` 与任意 `--project`,按锁内容注入失败,记录锁内 URL 前缀。 + +### 不负责 + +- **不改写 `repo/` 内任何文件**;原锁与 `pyproject.toml` 全程只读。 +- 不新增 `stage`、`state`、错误码或协议事件字段(C10 第 7 条)。 +- 不为 `uv sync` 传 `--default-index` / `UV_DEFAULT_INDEX`(那会让 `--locked` 判定锁需更新)。 +- 不新增镜像源(目录中没有腾讯云源;新增需先实测两种布局,见 C10)。 +- 不改 `uv lock --check` 阶段、不改 `dependencies check` 与 `dependencies rebuild` 的既有语义。 +- 不下发镜像列表给后端(那是 T13.5);不区分预置与运行期 uv 缓存(那是 T13.6)。 + +## 3. 机制 + +```text +Sync(request) + ├─ requireRegularLockfile(repo/uv.lock) 既有 + ├─ uv lock --project --check 既有,对原锁,不带索引覆盖 + └─ 镜像轮换 uv sync + ├─ offline → 原锁 + UV_OFFLINE=1,既有 --locked 路径,完全不改写 + └─ online → BuildPlan(catalog, policy, KindPackageIndex) + 对 plan 中每个源按序尝试: + ├─ official(末位) → 原锁 --locked(这就是 C10 的「回退原锁」) + └─ mirror → 临时项目目录 + 改写锁 + --frozen +``` + +**回退即 plan 末位的官方源。** 这样 `--mirror-only` 的「不回退」不需要额外分支: +`BuildPlan` 在 `mirrorOnly` 时本就不把官方源放进 plan,轮换耗尽自然映射 `MIRROR_EXHAUSTED`。 + +### 3.1 锁改写 + +对锁文本做两处**纯字符串前缀替换**,其余字节(含全部 `hash = "sha256:…"` 行)逐字不动: + +| 原前缀 | 改写为 | +| --- | --- | +| `https://pypi.org/simple` | 源的 simple 前缀 | +| `https://files.pythonhosted.org/packages/` | 源的 packages 前缀 | + +两个前缀互不重叠,替换顺序无关。改写结果幂等:改写后的文本已不含官方前缀,再改写为无变化。 + +### 3.2 每次镜像尝试 + +1. `/dependencies/` 目录经 `filesystem.PrepareManagedDirectory` 创建; +2. 写入 `pyproject.toml`(`repo/pyproject.toml` 的字节副本)与改写后的 `uv.lock`; +3. 执行 + + ```text + uv sync --project <临时目录> --python --frozen --no-default-groups --no-install-workspace + ``` + + `UV_PROJECT_ENVIRONMENT` 仍由 `UVRunner` 指向真实受管 venv,临时目录只提供项目元数据; +4. 无论成功、失败还是取消,都在返回前经 `filesystem` 受控删除移除该目录。 + +`--no-install-workspace` 排除根项目本身,因此临时目录只需这两个文件——不需要 README、 +LICENSE 或源码(C10 实验依据第 5 条已实测)。 + +### 3.3 临时目录生命周期 + +- **路径**:`/runtime/cache/build/dependencies/`,由 + `config.Layout.DependencySyncDir(operationID)` 唯一产出,两段都过 `validateSegment`。 + 位于 `BuildCacheDir()` 下,天然属于「可丢弃缓存」,`cleanup` 的 `build-cache` 条目已覆盖。 +- **创建**:`filesystem.PrepareManagedDirectory`(要求目标不存在,返回固定祖先句柄的租约)。 + 父目录 `…/build/dependencies` 用包内既有的 `ensureManagedDirectory` 逐级补齐。 +- **删除**:`filesystem.DeleteRequest{Kind: DeleteDependencySync}`,新增的 kind 把 + `expectedDeletePath` 钉在 `DependencySyncDir(operationID)` 上——目标不是这个精确路径就拒绝。 + **不使用裸 `os.RemoveAll`。** +- **取消收口**:清理走 `context.WithoutCancel(ctx)` + 有界超时派生的独立 context + (与 `gitrepo/clone.go` 的失败清理同构),业务取消不会让临时目录留在盘上。 +- **每次尝试后立即删除**,因此同一路径可被后续源复用,不会因残留导致 `PrepareManagedDirectory` 失败。 + +### 3.4 失败语义 + +| 场景 | 结果 | +| --- | --- | +| 某个镜像源 `uv sync` 非零退出 | 记一次尝试,`OutcomeSwitchSource`,换下一个源 | +| 全部镜像失败、非 `--mirror-only` | plan 末位官方源用**原锁 `--locked`** 再跑一次 | +| 官方源那次也失败 | `DEPENDENCY_SYNC_FAILED`(与今天完全一致) | +| 全部失败、`--mirror-only` | `MIRROR_EXHAUSTED`(`BuildPlan` 未把官方源放进 plan) | +| `--offline` 且缓存不足 | `NETWORK_UNAVAILABLE`(既有分支,不进轮换) | +| 业务取消 | 原样返回 `context.Canceled`,临时目录已收口 | +| 临时目录创建/写入失败 | 该源记一次失败并换源;不升级为整体失败 | + +**每源只尝试一次**(`OutcomeSwitchSource`,不是 `network.go` 用的 `OutcomeRetrySameSource`)。 +理由:`uv sync` 是重操作,uv 自身对单个 artifact 已有重试;同源重试要重建临时目录并重跑整条 +安装流程,在真实断网场景下会把用户的等待时间乘以 2。轮换规则第 3 条「单个源只执行有限次数 +重试」仍然满足。 + +### 3.5 事件形态 + +**不新增任何协议字段。** 这一条是硬约束(C10 第 7 条 + 红线第 2 条),它决定了下面的取舍: + +- `log` 事件**不用**。`log` 在本协议里专指受管进程 stdout/stderr 的转发(能力标识 + `log.stream`,架构设计「原始日志事件」),字段只有 `source`/`stream`/`message`,其中 + `source` 是日志来源(`backend`)而不是镜像源;`dependencies sync` 不声明 `log.stream`, + 也没有受管进程输出可转发。 +- `warning` 事件**不用**。`warning` 与 `error` 共用错误码全集,「某个镜像失败、继续换源」 + 没有对应的既有码,而 C10 明确禁止新增错误码。 +- `progress` 事件**用**:每次尝试前发一条 `stage=dependencies.sync`、`status=running` 的 + progress,`message` 为中文展示文案(含源 key)。`ProgressEvent` 没有 `details`,所以 + message 只供展示,调用方不得解析(红线第 8 条)。 +- 机器可读事实全部进 **`result.details`**: + + | 字段 | 类型 | 含义 | + | --- | --- | --- | + | `sourceKind` | string | 恒为 `package-index`,与 `internal/uv` 既有 details 用法一致 | + | `source` | string | 实际成功的源 key;回退原锁时为官方源 key `pypi`;`--offline` 时为空串 | + | `attemptCount` | number | 实际执行 `uv sync` 的次数(含回退那次) | + | `lockRewritten` | bool | 本次成功的执行是否用了改写后的锁副本 | + + 失败时 `error.details` / `result.details` 在既有 `sourceKind` / `exitCode` 之外追加 + `attemptCount`。字段名沿用 `internal/uv` 既有风格(`dependencies.go:122`、`errors.go:51` + 已在用 `sourceKind` / `source`);`internal/uv/python.go` 与 `bootstrap.go` 的 Python/uv + 镜像轮换同样只在 details 里报 `sourceKind`,不为轮换发专门事件——本设计与之一致。 + +### 3.6 显式 `--mirror package-index=` + +按 2026-09-01 对 C10 的修订:**不再返回 `INVALID_ARGUMENT`**,该源排在 `BuildPlan` 的尝试 +顺序最前,走同一条改写路径。删除三处拒绝:`internal/uv/dependencies.go:234-242`、 +`internal/cli/errors.go` 的 `rejectPackageIndexOverride` 及其在 `bootstrap.go` / `repair.go` +的两处调用。既有的策略校验不变——不存在的 key 与 `--mirror-only` 下指定官方源仍由 +`mirror.BuildPlan` 返回 `ErrPolicyRejected`。 + +## 4. 安全性依据 + +锁内每个 artifact 的 `hash = "sha256:…"` 在改写中逐字保留,uv 在 `--frozen` 安装时对每个 +下载文件逐一校验。C10 实验依据第 4 条已实测:篡改一个 `sha256` 后 `--frozen` 报哈希不匹配、 +exit 1。因此镜像即使返回了不同的字节也会被拒装,**Runtime 不需要再做额外校验**;这也是 +「切换源不能改变锁定目标」在本机制下的准确落地——改写只动下载位置。 + +改写函数只替换两个精确前缀,不做正则、不解析 TOML、不碰哈希行;单测把「哈希行逐字不变」 +与「替换计数等于锁内 registry/artifact 条目数」作为不变量断言。 + +## 5. 镜像目录的改写前缀 + +`internal/mirror` 中每个 `KindPackageIndex` 源**显式**携带两个前缀,不从 `baseURL` 推导: + +| key | simple 前缀 | packages 前缀 | +| --- | --- | --- | +| `aliyun` | `https://mirrors.aliyun.com/pypi/simple` | `https://mirrors.aliyun.com/pypi/packages/` | +| `tsinghua` | `https://pypi.tuna.tsinghua.edu.cn/simple` | `https://pypi.tuna.tsinghua.edu.cn/packages/` | +| `ustc` | `https://pypi.mirrors.ustc.edu.cn/simple` | `https://pypi.mirrors.ustc.edu.cn/packages/` | +| `pypi`(官方) | `https://pypi.org/simple` | `https://files.pythonhosted.org/packages/` | + +推导规则不可用的证据就在最后一行:官方源的 artifact 在 `files.pythonhosted.org`,与索引 +不同 host,「去掉结尾 `simple/`」会得到不存在的 `https://pypi.org/packages/`。三家镜像 +索引与 artifact 同 host 只是巧合。官方源的两个前缀即被改写的**源**侧取值,因此 +「官方源改写」在数据上等价于恒等变换,且实际路径根本不会走到它(官方源用原锁)。 + +2026-09-01 实测(`curl -sL`,欧洲出口,探针 `six-1.16.0.tar.gz` 的 PyPI 原路径): +`aliyun` / `tsinghua` 两个前缀均 HTTP 200 且无跳转;`ustc` 的索引 302 到 +`mirrors.ustc.edu.cn/pypi/simple`、artifact 302 到清华,两者均 200——可用,与 `tsinghua` +冗余但不冲突,顺序由目录决定。**目录里没有腾讯云源**,新增它属于另一个任务。 + +## 6. 对 T13.5 的交接面 + +T13.5 需要「解析后的有序源列表」。本任务不新增专用 API,它已经存在: + +```go +plan, err := mirror.BuildPlan(catalog, policy, mirror.KindPackageIndex) +for _, source := range plan.Sources() { _ = source.BaseURL() } +``` + +- `plan.Sources()` 的顺序**就是**尝试顺序:显式首选在最前,其余按目录顺序,官方源在末位; +- `--mirror-only` 时不含官方源; +- `--offline` 时 `plan.Offline()` 为真且 `Sources()` 为空——对应 C11 要求的空串注入。 + +`Source.BaseURL()` 是 CLI 语义上的「源地址」(package-index 即 simple 索引 URL)。 +本任务新增的 `Source.PackageIndexRewrite()` 只服务锁改写,T13.5 不需要它。 + +## 7. 待验证项(本任务无法覆盖) + +- 真实 `uv.lock` 的端到端安装:依赖 AUTO-MAS `TODO-PY-6` 把锁入库;本任务用假 uv 覆盖。 +- 三家镜像在国内网络下的实际可达性与速度:本机在欧洲出口,只验证了布局与状态码。 + +--- + +## 8. 实施计划 + +严格 TDD:每个 Task 先写失败测试并确认失败原因正确,再写最小实现,独立提交。 +定向测试统一用下面的模板,同时校验退出码、无 `[no tests to run]`、出现 `--- PASS:`: + +```powershell +$env:GOCACHE = Join-Path $env:TEMP "auto-mas-runtime-verify" +$out = & go test ./<包> -run '<正则>' -count=1 2>&1 +$code = $LASTEXITCODE +$out +if ($code -ne 0) { throw "targeted tests failed" } +if ($out -match '\[no tests to run\]') { throw "no tests matched" } +if (-not ($out -match '--- PASS:')) { throw "no PASS line" } +``` + +### Task 1 — mirror 源的显式改写前缀 + +- 文件:`internal/mirror/source.go`、`internal/mirror/defaults.go`、`internal/mirror/source_test.go` +- 红灯:`TestSource_PackageIndexRewrite`、`TestDefaultCatalog_PackageIndexRewrites` + (断言四个源的两个前缀逐字相符;非 package-index 源返回 `ok=false`; + `NewSource` 造的 package-index 源没有前缀,返回 `ok=false`) +- 绿灯:`Source` 增两个不可变字段 + `NewPackageIndexSource` + `PackageIndexRewrite()`; + `planSeal` 纳入两个新字段;`DefaultCatalog` 的四个 package-index 源改用新构造函数 +- 验证:`go test ./internal/mirror -run 'TestSource_PackageIndexRewrite|TestDefaultCatalog' -count=1` + +### Task 2 — 锁文本改写纯函数 + +- 文件:`internal/uv/lockrewrite.go`、`internal/uv/lockrewrite_test.go` +- 红灯:`TestRewriteLockfile`(表驱动:两个精确前缀各自被替换;哈希行逐字不变; + 替换计数等于夹具中 registry + artifact 条目数;对已改写文本再改写无变化; + 不含两前缀的锁原样返回且计数为 0;相似但不相等的前缀不被替换) +- 绿灯:`rewriteLockfile(lock string, rewrite mirror.PackageIndexRewrite) (string, int)` +- 验证:`go test ./internal/uv -run 'TestRewriteLockfile' -count=1` + +### Task 3 — 临时项目目录的路径与删除类别 + +- 文件:`internal/config/segment.go`、`internal/config/segment_test.go`、 + `internal/filesystem/delete.go`、`internal/filesystem/delete_windows.go`、 + `internal/filesystem/delete_windows_test.go` +- 红灯:`TestLayout_DependencySyncDir`(合法 operationID 的路径形状;空串与非法段返回 + `ErrInvalidSegment`)、`TestOperator_RemoveDependencySync`(删除精确路径成功; + 目标不是该路径时拒绝;`Version` 非空时拒绝) +- 绿灯:`Layout.DependencySyncDir(operationID)`;`DeleteDependencySync` 加入 + `DeleteKind` 全集与 `expectedDeletePath` +- 验证:`go test ./internal/config ./internal/filesystem -run 'DependencySync' -count=1` + +### Task 4 — 依赖同步的镜像轮换 + +- 文件:`internal/uv/dependencies.go`、`internal/uv/dependencies_mirror.go`、 + `internal/uv/dependencies_test.go` +- 红灯:`TestDependenciesService_SyncRotatesMirrors`(首选失败→次选成功,结果报次选; + 全部镜像失败→官方源用原锁成功,`LockRewritten=false`;镜像成功时 `--project` 指向临时 + 目录且参数含 `--frozen`;官方源那次 `--project` 指向 repo 且参数含 `--locked`; + `repo/uv.lock` 与 `repo/pyproject.toml` 字节不变;临时目录事后不存在)、 + `TestDependenciesService_SyncMirrorOnlyDoesNotFallBack`(`MIRROR_EXHAUSTED`)、 + `TestDependenciesService_SyncOfflineSkipsRewrite`(不建临时目录、注入 `UV_OFFLINE=1`)、 + `TestDependenciesService_SyncCancellationCleansTemporaryProject` +- 绿灯:`DependenciesService` 经 Option 注入 catalog / rotator / staging remover(生产默认 + 为 `DefaultCatalog` + `NewRotator` + filesystem 删除器);`DependenciesResult` 增 + `Source` / `SourceKind` / `AttemptCount` / `LockRewritten`;`DependenciesRequest` 增 + `Attempt` 回调 +- 验证:`go test ./internal/uv -run 'TestDependenciesService_Sync' -count=1` + +### Task 5 — 删除显式包索引首选的拒绝 + +- 文件:`internal/uv/dependencies.go`、`internal/cli/errors.go`、`internal/cli/bootstrap.go`、 + `internal/cli/repair.go`,及相应测试 +- 红灯:`TestDependenciesService_SyncPrefersExplicitSource`(显式 key 第一个被尝试); + 既有断言 `INVALID_ARGUMENT` 的测试改为断言不再拒绝 +- 绿灯:删除 `validateRequest` 的拒绝分支与 `rejectPackageIndexOverride` 及其两处调用 +- 验证:`go test ./internal/uv ./internal/cli -run 'PackageIndex|ExplicitSource' -count=1` + +### Task 6 — fakeuv 夹具扩展 + +- 文件:`testdata/fakeuv/main.go` +- 红灯:由 Task 7/8 的组件测试驱动(夹具本身无单测,按仓库既有做法) +- 绿灯:规则新增 `argumentsContain`(全部命中)与 `lockContains`(`--project` 指向目录内 + `uv.lock` 含该子串)两个条件;`invocationRecord` 新增 `projectDir` 与 + `lockIndexPrefixes`(锁内出现的 simple 与 packages 前缀,去重排序); + 新增 `readyFile` / `releaseFile` 顶层动作用于取消时序 +- 验证:`go build -buildvcs=false ./...` 与 Task 8 + +### Task 7 — CLI 接线与 result.details + +- 文件:`internal/cli/dependencies.go`、`internal/cli/environment_support.go`、 + `internal/cli/bootstrap.go` +- 红灯:`TestDependenciesSync_ResultReportsSource`(result.details 含四个字段) +- 绿灯:把 `Attempt` 回调接到 `emitM5Progress`;`sessionSuccess.details` 增四个字段 +- 验证:`go test ./internal/cli -run 'ResultReportsSource' -count=1` + +### Task 8 — 组件测试矩阵 + +- 文件:`internal/cli/m13_component_test.go` +- 红灯 = 绿灯(新测试):`TestM13Component_DependencySyncMirrorRotation` 七个子用例—— + 首选失败次选成功 / 全部镜像失败回退原锁 / 全部失败含原锁 → `DEPENDENCY_SYNC_FAILED` / + `--offline` 不改写不建目录 / `--mirror-only` 不尝试官方源 / + 显式 `--mirror package-index=` 排最前 / 取消时临时目录收口。 + 每个子用例都断言临时目录事后不存在,且 `repo/uv.lock`、`repo/pyproject.toml` 哈希不变 +- 验证:`go test ./internal/cli -run 'TestM13Component_DependencySyncMirrorRotation' -count=1` + +### Task 9 — 契约测试 + +- 文件:`internal/cli/contract_m5_test.go` +- 红灯:`dependencies sync` 成功 transcript 的 result.details 缺字段时失败 +- 绿灯:补断言 +- 验证:`go test ./internal/cli -run 'Contract' -count=1` + +### 收尾 + +标准验证门(AGENTS.md 5.2)全绿;轮换路径涉及并发(`mirror.Rotator`),追加 +`go test -race ./... -count=1`。完成后按 6.4 回写 `doc/任务拆分.md`(独立 docs 提交)。 From 81f47a366321f7505205e7e012b7ce3e01d39a9b Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 21:50:16 +0200 Subject: [PATCH 09/57] =?UTF-8?q?feat:=20=E4=B8=BA=E5=8C=85=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=E6=BA=90=E6=98=BE=E5=BC=8F=E5=A3=B0=E6=98=8E=E9=94=81?= =?UTF-8?q?=E6=94=B9=E5=86=99=E5=89=8D=E7=BC=80=20(T13.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KindPackageIndex 源新增 simple/packages 两个显式前缀,不由 baseURL 推导—— 官方源的 artifact 在 files.pythonhosted.org,与索引不同 host。 Co-Authored-By: Claude Opus 5 --- internal/mirror/defaults.go | 70 +++++++--- internal/mirror/packageindex.go | 83 +++++++++++ internal/mirror/packageindex_test.go | 201 +++++++++++++++++++++++++++ internal/mirror/policy.go | 4 + internal/mirror/source.go | 37 +++-- 5 files changed, 364 insertions(+), 31 deletions(-) create mode 100644 internal/mirror/packageindex.go create mode 100644 internal/mirror/packageindex_test.go diff --git a/internal/mirror/defaults.go b/internal/mirror/defaults.go index 488bf63..a8239d8 100644 --- a/internal/mirror/defaults.go +++ b/internal/mirror/defaults.go @@ -5,10 +5,12 @@ import "fmt" // DefaultCatalog 通过生产校验路径构造冻结的内置 Source。 func DefaultCatalog() (*Catalog, error) { specs := []struct { - kind Kind - key string - baseURL string - official bool + kind Kind + key string + baseURL string + official bool + simpleBase string + packagesBase string }{ { kind: KindGit, @@ -59,35 +61,59 @@ func DefaultCatalog() (*Catalog, error) { official: true, }, { - kind: KindPackageIndex, - key: "aliyun", - baseURL: "https://mirrors.aliyun.com/pypi/simple/", + kind: KindPackageIndex, + key: "aliyun", + baseURL: "https://mirrors.aliyun.com/pypi/simple/", + simpleBase: "https://mirrors.aliyun.com/pypi/simple", + packagesBase: "https://mirrors.aliyun.com/pypi/packages/", }, { - kind: KindPackageIndex, - key: "tsinghua", - baseURL: "https://pypi.tuna.tsinghua.edu.cn/simple/", + kind: KindPackageIndex, + key: "tsinghua", + baseURL: "https://pypi.tuna.tsinghua.edu.cn/simple/", + simpleBase: "https://pypi.tuna.tsinghua.edu.cn/simple", + packagesBase: "https://pypi.tuna.tsinghua.edu.cn/packages/", }, { - kind: KindPackageIndex, - key: "ustc", - baseURL: "https://pypi.mirrors.ustc.edu.cn/simple/", + kind: KindPackageIndex, + key: "ustc", + baseURL: "https://pypi.mirrors.ustc.edu.cn/simple/", + simpleBase: "https://pypi.mirrors.ustc.edu.cn/simple", + packagesBase: "https://pypi.mirrors.ustc.edu.cn/packages/", }, { - kind: KindPackageIndex, - key: "pypi", - baseURL: "https://pypi.org/simple/", - official: true, + // 官方源的 artifact 在 files.pythonhosted.org,与索引不同 host。 + // 它同时是被改写的源侧前缀,因此这里的两个值就是锁文件里的原始前缀。 + kind: KindPackageIndex, + key: "pypi", + baseURL: "https://pypi.org/simple/", + official: true, + simpleBase: "https://pypi.org/simple", + packagesBase: "https://files.pythonhosted.org/packages/", }, } sources := make([]Source, 0, len(specs)) for _, spec := range specs { - source, err := NewSource( - spec.kind, - spec.key, - spec.baseURL, - spec.official, + var ( + source Source + err error ) + if spec.kind == KindPackageIndex { + source, err = NewPackageIndexSource(PackageIndexSpec{ + Key: spec.key, + BaseURL: spec.baseURL, + SimpleBase: spec.simpleBase, + PackagesBase: spec.packagesBase, + Official: spec.official, + }) + } else { + source, err = NewSource( + spec.kind, + spec.key, + spec.baseURL, + spec.official, + ) + } if err != nil { return nil, fmt.Errorf("build default source: %w", err) } diff --git a/internal/mirror/packageindex.go b/internal/mirror/packageindex.go new file mode 100644 index 0000000..10fd394 --- /dev/null +++ b/internal/mirror/packageindex.go @@ -0,0 +1,83 @@ +package mirror + +import ( + "fmt" + "strings" +) + +// PackageIndexSpec 是带显式锁改写前缀的包索引源构造输入。 +type PackageIndexSpec struct { + Key string + BaseURL string + SimpleBase string + PackagesBase string + Official bool +} + +// PackageIndexRewrite 保存包索引源在锁文件 URL 改写中使用的两个前缀。 +// +// 两个前缀由目录显式声明,不从 BaseURL 推导:官方源的 artifact 位于 +// files.pythonhosted.org,与它的索引不同 host,任何「去掉结尾 simple/」 +// 一类的推导都会得到不存在的地址;三家镜像索引与 artifact 同 host 只是巧合。 +type PackageIndexRewrite struct { + simpleBase string + packagesBase string +} + +// SimpleBase 返回替换 https://pypi.org/simple 的前缀,不以 / 结尾。 +func (r PackageIndexRewrite) SimpleBase() string { + return r.simpleBase +} + +// PackagesBase 返回替换 https://files.pythonhosted.org/packages/ 的前缀,以 / 结尾。 +func (r PackageIndexRewrite) PackagesBase() string { + return r.packagesBase +} + +// NewPackageIndexSource 校验并构造带显式改写前缀的包索引源。 +func NewPackageIndexSource(spec PackageIndexSpec) (Source, error) { + source, err := NewSource(KindPackageIndex, spec.Key, spec.BaseURL, spec.Official) + if err != nil { + return Source{}, err + } + simpleBase, packagesBase, err := normalizeRewritePrefixes(spec.SimpleBase, spec.PackagesBase) + if err != nil { + return Source{}, err + } + source.simpleBase = simpleBase + source.packagesBase = packagesBase + return source, nil +} + +// PackageIndexRewrite 返回包索引源的锁改写前缀;未显式声明时报告 false。 +func (s Source) PackageIndexRewrite() (PackageIndexRewrite, bool) { + if s.kind != KindPackageIndex || s.simpleBase == "" || s.packagesBase == "" { + return PackageIndexRewrite{}, false + } + return PackageIndexRewrite{simpleBase: s.simpleBase, packagesBase: s.packagesBase}, true +} + +// normalizeRewritePrefixes 校验两个前缀的 HTTPS 策略与尾斜杠不变量。 +// +// 尾斜杠是硬约束:simple 前缀替换的原串 https://pypi.org/simple 不带斜杠, +// packages 前缀之后要直接拼 PyPI 原路径,因此必须带斜杠。前缀还必须已经是 +// 规范形式,避免目录里写的值与实际参与改写的值不是同一个串。 +func normalizeRewritePrefixes(simpleBase, packagesBase string) (string, string, error) { + if simpleBase == "" || packagesBase == "" || + strings.HasSuffix(simpleBase, "/") || + !strings.HasSuffix(packagesBase, "/") { + return "", "", fmt.Errorf("%w: package index rewrite prefix shape", errInvalidSource) + } + normalizedSimple, err := normalizeSourceURL(simpleBase) + if err != nil { + return "", "", err + } + normalizedPackages, err := normalizeSourceURL(packagesBase) + if err != nil { + return "", "", err + } + if normalizedSimple != simpleBase || normalizedPackages != packagesBase { + return "", "", fmt.Errorf("%w: package index rewrite prefix is not canonical", errInvalidSource) + } + return normalizedSimple, normalizedPackages, nil +} diff --git a/internal/mirror/packageindex_test.go b/internal/mirror/packageindex_test.go new file mode 100644 index 0000000..6d2e485 --- /dev/null +++ b/internal/mirror/packageindex_test.go @@ -0,0 +1,201 @@ +package mirror + +import ( + "errors" + "strings" + "testing" +) + +func TestNewPackageIndexSource_ValidatesRewritePrefixes(t *testing.T) { + cases := []struct { + name string + spec PackageIndexSpec + want bool + }{ + { + name: "valid", + spec: PackageIndexSpec{ + Key: "aliyun", + BaseURL: "https://mirrors.aliyun.com/pypi/simple/", + SimpleBase: "https://mirrors.aliyun.com/pypi/simple", + PackagesBase: "https://mirrors.aliyun.com/pypi/packages/", + }, + want: true, + }, + { + name: "different hosts are allowed", + spec: PackageIndexSpec{ + Key: "pypi", + BaseURL: "https://pypi.org/simple/", + SimpleBase: "https://pypi.org/simple", + PackagesBase: "https://files.pythonhosted.org/packages/", + Official: true, + }, + want: true, + }, + { + name: "missing simple base", + spec: PackageIndexSpec{ + Key: "aliyun", + BaseURL: "https://mirrors.aliyun.com/pypi/simple/", + PackagesBase: "https://mirrors.aliyun.com/pypi/packages/", + }, + }, + { + name: "missing packages base", + spec: PackageIndexSpec{ + Key: "aliyun", + BaseURL: "https://mirrors.aliyun.com/pypi/simple/", + SimpleBase: "https://mirrors.aliyun.com/pypi/simple", + }, + }, + { + name: "simple base must not end with a slash", + spec: PackageIndexSpec{ + Key: "aliyun", + BaseURL: "https://mirrors.aliyun.com/pypi/simple/", + SimpleBase: "https://mirrors.aliyun.com/pypi/simple/", + PackagesBase: "https://mirrors.aliyun.com/pypi/packages/", + }, + }, + { + name: "packages base must end with a slash", + spec: PackageIndexSpec{ + Key: "aliyun", + BaseURL: "https://mirrors.aliyun.com/pypi/simple/", + SimpleBase: "https://mirrors.aliyun.com/pypi/simple", + PackagesBase: "https://mirrors.aliyun.com/pypi/packages", + }, + }, + { + name: "plain HTTP is rejected", + spec: PackageIndexSpec{ + Key: "aliyun", + BaseURL: "https://mirrors.aliyun.com/pypi/simple/", + SimpleBase: "http://mirrors.aliyun.com/pypi/simple", + PackagesBase: "https://mirrors.aliyun.com/pypi/packages/", + }, + }, + { + name: "invalid key", + spec: PackageIndexSpec{ + Key: "Aliyun", + BaseURL: "https://mirrors.aliyun.com/pypi/simple/", + SimpleBase: "https://mirrors.aliyun.com/pypi/simple", + PackagesBase: "https://mirrors.aliyun.com/pypi/packages/", + }, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + source, err := NewPackageIndexSource(testCase.spec) + if !testCase.want { + if err == nil { + t.Fatalf("NewPackageIndexSource(%#v) error = nil, want error", testCase.spec) + } + if !errors.Is(err, errInvalidSource) { + t.Fatalf("NewPackageIndexSource() error = %v, want errInvalidSource", err) + } + return + } + if err != nil { + t.Fatalf("NewPackageIndexSource(%#v) error = %v", testCase.spec, err) + } + if source.Kind() != KindPackageIndex || source.Key() != testCase.spec.Key { + t.Fatalf("source = %q/%q, want %q/%q", source.Kind(), source.Key(), KindPackageIndex, testCase.spec.Key) + } + rewrite, ok := source.PackageIndexRewrite() + if !ok { + t.Fatal("PackageIndexRewrite() ok = false, want true") + } + if rewrite.SimpleBase() != testCase.spec.SimpleBase { + t.Errorf("SimpleBase() = %q, want %q", rewrite.SimpleBase(), testCase.spec.SimpleBase) + } + if rewrite.PackagesBase() != testCase.spec.PackagesBase { + t.Errorf("PackagesBase() = %q, want %q", rewrite.PackagesBase(), testCase.spec.PackagesBase) + } + if err := validateSource(source); err != nil { + t.Errorf("validateSource() error = %v, want nil", err) + } + }) + } +} + +func TestSource_PackageIndexRewriteAbsentWithoutExplicitPrefixes(t *testing.T) { + // NewSource 不接受改写前缀,因此它造出的包索引源不能参与锁改写: + // 改写前缀必须显式声明,绝不由 baseURL 推导。 + source, err := NewSource(KindPackageIndex, "aliyun", "https://mirrors.aliyun.com/pypi/simple/", false) + if err != nil { + t.Fatalf("NewSource() error = %v", err) + } + if _, ok := source.PackageIndexRewrite(); ok { + t.Fatal("PackageIndexRewrite() ok = true for a source without explicit prefixes, want false") + } + other, err := NewSource(KindUV, "github", "https://github.com/astral-sh/uv/releases/download", true) + if err != nil { + t.Fatalf("NewSource() error = %v", err) + } + if _, ok := other.PackageIndexRewrite(); ok { + t.Fatal("PackageIndexRewrite() ok = true for a non package-index source, want false") + } +} + +func TestDefaultCatalog_PackageIndexRewrites(t *testing.T) { + catalog, err := DefaultCatalog() + if err != nil { + t.Fatalf("DefaultCatalog() error = %v", err) + } + want := []struct { + key string + simpleBase string + packagesBase string + official bool + }{ + { + key: "aliyun", + simpleBase: "https://mirrors.aliyun.com/pypi/simple", + packagesBase: "https://mirrors.aliyun.com/pypi/packages/", + }, + { + key: "tsinghua", + simpleBase: "https://pypi.tuna.tsinghua.edu.cn/simple", + packagesBase: "https://pypi.tuna.tsinghua.edu.cn/packages/", + }, + { + key: "ustc", + simpleBase: "https://pypi.mirrors.ustc.edu.cn/simple", + packagesBase: "https://pypi.mirrors.ustc.edu.cn/packages/", + }, + { + key: "pypi", + simpleBase: "https://pypi.org/simple", + packagesBase: "https://files.pythonhosted.org/packages/", + official: true, + }, + } + sources := catalog.Sources(KindPackageIndex) + if len(sources) != len(want) { + t.Fatalf("package-index sources = %d, want %d", len(sources), len(want)) + } + for index, expected := range want { + source := sources[index] + if source.Key() != expected.key || source.Official() != expected.official { + t.Fatalf("source[%d] = %q/official %t, want %q/official %t", + index, source.Key(), source.Official(), expected.key, expected.official) + } + rewrite, ok := source.PackageIndexRewrite() + if !ok { + t.Fatalf("source[%d] %q has no rewrite prefixes", index, source.Key()) + } + if rewrite.SimpleBase() != expected.simpleBase { + t.Errorf("source %q SimpleBase() = %q, want %q", source.Key(), rewrite.SimpleBase(), expected.simpleBase) + } + if rewrite.PackagesBase() != expected.packagesBase { + t.Errorf("source %q PackagesBase() = %q, want %q", source.Key(), rewrite.PackagesBase(), expected.packagesBase) + } + if !strings.HasSuffix(rewrite.PackagesBase(), "/") || strings.HasSuffix(rewrite.SimpleBase(), "/") { + t.Errorf("source %q rewrite prefixes violate the slash invariant: %q / %q", + source.Key(), rewrite.SimpleBase(), rewrite.PackagesBase()) + } + } +} diff --git a/internal/mirror/policy.go b/internal/mirror/policy.go index 39e3a97..0d34d24 100644 --- a/internal/mirror/policy.go +++ b/internal/mirror/policy.go @@ -228,6 +228,10 @@ func planSeal(kind Kind, sources []Source, offline bool) [sha256.Size]byte { builder.WriteString(source.baseURL) builder.WriteByte(0) builder.WriteString(strconv.FormatBool(source.official)) + builder.WriteByte(0) + builder.WriteString(source.simpleBase) + builder.WriteByte(0) + builder.WriteString(source.packagesBase) } return sha256.Sum256([]byte(builder.String())) } diff --git a/internal/mirror/source.go b/internal/mirror/source.go index 5c25146..9108b6a 100644 --- a/internal/mirror/source.go +++ b/internal/mirror/source.go @@ -28,11 +28,16 @@ func (e *sourceURLParseError) Unwrap() []error { } // Source 是经过校验的不可变 HTTPS 网络源。 +// +// simpleBase 与 packagesBase 只对 KindPackageIndex 有意义,且只能由 +// NewPackageIndexSource 显式赋值,经 PackageIndexRewrite 读取。 type Source struct { - kind Kind - key string - baseURL string - official bool + kind Kind + key string + baseURL string + official bool + simpleBase string + packagesBase string } // NewSource 校验并规范化单个网络源。 @@ -161,14 +166,28 @@ func normalizeSourceURL(value string) (string, error) { } func validateSource(source Source) error { - normalized, err := NewSource( + normalized, err := rebuildSource(source) + if err != nil || normalized != source { + return errInvalidSource + } + return nil +} + +// rebuildSource 用公开构造函数重建 Source,使校验与构造走同一条路径。 +func rebuildSource(source Source) (Source, error) { + if source.simpleBase != "" || source.packagesBase != "" { + return NewPackageIndexSource(PackageIndexSpec{ + Key: source.key, + BaseURL: source.baseURL, + SimpleBase: source.simpleBase, + PackagesBase: source.packagesBase, + Official: source.official, + }) + } + return NewSource( source.kind, source.key, source.baseURL, source.official, ) - if err != nil || normalized != source { - return errInvalidSource - } - return nil } From f20cd1b5998628d27f43ce8182bc7b6621e0d75f Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 21:52:43 +0200 Subject: [PATCH 10/57] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20uv.lock=20?= =?UTF-8?q?=E4=B8=A4=E5=A4=84=E5=89=8D=E7=BC=80=E7=9A=84=E7=BA=AF=E5=AD=97?= =?UTF-8?q?=E7=AC=A6=E4=B8=B2=E6=94=B9=E5=86=99=20(T13.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 单遍 strings.Replacer 只替换 https://pypi.org/simple 与 https://files.pythonhosted.org/packages/ 两个精确前缀,hash 与 size 逐字保留。 Co-Authored-By: Claude Opus 5 --- internal/uv/lockrewrite.go | 51 +++++++++ internal/uv/lockrewrite_test.go | 188 ++++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 internal/uv/lockrewrite.go create mode 100644 internal/uv/lockrewrite_test.go diff --git a/internal/uv/lockrewrite.go b/internal/uv/lockrewrite.go new file mode 100644 index 0000000..f8e9e0e --- /dev/null +++ b/internal/uv/lockrewrite.go @@ -0,0 +1,51 @@ +package uv + +import ( + "strings" + + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" +) + +const ( + // officialIndexPrefix 是 uv.lock 中 registry 条目的官方索引前缀。 + officialIndexPrefix = "https://pypi.org/simple" + // officialArtifactPrefix 是 uv.lock 中 sdist/wheel URL 的官方 artifact 前缀。 + officialArtifactPrefix = "https://files.pythonhosted.org/packages/" +) + +// lockRewriteResult 保存改写后的锁文本与两处前缀各自的替换次数。 +type lockRewriteResult struct { + Lock string + Indexes int + Artifacts int +} + +// Total 返回两处前缀的替换总次数。 +func (r lockRewriteResult) Total() int { + return r.Indexes + r.Artifacts +} + +// rewriteLockfile 把锁文本中的两个官方前缀替换为镜像前缀。 +// +// 这是纯字符串前缀替换:不解析 TOML、不使用正则、不触碰 hash 与 size 字段。 +// 锁内每个 artifact 的 sha256 因此逐字保留,由 uv 在 --frozen 安装时逐个校验—— +// 镜像返回了不同的字节就会被拒装,Runtime 不需要再做额外校验。 +// +// 用单遍 strings.Replacer 而不是两次 ReplaceAll:单遍扫描保证替换产生的文本 +// 不会再被第二个模式命中,改写结果与两个模式的先后无关。 +func rewriteLockfile(lock string, rewrite mirror.PackageIndexRewrite) lockRewriteResult { + simpleBase := rewrite.SimpleBase() + packagesBase := rewrite.PackagesBase() + if simpleBase == "" || packagesBase == "" { + return lockRewriteResult{Lock: lock} + } + replacer := strings.NewReplacer( + officialArtifactPrefix, packagesBase, + officialIndexPrefix, simpleBase, + ) + return lockRewriteResult{ + Lock: replacer.Replace(lock), + Indexes: strings.Count(lock, officialIndexPrefix), + Artifacts: strings.Count(lock, officialArtifactPrefix), + } +} diff --git a/internal/uv/lockrewrite_test.go b/internal/uv/lockrewrite_test.go new file mode 100644 index 0000000..00f2290 --- /dev/null +++ b/internal/uv/lockrewrite_test.go @@ -0,0 +1,188 @@ +package uv + +import ( + "regexp" + "strings" + "testing" + + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" +) + +// lockFixture 是形态与真实 uv.lock 一致的最小锁文本:两个 registry 条目、 +// 一个 sdist 与两个 wheel,外加一个既不是索引也不是 artifact 的干扰 URL。 +const lockFixture = `version = 1 +revision = 2 +requires-python = ">=3.12, <3.13" + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "auto-mas" +version = "5.5.0" +source = { editable = "." } + +[package.metadata] +requires-dist = [{ name = "certifi" }, { name = "idna", specifier = ">=3.10" }] +# 干扰项:两个官方前缀都不匹配,必须原样保留。 +# https://pypi.org/pypi/certifi/json https://files.pythonhosted.org/pypi/index +` + +const ( + lockFixtureIndexes = 2 + lockFixtureArtifacts = 3 +) + +func testRewrite(t *testing.T, simpleBase, packagesBase string) mirror.PackageIndexRewrite { + t.Helper() + source, err := mirror.NewPackageIndexSource(mirror.PackageIndexSpec{ + Key: "tsinghua", + BaseURL: "https://pypi.tuna.tsinghua.edu.cn/simple/", + SimpleBase: simpleBase, + PackagesBase: packagesBase, + }) + if err != nil { + t.Fatalf("mirror.NewPackageIndexSource() error = %v", err) + } + rewrite, ok := source.PackageIndexRewrite() + if !ok { + t.Fatal("PackageIndexRewrite() ok = false, want true") + } + return rewrite +} + +func TestRewriteLockfile_ReplacesOnlyTheTwoExactPrefixes(t *testing.T) { + rewrite := testRewrite( + t, + "https://pypi.tuna.tsinghua.edu.cn/simple", + "https://pypi.tuna.tsinghua.edu.cn/packages/", + ) + result := rewriteLockfile(lockFixture, rewrite) + + if result.Indexes != lockFixtureIndexes || result.Artifacts != lockFixtureArtifacts { + t.Fatalf("counts = %d indexes/%d artifacts, want %d/%d", + result.Indexes, result.Artifacts, lockFixtureIndexes, lockFixtureArtifacts) + } + if got, want := result.Total(), lockFixtureIndexes+lockFixtureArtifacts; got != want { + t.Fatalf("Total() = %d, want %d", got, want) + } + if strings.Contains(result.Lock, "https://pypi.org/simple") || + strings.Contains(result.Lock, "https://files.pythonhosted.org/packages/") { + t.Fatal("rewritten lock still contains an official prefix") + } + if got := strings.Count(result.Lock, `registry = "https://pypi.tuna.tsinghua.edu.cn/simple"`); got != lockFixtureIndexes { + t.Errorf("rewritten registry entries = %d, want %d", got, lockFixtureIndexes) + } + if got := strings.Count(result.Lock, "https://pypi.tuna.tsinghua.edu.cn/packages/"); got != lockFixtureArtifacts { + t.Errorf("rewritten artifact URLs = %d, want %d", got, lockFixtureArtifacts) + } + for _, untouched := range []string{ + "https://pypi.org/pypi/certifi/json", + "https://files.pythonhosted.org/pypi/index", + } { + if !strings.Contains(result.Lock, untouched) { + t.Errorf("rewritten lock lost an unrelated URL %q", untouched) + } + } + if got, want := lineCount(result.Lock), lineCount(lockFixture); got != want { + t.Errorf("line count = %d, want %d", got, want) + } +} + +func TestRewriteLockfile_KeepsHashesByteForByte(t *testing.T) { + rewrite := testRewrite( + t, + "https://mirrors.aliyun.com/pypi/simple", + "https://mirrors.aliyun.com/pypi/packages/", + ) + result := rewriteLockfile(lockFixture, rewrite) + + hashPattern := regexp.MustCompile(`hash = "sha256:[0-9a-f]{64}"`) + before := hashPattern.FindAllString(lockFixture, -1) + after := hashPattern.FindAllString(result.Lock, -1) + if len(before) != lockFixtureArtifacts { + t.Fatalf("fixture hash lines = %d, want %d", len(before), lockFixtureArtifacts) + } + if len(after) != len(before) { + t.Fatalf("hash lines after rewrite = %d, want %d", len(after), len(before)) + } + for index := range before { + if after[index] != before[index] { + t.Errorf("hash[%d] = %q, want %q", index, after[index], before[index]) + } + } + if got, want := strings.Count(result.Lock, "size = "), strings.Count(lockFixture, "size = "); got != want { + t.Errorf("size fields = %d, want %d", got, want) + } +} + +func TestRewriteLockfile_IsIdempotent(t *testing.T) { + rewrite := testRewrite( + t, + "https://mirrors.aliyun.com/pypi/simple", + "https://mirrors.aliyun.com/pypi/packages/", + ) + first := rewriteLockfile(lockFixture, rewrite) + second := rewriteLockfile(first.Lock, rewrite) + + if second.Lock != first.Lock { + t.Error("second rewrite changed the lock text") + } + if second.Total() != 0 { + t.Errorf("second rewrite replaced %d prefixes, want 0", second.Total()) + } +} + +func TestRewriteLockfile_LeavesUnrelatedLockUnchanged(t *testing.T) { + rewrite := testRewrite( + t, + "https://mirrors.aliyun.com/pypi/simple", + "https://mirrors.aliyun.com/pypi/packages/", + ) + const unrelated = "version = 1\nrequires-python = \">=3.12\"\n" + result := rewriteLockfile(unrelated, rewrite) + + if result.Lock != unrelated { + t.Errorf("lock = %q, want %q", result.Lock, unrelated) + } + if result.Total() != 0 { + t.Errorf("Total() = %d, want 0", result.Total()) + } +} + +func TestRewriteLockfile_OfficialPrefixesAreIdentity(t *testing.T) { + // 官方源的两个前缀就是被替换的原串,因此改写在数据上是恒等变换。 + // 实际路径不会走到它(官方源用原锁),这条只是把不变量钉住。 + rewrite := testRewrite( + t, + "https://pypi.org/simple", + "https://files.pythonhosted.org/packages/", + ) + result := rewriteLockfile(lockFixture, rewrite) + + if result.Lock != lockFixture { + t.Error("official rewrite changed the lock text") + } + if result.Indexes != lockFixtureIndexes || result.Artifacts != lockFixtureArtifacts { + t.Errorf("counts = %d/%d, want %d/%d", + result.Indexes, result.Artifacts, lockFixtureIndexes, lockFixtureArtifacts) + } +} + +func lineCount(text string) int { + return strings.Count(text, "\n") +} From 30fe0baf23e07e78c1a2597d6c810f1f25b682d4 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 21:52:55 +0200 Subject: [PATCH 11/57] =?UTF-8?q?feat(process):=20Job=20=E5=85=81=E8=AE=B8?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E8=AF=B7=E6=B1=82=E8=84=B1=E7=A6=BB=20(T13.2?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按增补 1 C8:Job 的 LimitFlags 追加 JOB_OBJECT_LIMIT_BREAKAWAY_OK, 使 AUTO-MAS 用 CREATE_BREAKAWAY_FROM_JOB 拉起的模拟器与 PC 游戏不随 后端退出。刻意不用 SILENT_BREAKAWAY_OK——那会让 worker 与 Agent 也 默认脱离。KILL_ON_JOB_CLOSE 与未请求脱离的进程归属不变。 --- ...76\345\274\217\350\204\261\347\246\273.md" | 59 ++++++ internal/process/doc.go | 6 + internal/process/job.go | 3 +- internal/process/job_windows.go | 9 +- internal/process/managed_windows_test.go | 170 +++++++++++++++++- 5 files changed, 241 insertions(+), 6 deletions(-) create mode 100644 "doc/current/M13/\350\256\276\350\256\241-T13.2-Job-Object-\345\205\201\350\256\270\346\230\276\345\274\217\350\204\261\347\246\273.md" diff --git "a/doc/current/M13/\350\256\276\350\256\241-T13.2-Job-Object-\345\205\201\350\256\270\346\230\276\345\274\217\350\204\261\347\246\273.md" "b/doc/current/M13/\350\256\276\350\256\241-T13.2-Job-Object-\345\205\201\350\256\270\346\230\276\345\274\217\350\204\261\347\246\273.md" new file mode 100644 index 0000000..ceeebdc --- /dev/null +++ "b/doc/current/M13/\350\256\276\350\256\241-T13.2-Job-Object-\345\205\201\350\256\270\346\230\276\345\274\217\350\204\261\347\246\273.md" @@ -0,0 +1,59 @@ +# 设计与计划 T13.2 Job Object 允许游戏与模拟器脱离 + +- 契约:[增补 1 C8](../../契约补充-v1-增补1.md#c8job-object-逃逸与游戏模拟器的进程归属) +- 任务:[任务拆分 T13.2](../../任务拆分.md) +- 状态:设计 + 计划 + +## 目标 + +Runtime 创建的 Job Object 在 `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` 之外追加 +`JOB_OBJECT_LIMIT_BREAKAWAY_OK`,使 AUTO-MAS 在拉起模拟器与 PC 游戏时可以用 +`CREATE_BREAKAWAY_FROM_JOB` 让它们脱离受管进程树,从而**不随后端退出**——这是为保持 +今天的用户可见行为而定的产品决策。 + +## 边界(不负责) + +- **不用 `JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK`。** 那一位让所有子进程默认脱离, + 后端自己的 worker 与 Agent 会一起逃出回收边界;只放开「显式请求」这一条路径; +- `KILL_ON_JOB_CLOSE` 保留,未请求脱离的进程归属、`Snapshot`/`WaitEmpty` 的证明方式、 + `details.pid` 语义、`BACKEND_FORCE_TERMINATED` 警告一律不变; +- 不引入任何按进程名的清理(红线第 5 条); +- Runtime 单独开这一位不产生任何行为变化——真正的效果要 AUTO-MAS 侧 `TODO-PY-11` + 带上 `CREATE_BREAKAWAY_FROM_JOB` 才出现,验收以跨仓联合「跑着游戏关 AUTO-MAS」为准。 + +## API 形态 + +无导出 API 变化。`internal/process/job_windows.go` 的 `NewJob` 只改一行 `LimitFlags`, +并更新 `NewJob` 的 doc comment 与 `internal/process/doc.go` 的包注释措辞——注释是文档的 +一部分,「进程树唯一受管」的绝对表述必须收敛为「未显式请求脱离的进程唯一受管」。 + +## 失败语义 + +不变。`SetInformationJobObject` 失败仍关闭句柄并返回原错误。 +需要注意的是**加这一位之前**,带 `CREATE_BREAKAWAY_FROM_JOB` 的 `CreateProcess` +会直接失败(`ERROR_ACCESS_DENIED`),这正是红灯测试的失败原因。 + +## Task 拆分 + +### Task 1:Job 允许显式脱离 + +- 文件:`internal/process/job_windows.go`、`internal/process/doc.go`、 + `internal/process/managed_windows_test.go` +- 红灯: + - `TestJob_BreakawayGrandchildSurvivesJobClose`——孙进程带 + `CREATE_BREAKAWAY_FROM_JOB` 启动,`IsProcessInJob(孙, 本 Job)` 为 false, + `Close()` 之后仍存活(测试自行终止它);无 `BREAKAWAY_OK` 时孙进程根本起不来 + - `TestJob_NonBreakawayGrandchildStaysInJobAndIsReaped`——不带该标志的孙进程 + `IsProcessInJob` 为 true,`Close()` 之后被杀 +- 绿灯:`LimitFlags |= windows.JOB_OBJECT_LIMIT_BREAKAWAY_OK` +- 验证:`go test ./internal/process -run 'Breakaway|StaysInJob' -count=1 -v`, + 以及 `go test ./internal/process -count=100`(并发相关)与全仓 race + +## 验收对照 + +| 任务拆分验收项 | 覆盖方式 | +| --- | --- | +| 带 `CREATE_BREAKAWAY_FROM_JOB` 的子进程能脱离、Job 关闭后仍存活 | `TestJob_BreakawayGrandchildSurvivesJobClose` | +| 不带该标志的子进程仍随 Job 回收 | `TestJob_NonBreakawayGrandchildStaysInJobAndIsReaped` + 既有 `TestJobE2E_RuntimeTerminationReapsGrandchildren` | +| 既有进程树清理、`BACKEND_FORCE_TERMINATED`、`details.pid` 无回退 | 既有 `internal/process` 与 `internal/backend` 全套测试不改 | +| 「跑着游戏关 AUTO-MAS」 | 跨仓联合验收,本仓库单侧做不到 | diff --git a/internal/process/doc.go b/internal/process/doc.go index 46af8a5..3e6f05e 100644 --- a/internal/process/doc.go +++ b/internal/process/doc.go @@ -1,2 +1,8 @@ // Package process 负责 Windows 进程与 Job Object 集成。 +// +// Runtime 创建的进程及其后代默认全部留在同一个 Job Object 内,由它统一回收; +// Job 同时开启 BREAKAWAY_OK,因此**显式**带 CREATE_BREAKAWAY_FROM_JOB 创建的 +// 进程会脱离该边界、不随 Job 关闭被杀(增补 1 C8:模拟器与 PC 游戏)。 +// 脱离出去的进程的生命周期归创建它的一方负责,Runtime 既不再回收它,也不会 +// 按进程名去找它。 package process diff --git a/internal/process/job.go b/internal/process/job.go index e3d0020..44ebbcf 100644 --- a/internal/process/job.go +++ b/internal/process/job.go @@ -5,7 +5,8 @@ import "errors" // ErrUnsupported 表示当前平台没有 Job Object 等价实现。 var ErrUnsupported = errors.New("process job is unsupported") -// Job 负责把 Runtime 创建的进程及其子进程绑定到同一回收边界。 +// Job 负责把 Runtime 创建的进程及其子进程绑定到同一回收边界; +// 只有显式带 CREATE_BREAKAWAY_FROM_JOB 创建的进程在该边界之外。 type Job interface { Assign(uint32) error Terminate(uint32) error diff --git a/internal/process/job_windows.go b/internal/process/job_windows.go index b6dfa56..b4b6719 100644 --- a/internal/process/job_windows.go +++ b/internal/process/job_windows.go @@ -25,14 +25,19 @@ type windowsJob struct { // Supported 报告 Windows 已提供 Job Object 进程树回收实现。 func Supported() bool { return true } -// NewJob 创建带 KILL_ON_JOB_CLOSE 的 Windows Job Object。 +// NewJob 创建带 KILL_ON_JOB_CLOSE 与 BREAKAWAY_OK 的 Windows Job Object。 +// BREAKAWAY_OK 只允许**显式**带 CREATE_BREAKAWAY_FROM_JOB 的子进程脱离, +// 供 AUTO-MAS 拉起的模拟器与 PC 游戏不随后端退出(增补 1 C8);未请求脱离的 +// 进程归属不受影响。刻意不用 SILENT_BREAKAWAY_OK——那会让所有子进程默认脱离, +// 后端自己的 worker 与 Agent 也会逃出回收边界。 func NewJob() (Job, error) { handle, err := windows.CreateJobObject(nil, nil) if err != nil { return nil, err } info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} - info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | + windows.JOB_OBJECT_LIMIT_BREAKAWAY_OK if result, err := windows.SetInformationJobObject( handle, windows.JobObjectExtendedLimitInformation, diff --git a/internal/process/managed_windows_test.go b/internal/process/managed_windows_test.go index 415ac92..4ab7b49 100644 --- a/internal/process/managed_windows_test.go +++ b/internal/process/managed_windows_test.go @@ -28,9 +28,13 @@ const ( managedGrandchildPIDEnv = "AUTO_MAS_TEST_MANAGED_GRANDCHILD_PID" managedGrandchildReleaseEnv = "AUTO_MAS_TEST_MANAGED_GRANDCHILD_RELEASE" managedDetachGrandchildEnv = "AUTO_MAS_TEST_MANAGED_DETACH_GRANDCHILD" - managedChildRootRole = "root" - managedChildSpawnerRole = "spawner" - managedChildGrandchildRole = "grandchild" + // managedBreakawayGrandchildEnv 让 detached spawner 用 CREATE_BREAKAWAY_FROM_JOB + // 启动孙进程,模拟 AUTO-MAS 拉起模拟器与 PC 游戏的方式(增补 1 C8)。 + managedBreakawayGrandchildEnv = "AUTO_MAS_TEST_MANAGED_BREAKAWAY_GRANDCHILD" + managedChildRootRole = "root" + managedChildSpawnerRole = "spawner" + managedChildDetachedSpawnerRole = "detached-spawner" + managedChildGrandchildRole = "grandchild" ) func TestJob_CreateSuspendedAssignsBeforeResume(t *testing.T) { @@ -329,6 +333,128 @@ func TestJobE2E_RuntimeTerminationReapsGrandchildren(t *testing.T) { } } +// TestJob_BreakawayGrandchildSurvivesJobClose 证明增补 1 C8 的正面:显式带 +// CREATE_BREAKAWAY_FROM_JOB 的孙进程不属于 Runtime 的 Job,Job 关闭后仍存活。 +// 这是「关闭 AUTO-MAS 时模拟器与 PC 游戏不跟着关」的机制依据。 +func TestJob_BreakawayGrandchildSurvivesJobClose(t *testing.T) { + handle, jobHandle, managed, release := startDetachedGrandchildFixture(t, true) + if processInJob(t, handle, jobHandle) { + t.Fatal("breakaway grandchild is still inside the runtime job") + } + if !processInJob(t, handle, 0) { + t.Fatal("breakaway grandchild belongs to no job at all, want its own or none of ours") + } + closeManagedAfterRelease(t, managed, release) + if result, err := windows.WaitForSingleObject(handle, 500); err != nil || result != uint32(windows.WAIT_TIMEOUT) { + t.Fatalf("breakaway grandchild after job close = result %d, err %v, want WAIT_TIMEOUT", result, err) + } +} + +// TestJob_NonBreakawayGrandchildStaysInJobAndIsReaped 是上一条的对照组:同一个 +// spawner、同样不继承管道,只是不带 CREATE_BREAKAWAY_FROM_JOB。BREAKAWAY_OK 只 +// 允许显式请求脱离,未请求的进程归属与回收一个字节都不能变(红线第 5 条)。 +func TestJob_NonBreakawayGrandchildStaysInJobAndIsReaped(t *testing.T) { + handle, jobHandle, managed, release := startDetachedGrandchildFixture(t, false) + if !processInJob(t, handle, jobHandle) { + t.Fatal("grandchild without CREATE_BREAKAWAY_FROM_JOB escaped the runtime job") + } + closeManagedAfterRelease(t, managed, release) + if result, err := windows.WaitForSingleObject(handle, 3000); err != nil || result != windows.WAIT_OBJECT_0 { + t.Fatalf("grandchild after job close = result %d, err %v, want WAIT_OBJECT_0", result, err) + } +} + +// startDetachedGrandchildFixture 启动一个 detached spawner 并返回孙进程句柄、 +// 当前 Job 句柄、受管进程和 release 文件路径。孙进程句柄与它的终止都由 Cleanup 负责, +// 因为脱离出去的进程按定义不再受 Job 回收。 +func startDetachedGrandchildFixture(t *testing.T, breakaway bool) (windows.Handle, windows.Handle, *ManagedProcess, string) { + t.Helper() + spec, signal, release := testManagedSpec(t, managedChildDetachedSpawnerRole) + spec.Env = replaceTestEnvironment(spec.Env, managedBreakawayGrandchildEnv, boolFlagValue(breakaway)) + managed, err := StartManaged(t.Context(), spec) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = managed.Close() }) + record := waitTestSignal(t, signal) + if !strings.Contains(record, "grandchildStart=ok") { + t.Fatalf("detached spawner record = %q, want grandchildStart=ok", record) + } + grandchildPID := waitGrandchildPID(t, filepath.Join(filepath.Dir(signal), "grandchild.pid")) + handle, err := windows.OpenProcess( + windows.SYNCHRONIZE|windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_TERMINATE, + false, + uint32(grandchildPID), + ) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + // 只终止仍在运行的那一个;对照组已被 Job 回收,这里的失败无意义。 + if result, waitErr := windows.WaitForSingleObject(handle, 0); waitErr == nil && result != windows.WAIT_OBJECT_0 { + _ = windows.TerminateProcess(handle, 99) + _, _ = windows.WaitForSingleObject(handle, 3000) + } + _ = windows.CloseHandle(handle) + }) + return handle, managedJobHandle(t, managed), managed, release +} + +func boolFlagValue(enabled bool) string { + if enabled { + return "1" + } + return "0" +} + +// closeManagedAfterRelease 放行根进程并关闭 Job,触发 KILL_ON_JOB_CLOSE。 +func closeManagedAfterRelease(t *testing.T, managed *ManagedProcess, release string) { + t.Helper() + if err := os.WriteFile(release, []byte("release"), 0o600); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + if _, err := managed.Wait(ctx); err != nil { + t.Fatalf("Wait() error = %v", err) + } + if err := managed.Close(); err != nil { + t.Fatal(err) + } +} + +// managedJobHandle 取出受管 Job 的原始句柄,供 IsProcessInJob 精确断言归属。 +// 必须在 Close 之前调用:Close 会关闭该句柄。 +func managedJobHandle(t *testing.T, managed *ManagedProcess) windows.Handle { + t.Helper() + job, ok := managed.job.(*windowsJob) + if !ok { + t.Fatalf("managed job type = %T, want *windowsJob", managed.job) + } + job.mu.Lock() + defer job.mu.Unlock() + if job.closed { + t.Fatal("managed job is already closed") + } + return job.handle +} + +// processInJob 报告进程是否属于指定 Job;jobHandle 为 0 时表示「是否属于任何 Job」。 +func processInJob(t *testing.T, processHandle, jobHandle windows.Handle) bool { + t.Helper() + procedure := windows.NewLazySystemDLL("kernel32.dll").NewProc("IsProcessInJob") + var inJob uint32 + result, _, callErr := procedure.Call( + uintptr(processHandle), + uintptr(jobHandle), + uintptr(unsafe.Pointer(&inJob)), + ) + if result == 0 { + t.Fatalf("IsProcessInJob() failed: %v", callErr) + } + return inJob != 0 +} + func TestManagedProcessChild(t *testing.T) { role := os.Getenv(managedChildRoleEnv) if role == "" { @@ -350,12 +476,50 @@ func TestManagedProcessChild(t *testing.T) { startManagedGrandchild(t) } record := fmt.Sprintf("inJob=%t stdinEOF=%t", inJob, stdinEOF) + if role == managedChildDetachedSpawnerRole { + // 启动结果写进 signal 而不是 t.Fatal:没有 BREAKAWAY_OK 时 CreateProcess + // 直接失败,父测试必须看到原因,而不是干等 grandchild.pid 超时。 + record += " grandchildStart=" + startDetachedGrandchild() + } if err := writeTestSignal(os.Getenv(managedChildSignalEnv), []byte(record+"\n")); err != nil { t.Fatal(err) } waitForReleaseFile(os.Getenv(managedChildReleaseEnv)) } +// startDetachedGrandchild 启动一个既不继承 Runtime 管道、也不随父测试进程取消的孙进程, +// 并在 managedBreakawayGrandchildEnv=1 时追加 CREATE_BREAKAWAY_FROM_JOB。 +// 不继承 stdout/stderr 是必要的:脱离 Job 之后 Terminate 收不到它,若它仍持有写端, +// 父进程的 Wait 会永远等不到 EOF,测出来的就不是脱离行为而是管道行为。 +// 返回值是写进 signal 的单行状态,"ok" 表示启动成功。 +func startDetachedGrandchild() string { + executable, err := os.Executable() + if err != nil { + return describeDetachedGrandchildError(err) + } + command := exec.Command(executable, "-test.run=^TestManagedProcessChild$") + command.Env = replaceTestEnvironment(os.Environ(), managedChildRoleEnv, managedChildGrandchildRole) + command.Env = replaceTestEnvironment(command.Env, managedChildReleaseEnv, os.Getenv(managedGrandchildReleaseEnv)) + if os.Getenv(managedBreakawayGrandchildEnv) == "1" { + command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_BREAKAWAY_FROM_JOB} + } + if err := command.Start(); err != nil { + return describeDetachedGrandchildError(err) + } + pidFile := os.Getenv(managedGrandchildPIDEnv) + if err := writeTestSignal(pidFile, []byte(strconv.Itoa(command.Process.Pid)+"\n")); err != nil { + _ = command.Process.Kill() + _ = command.Wait() + return describeDetachedGrandchildError(err) + } + go func() { _ = command.Wait() }() + return "ok" +} + +func describeDetachedGrandchildError(err error) string { + return strings.NewReplacer("\n", " ", "\r", " ", " ", "_").Replace(err.Error()) +} + func startManagedGrandchild(t *testing.T) { t.Helper() ctx := context.Background() From 4cfdd95f431b791815f0f46eb1cbc72dd8cae653 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 21:53:19 +0200 Subject: [PATCH 12/57] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20T13.2=20?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=83=85=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...3\273\345\212\241\346\213\206\345\210\206.md" | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index 00476d6..966594f 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -932,10 +932,24 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 (需真实 environment 状态 + git revision)本任务未新建,managed 的 cwd 由「backend 单测断言 传入 app-root」+「uv 真实子进程证明 WorkingDir 生效」两段接力覆盖;「用户数据在 `workspace sync` 后仍存在」需跨仓联合验收(TODO-PY-4/TODO-PY-10)才能端到端证明。 -- [ ] **T13.2 Job Object 允许游戏与模拟器脱离**(M) +- [x] **T13.2 Job Object 允许游戏与模拟器脱离**(M) ✅ 2026-09-01 `30fe0ba` - 依赖:M6;契约 [增补 1 C8](./契约补充-v1-增补1.md#c8job-object-逃逸与游戏模拟器的进程归属);成对 TODO-PY-11 - 内容:`internal/process/job_windows.go:35` 的 `LimitFlags` 增加 `JOB_OBJECT_LIMIT_BREAKAWAY_OK`,与既有 `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` 并存。只放开「显式请求脱离」这一条路径,不改变任何未请求脱离的进程的归属,也不引入按进程名清理(红线第 5 条)。 - 验收:真实 Windows Job 测试证明——带 `CREATE_BREAKAWAY_FROM_JOB` 创建的子进程能脱离,Job 关闭后仍存活;**不带**该标志的子进程仍随 Job 一起回收;既有进程树清理、`BACKEND_FORCE_TERMINATED` 警告与 `details.pid` 语义无回退。跨仓联合验收单独跑一次「跑着游戏关 AUTO-MAS」。 + - 证据:`internal/process/job_windows.go` 的 `LimitFlags` 现为 + `KILL_ON_JOB_CLOSE | BREAKAWAY_OK`(**未**使用 `SILENT_BREAKAWAY_OK`)。 + `TestJob_BreakawayGrandchildSurvivesJobClose` 与 + `TestJob_NonBreakawayGrandchildStaysInJobAndIsReaped` 是同一 spawner 的对照组, + 只差 `CREATE_BREAKAWAY_FROM_JOB`:用 `IsProcessInJob(孙进程, 本 Job 句柄)` 断言归属, + 再用 `WaitForSingleObject` 断言 Job 关闭后前者 `WAIT_TIMEOUT`(存活,由测试自行终止)、 + 后者 `WAIT_OBJECT_0`(被回收)。红灯时前者报 + `fork/exec ...: Access is denied.`——正是缺 `BREAKAWAY_OK` 的表现。 + `internal/process/doc.go` 与 `job.go` 的「同一回收边界」措辞已按 C8 收敛。 + gofmt/vet/build/`go test ./... -count=1`/`git diff --check` 全绿; + `go test ./internal/protocol -count=100` 与 `go test ./internal/process -count=100` + 均 exit 0;`go test -race ./... -count=1` exit 0(GCC 目录已前置到 PATH)。 + **缺口**:「跑着游戏关 AUTO-MAS」需 AUTO-MAS TODO-PY-11 落地后跨仓联合验收; + Runtime 单侧开这一位不产生任何可观察的行为变化。 - [ ] **T13.3 关闭超时参数化**(S) - 依赖:M6;契约 [增补 1 C9](./契约补充-v1-增补1.md#c9关闭预算参数化);默认值调整依赖 TODO-PY-12 的实测数据 - 内容:把 `internal/backend/control.go:20` 的 `defaultShutdownTimeout` 从编译期常量改为 `backend supervise` 的选项,正整数秒、合法范围 `1`~`120`、默认 `5`。越界或非数字按参数错误映射 `INVALID_ARGUMENT`(退出码 2、不可重试)。就绪侧预算(`internal/health/checker.go:21-24`)本次**不动**。**本任务只加开关,不改默认值**——在有实测数据之前改默认值等于用猜测替换猜测。 From a94228fcb9ba27465a2ea001758199e5fd57e718 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 21:54:06 +0200 Subject: [PATCH 13/57] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E5=90=8C=E6=AD=A5=E4=B8=B4=E6=97=B6=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E4=B8=8E=E5=8F=97=E6=8E=A7=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E7=B1=BB=E5=88=AB=20(T13.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DependencySyncDir 把临时项目目录钉在 build 缓存下,新增 DeleteDependencySync 让删除走 filesystem 受控原语而不是裸 RemoveAll。 Co-Authored-By: Claude Opus 5 --- internal/config/segment.go | 11 +++++++++++ internal/config/segment_test.go | 11 +++++++++++ internal/filesystem/delete.go | 4 +++- internal/filesystem/delete_test.go | 12 ++++++++++++ internal/filesystem/delete_windows.go | 3 +++ 5 files changed, 40 insertions(+), 1 deletion(-) diff --git a/internal/config/segment.go b/internal/config/segment.go index d656324..ce62cad 100644 --- a/internal/config/segment.go +++ b/internal/config/segment.go @@ -129,6 +129,17 @@ func (l *Layout) UVStagingDir(version, operationID string) (string, error) { return filepath.Join(l.paths.buildCacheDir, "uv", version, operationID), nil } +// DependencySyncDir 返回一次依赖同步中改写锁副本使用的临时项目目录。 +// +// 该目录位于构建缓存下,属于可丢弃缓存:由 Runtime 在每次镜像尝试前创建、 +// 尝试结束后立即删除,绝不落在 repo 或任何用户数据目录里。 +func (l *Layout) DependencySyncDir(operationID string) (string, error) { + if err := validateSegment(operationID); err != nil { + return "", fmt.Errorf("validate dependency sync operation id: %w", err) + } + return filepath.Join(l.paths.buildCacheDir, "dependencies", operationID), nil +} + // RuntimeLogFile 返回指定命令在本地日期的运行日志文件路径。 func (l *Layout) RuntimeLogFile(command string, localDate time.Time) (string, error) { if err := validateSegment(command); err != nil { diff --git a/internal/config/segment_test.go b/internal/config/segment_test.go index d1f4edb..8dae846 100644 --- a/internal/config/segment_test.go +++ b/internal/config/segment_test.go @@ -72,6 +72,14 @@ func TestLayout_DynamicPathsPreserveSegments(t *testing.T) { if want := filepath.Join(layout.BuildCacheDir(), "uv", "0.8.0", "Op-ID_1"); staging != want { t.Fatalf("UVStagingDir() = %q, want %q", staging, want) } + + dependencySync, err := layout.DependencySyncDir("Op-ID_1") + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(layout.BuildCacheDir(), "dependencies", "Op-ID_1"); dependencySync != want { + t.Fatalf("DependencySyncDir() = %q, want %q", dependencySync, want) + } } func TestLayout_DynamicPathsRejectUnsafeSegments(t *testing.T) { @@ -113,6 +121,9 @@ func TestLayout_DynamicPathsRejectUnsafeSegments(t *testing.T) { assertInvalidSegment(t, "RuntimeLogFile", func() (string, error) { return layout.RuntimeLogFile(value, time.Date(2026, 7, 30, 0, 30, 0, 0, time.UTC)) }) + assertInvalidSegment(t, "DependencySyncDir", func() (string, error) { + return layout.DependencySyncDir(value) + }) }) t.Run("staging version "+segmentTestName(value), func(t *testing.T) { diff --git a/internal/filesystem/delete.go b/internal/filesystem/delete.go index e64e413..2f99da3 100644 --- a/internal/filesystem/delete.go +++ b/internal/filesystem/delete.go @@ -105,6 +105,7 @@ const ( DeleteUVStaging DeleteKind = "uv_staging" DeletePythonCache DeleteKind = "python_cache" DeleteBuildCache DeleteKind = "build_cache" + DeleteDependencySync DeleteKind = "dependency_sync" ) func (k DeleteKind) String() string { return string(k) } @@ -120,7 +121,8 @@ func (k DeleteKind) Valid() bool { DeleteDownloadTemporary, DeleteUVStaging, DeletePythonCache, - DeleteBuildCache: + DeleteBuildCache, + DeleteDependencySync: return true default: return false diff --git a/internal/filesystem/delete_test.go b/internal/filesystem/delete_test.go index aa112de..9f055fe 100644 --- a/internal/filesystem/delete_test.go +++ b/internal/filesystem/delete_test.go @@ -190,6 +190,13 @@ func TestAuthorizeDeleteRequest_AcceptsOnlyExactLayoutIdentity(t *testing.T) { } return path } + dependencySync := func(operationID string) string { + path, err := fixture.layout.DependencySyncDir(operationID) + if err != nil { + t.Fatalf("DependencySyncDir() error = %v", err) + } + return path + } tests := []requestCase{ {name: "uv cache", kind: DeleteUVCache, target: fixture.layout.UVCacheDir()}, {name: "venv", kind: DeleteManagedVenv, target: fixture.layout.VenvDir()}, @@ -221,6 +228,11 @@ func TestAuthorizeDeleteRequest_AcceptsOnlyExactLayoutIdentity(t *testing.T) { target: filepath.Join(fixture.layout.RepoDir(), "pkg", "__pycache__"), }, {name: "build cache", kind: DeleteBuildCache, target: fixture.layout.BuildCacheDir()}, + { + name: "dependency sync", + kind: DeleteDependencySync, + dynamicTarget: dependencySync, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/internal/filesystem/delete_windows.go b/internal/filesystem/delete_windows.go index 6b3f129..a6051c1 100644 --- a/internal/filesystem/delete_windows.go +++ b/internal/filesystem/delete_windows.go @@ -230,6 +230,9 @@ func (o *Operator) expectedDeletePath( return o.layout.RepoDir(), false, nil case DeleteBuildCache: return o.layout.BuildCacheDir(), true, nil + case DeleteDependencySync: + path, err := o.layout.DependencySyncDir(request.OperationID) + return path, true, wrapLayoutArgument(err) default: return "", false, ErrInvalidArgument } From 39cf370039763a522edd1cbbae113b5e1ae7304b Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 21:58:15 +0200 Subject: [PATCH 14/57] =?UTF-8?q?feat(cli):=20backend=20supervise=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20--shutdown-timeout=20(T13.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按增补 1 C9:正整数秒、合法范围 1~120、默认 5,越界或非整数映射 INVALID_ARGUMENT(退出码 2)。选项收 string 自行解析,让非整数与越界 共用同一条 result 失败语义——pflag 的 int 解析失败只走 stderr 诊断。 backend.Request 增加 ShutdownTimeout,优先级高于 Dependencies 注入值, 两者都缺省时仍是编译期的 5 秒,既有行为不变。就绪侧预算不参数化。 --- ...66\345\217\202\346\225\260\345\214\226.md" | 70 +++++++++ internal/backend/control.go | 19 ++- internal/backend/control_test.go | 133 ++++++++++++++++++ internal/backend/types.go | 12 +- internal/cli/backend.go | 43 ++++++ internal/cli/backend_test.go | 94 +++++++++++++ 6 files changed, 362 insertions(+), 9 deletions(-) create mode 100644 "doc/current/M13/\350\256\276\350\256\241-T13.3-\345\205\263\351\227\255\350\266\205\346\227\266\345\217\202\346\225\260\345\214\226.md" diff --git "a/doc/current/M13/\350\256\276\350\256\241-T13.3-\345\205\263\351\227\255\350\266\205\346\227\266\345\217\202\346\225\260\345\214\226.md" "b/doc/current/M13/\350\256\276\350\256\241-T13.3-\345\205\263\351\227\255\350\266\205\346\227\266\345\217\202\346\225\260\345\214\226.md" new file mode 100644 index 0000000..00d2fd7 --- /dev/null +++ "b/doc/current/M13/\350\256\276\350\256\241-T13.3-\345\205\263\351\227\255\350\266\205\346\227\266\345\217\202\346\225\260\345\214\226.md" @@ -0,0 +1,70 @@ +# 设计与计划 T13.3 关闭超时参数化 + +- 契约:[增补 1 C9](../../契约补充-v1-增补1.md#c9关闭预算参数化) +- 任务:[任务拆分 T13.3](../../任务拆分.md) +- 状态:设计 + 计划 + +## 目标 + +`backend supervise` 新增 `--shutdown-timeout <秒>`:正整数,合法范围 `1`~`120`,**默认 `5`**。 +语义是「从向后端发出 `POST /api/core/close` 到进程退出的等待上限,超时才收 Job」。 +默认值不变,所以既有行为与既有 E2E 一个字节都不动——本任务**只加开关,不改默认值**。 + +## 边界(不负责) + +- 就绪侧预算(总启动 60 秒、轮询 500 毫秒、单次请求 2 秒、连续成功 2 次)**不参数化**, + 保持编译期常量;`defaultRestartDelay` 同样不动; +- 不改默认值:调多少要等 AUTO-MAS `TODO-PY-12` 的实测数据; +- 不改 `hello.capabilities`——那表达的是 stdin 控制命令,不是 CLI 选项; +- 超时之后的语义不变:仍关 Job 兜底,确认树空即发 `BACKEND_FORCE_TERMINATED` 警告并正常完成。 + +## API 形态 + +CLI:`command.Flags().StringVar(&shutdownTimeout, "shutdown-timeout", "5", ...)`。 +**刻意用 string 而不是 int**:pflag 的 int 解析失败发生在 Cobra 解析阶段,只会走 +`diagnosticExit`(stderr + 退出码 2),产生不了 `INVALID_ARGUMENT` 的 NDJSON `result`。 +用 string 自行 `strconv.Atoi` 才能让「非整数」和「越界」走同一条 `commandError` 路径, +与 C9「越界按参数错误处理并映射 `INVALID_ARGUMENT`」一致,也与既有 `--protocol` 的处理一致。 + +`internal/backend`:`Request` 新增 `ShutdownTimeout time.Duration`。 + +为什么放 `Request` 而不是改 `backendFactory` 签名:超时是**单次监督请求**的参数, +和 `Mode` / `DevelopmentRepo` 同级;而 `backendFactory` 的四参签名被十余处测试引用, +改它会产生与本任务无关的大面积 diff。`Dependencies.ShutdownTimeout` 作为组件测试的 +注入点保留,优先级为 `Request.ShutdownTimeout` > `Dependencies.ShutdownTimeout` > +`defaultShutdownTimeout`,只在 `shutdownBackend` 一处解析。 + +## 失败语义 + +`--shutdown-timeout` 非整数、`<1`、`>120`(含 `0`、`121`、负数、空串、小数)一律返回 +`INVALID_ARGUMENT`(退出码 2、不可重试),`details.field = "shutdown-timeout"`, +在 `backendFactory` 之前拒绝,不建立任何后端资源。不新增错误码、`stage` 或 `state`。 + +## Task 拆分 + +### Task 1:CLI 选项与参数校验 + +- 文件:`internal/cli/backend.go`、`internal/cli/backend_test.go` +- 红灯:`TestBackendSupervise_ShutdownTimeoutArgument`(表驱动:默认 5、边界 1/120、 + 合法中间值,以及 `0`/`121`/`-1`/`abc`/空串六种拒绝,并断言拒绝时 factory 零调用) +- 绿灯:注册 flag + `strconv.Atoi` + 范围校验 + 写入 `Request.ShutdownTimeout` +- 验证:`go test ./internal/cli -run ShutdownTimeout -count=1 -v` + +### Task 2:监督器消费该预算 + +- 文件:`internal/backend/types.go`、`internal/backend/control.go`、 + `internal/backend/control_test.go` +- 红灯:`TestBackend_ShutdownTimeoutBudgetComesFromRequest`——用记录关闭上下文 deadline 的 + 假 HTTP closer 证明预算随配置变化;配 30 秒时「close 后才退出」优雅收场、 + 配 10 毫秒且后端不退出时走 Job 兜底并发 `BACKEND_FORCE_TERMINATED` +- 绿灯:`Request.ShutdownTimeout` 字段 + `shutdownBackend` 的三级回退 +- 验证:`go test ./internal/backend -run ShutdownTimeout -count=1 -v` + +## 验收对照 + +| 任务拆分验收项 | 覆盖方式 | +| --- | --- | +| 表驱动覆盖合法值、边界 1/120、越界与非数字 | Task 1 | +| 不传该选项时行为与改动前完全一致 | Task 1 的 default 用例(Request 得到 5 秒)+ 既有全套 E2E 不改 | +| 假后端证明配置值真实生效(Job 兜底时刻随配置变化) | Task 2 的 deadline 断言 + 两条分支行为断言 | +| 既有关闭与单次重启契约测试无回退 | `go test ./... -count=1` 全绿 | diff --git a/internal/backend/control.go b/internal/backend/control.go index 7bed1d9..7bfca26 100644 --- a/internal/backend/control.go +++ b/internal/backend/control.go @@ -1930,11 +1930,7 @@ func (s *ManagedSupervisor) finishControlShutdown(ctx context.Context, request R snapshot.set(protocol.StageBackendShutdown, protocol.StateStopped, details) return s.emitState(request.Emitter, protocol.StageBackendShutdown, protocol.StateStopped, "后端已停止", details) } - timeout := s.deps.ShutdownTimeout - if timeout <= 0 { - timeout = defaultShutdownTimeout - } - closeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout) + closeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), s.shutdownTimeout(request)) defer cancel() closer := s.deps.HTTP if closer == nil { @@ -1973,6 +1969,19 @@ func (s *ManagedSupervisor) finishControlShutdown(ctx context.Context, request R return s.emitState(request.Emitter, protocol.StageBackendShutdown, protocol.StateStopped, "后端已停止", details) } +// shutdownTimeout 解析本次关闭的等待上限(增补 1 C9):调用方显式给出的预算优先, +// 其次是注入的依赖默认值,最后才是编译期常量。三级都在这一处解析,避免出现 +// 第二个真值来源。 +func (s *ManagedSupervisor) shutdownTimeout(request Request) time.Duration { + if request.ShutdownTimeout > 0 { + return request.ShutdownTimeout + } + if s.deps.ShutdownTimeout > 0 { + return s.deps.ShutdownTimeout + } + return defaultShutdownTimeout +} + func emitForceWarning(emitter EventEmitter, details map[string]any) error { warning, err := protocol.NewWarningEvent(protocol.CodeBackendForceTerminated, protocol.StageBackendShutdown, "后端已强制终止", details) if err != nil { diff --git a/internal/backend/control_test.go b/internal/backend/control_test.go index 1f9f68c..ce2751f 100644 --- a/internal/backend/control_test.go +++ b/internal/backend/control_test.go @@ -926,6 +926,107 @@ func TestBackend_ForceTerminationWarnsAndSucceeds(t *testing.T) { } } +// TestBackend_ShutdownTimeoutBudgetComesFromRequest 证明增补 1 C9 的开关真实生效: +// 关闭预算随 Request.ShutdownTimeout 变化,且预算大于后端退出耗时时优雅收场、 +// 小于时仍走既有的 Job 兜底与 BACKEND_FORCE_TERMINATED 路径。 +func TestBackend_ShutdownTimeoutBudgetComesFromRequest(t *testing.T) { + tests := []struct { + name string + timeout time.Duration + exitOnClose bool + wantForced bool + }{ + {name: "budget outlasts backend exit", timeout: 30 * time.Second, exitOnClose: true}, + {name: "budget expires before backend exit", timeout: 10 * time.Millisecond, wantForced: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f := newBackendFixture(t) + f.proc.keepAlive = true + closer := &budgetHTTPCloser{process: f.proc, exitOnClose: test.exitOnClose} + f.depsHTTP = closer + // deps 侧留一个明显不同的值,证明 Request 的优先级高于依赖默认值。 + f.shutdownTimeout = time.Hour + mailbox := NewControlMailbox(8) + done := make(chan error, 1) + go func() { + req := f.request() + req.Control = mailbox + req.ShutdownTimeout = test.timeout + done <- f.supervisorWithHTTP(t.Context(), req) + }() + waitFor(t, f.emitter.running) + if err := mailbox.Submit(context.Background(), protocol.ControlCommand{ + Command: protocol.ControlShutdown, + CommandID: "shutdown-budget", + }); err != nil { + t.Fatalf("Submit(shutdown) error = %v", err) + } + if err := <-done; err != nil { + t.Fatalf("Supervise() error = %v, want nil", err) + } + budget, calls := closer.observed() + if calls != 1 { + t.Fatalf("close calls = %d, want 1", calls) + } + // 预算按真实时钟从 WithTimeout 起算,观测值必然略小于配置值。 + if budget <= 0 || budget > test.timeout { + t.Fatalf("close budget = %v, want (0, %v]", budget, test.timeout) + } + if slack := test.timeout - budget; slack > test.timeout/2 { + t.Fatalf("close budget = %v, want close to the configured %v", budget, test.timeout) + } + forced := indexOfEvent(f.emitter.eventsSnapshot(), "warning:"+string(protocol.CodeBackendForceTerminated)) >= 0 + if forced != test.wantForced { + t.Fatalf("force warning = %t, want %t; events=%#v", forced, test.wantForced, f.emitter.eventsSnapshot()) + } + }) + } +} + +// TestBackend_ShutdownTimeoutFallsBackToDependencyAndDefault 锁定三级回退: +// Request 未设置时用依赖注入值,依赖也未设置时用编译期默认的 5 秒。 +func TestBackend_ShutdownTimeoutFallsBackToDependencyAndDefault(t *testing.T) { + tests := []struct { + name string + dependency time.Duration + want time.Duration + }{ + {name: "dependency value", dependency: 45 * time.Second, want: 45 * time.Second}, + {name: "compiled default", want: defaultShutdownTimeout}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f := newBackendFixture(t) + f.proc.keepAlive = true + closer := &budgetHTTPCloser{process: f.proc, exitOnClose: true} + f.depsHTTP = closer + f.shutdownTimeout = test.dependency + mailbox := NewControlMailbox(8) + done := make(chan error, 1) + go func() { + req := f.request() + req.Control = mailbox + done <- f.supervisorWithHTTP(t.Context(), req) + }() + waitFor(t, f.emitter.running) + if err := mailbox.Submit(context.Background(), protocol.ControlCommand{ + Command: protocol.ControlShutdown, + CommandID: "shutdown-fallback", + }); err != nil { + t.Fatalf("Submit(shutdown) error = %v", err) + } + if err := <-done; err != nil { + t.Fatalf("Supervise() error = %v, want nil", err) + } + budget, _ := closer.observed() + if budget <= 0 || budget > test.want || test.want-budget > test.want/2 { + t.Fatalf("close budget = %v, want close to %v", budget, test.want) + } + }) + } +} + func TestBackend_ShutdownFailedIfTreeUncertain(t *testing.T) { f := newBackendFixture(t) f.proc.keepAlive = true @@ -1359,6 +1460,38 @@ type orderedHTTPCloser struct { record func(string) } +// budgetHTTPCloser 记录 Runtime 交给关闭请求的真实预算,并按 exitOnClose +// 决定后端收到 close 之后是否真的退出——这是「close 后 N 秒才退出」的确定性替身, +// 不靠 time.Sleep 撞运气。 +type budgetHTTPCloser struct { + process *fakeProcess + exitOnClose bool + + // mu 保护 budget 与 calls;Close 由监督 goroutine 调用,断言在测试 goroutine。 + mu sync.Mutex + budget time.Duration + calls int +} + +func (c *budgetHTTPCloser) Close(ctx context.Context) error { + c.mu.Lock() + c.calls++ + if deadline, ok := ctx.Deadline(); ok { + c.budget = time.Until(deadline) + } + c.mu.Unlock() + if c.exitOnClose { + return c.process.Terminate(0) + } + return nil +} + +func (c *budgetHTTPCloser) observed() (time.Duration, int) { + c.mu.Lock() + defer c.mu.Unlock() + return c.budget, c.calls +} + type errorHTTPCloser struct{} func (errorHTTPCloser) Close(context.Context) error { return errors.New("close endpoint unavailable") } diff --git a/internal/backend/types.go b/internal/backend/types.go index d1ac11f..c75ae49 100644 --- a/internal/backend/types.go +++ b/internal/backend/types.go @@ -20,10 +20,14 @@ type EventEmitter interface { // Request 描述一次受管后端监督请求。 type Request struct { - OperationID string - RuntimePID uint32 - Mode Mode - DevelopmentRepo string + OperationID string + RuntimePID uint32 + Mode Mode + DevelopmentRepo string + // ShutdownTimeout 是从发出 POST /api/core/close 到进程退出的等待上限, + // 超时才收 Job(增补 1 C9)。CLI 由 --shutdown-timeout 提供,取值 1~120 秒; + // 为零或负数时回退 Dependencies.ShutdownTimeout。 + ShutdownTimeout time.Duration Emitter EventEmitter Control ControlReceiver BeforeShutdown func(string) diff --git a/internal/cli/backend.go b/internal/cli/backend.go index 93d5d70..ddac96a 100644 --- a/internal/cli/backend.go +++ b/internal/cli/backend.go @@ -5,8 +5,10 @@ import ( "errors" "os" "path/filepath" + "strconv" "strings" "sync" + "time" "github.com/spf13/cobra" @@ -17,11 +19,17 @@ import ( const ( backendModeManaged = "managed" backendModeDevelopment = "development" + // 关闭预算的取值范围与默认值按增补 1 C9 冻结;默认值待 AUTO-MAS 侧 + // 实测「MaaFW 任务运行中收到 close」的耗时后再议,本任务只加开关。 + backendShutdownTimeoutDefault = "5" + backendShutdownTimeoutMin = 1 + backendShutdownTimeoutMax = 120 ) func backendSuperviseCommand(deps *deps) *cobra.Command { var mode string var repo string + var shutdownTimeout string command := &cobra.Command{ Use: "supervise", Short: "启动并监督后端进程", @@ -74,6 +82,10 @@ func backendSuperviseCommand(deps *deps) *cobra.Command { cause: errors.New("managed mode does not accept development repository"), } } + shutdownBudget, err := parseBackendShutdownTimeout(shutdownTimeout) + if err != nil { + return sessionSuccess{}, err + } service, err := deps.options.backendFactory( ctx, deps.global.layout, @@ -107,6 +119,7 @@ func backendSuperviseCommand(deps *deps) *cobra.Command { RuntimePID: uint32(pid), Mode: backend.Mode(mode), DevelopmentRepo: repo, + ShutdownTimeout: shutdownBudget, Emitter: &backendEventEmitter{emitter: emitter, control: control}, Control: mailbox, BeforeShutdown: mailbox.BeforeShutdown, @@ -126,9 +139,39 @@ func backendSuperviseCommand(deps *deps) *cobra.Command { } command.Flags().StringVar(&mode, "mode", "", "后端运行模式:managed 或 development") command.Flags().StringVar(&repo, "repo", "", "development 模式源码目录") + command.Flags().StringVar( + &shutdownTimeout, + "shutdown-timeout", + backendShutdownTimeoutDefault, + "关闭后端的等待上限(秒),取值 1~120", + ) return command } +// parseBackendShutdownTimeout 校验 --shutdown-timeout(增补 1 C9):正整数秒、 +// 合法范围 1~120。这里刻意收 string 而不是让 pflag 收 int——pflag 的整数解析失败 +// 发生在 Cobra 解析阶段,只会走 stderr 诊断通道,产不出 INVALID_ARGUMENT 的 +// result 事件;自行解析才能让越界与非整数共用同一条失败语义。 +func parseBackendShutdownTimeout(raw string) (time.Duration, error) { + reject := func(cause error) error { + return &commandError{ + code: protocol.CodeInvalidArgument, + stage: protocol.StageBackendSpawn, + message: "关闭超时必须是 1 到 120 之间的整数秒", + details: map[string]any{"field": "shutdown-timeout", "value": raw}, + cause: cause, + } + } + seconds, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil { + return 0, reject(errors.New("backend shutdown timeout is not an integer")) + } + if seconds < backendShutdownTimeoutMin || seconds > backendShutdownTimeoutMax { + return 0, reject(errors.New("backend shutdown timeout is out of range")) + } + return time.Duration(seconds) * time.Second, nil +} + func runBackendSuperviseSession( ctx context.Context, deps *deps, diff --git a/internal/cli/backend_test.go b/internal/cli/backend_test.go index f11ca6a..59dc244 100644 --- a/internal/cli/backend_test.go +++ b/internal/cli/backend_test.go @@ -63,6 +63,100 @@ func TestBackendSupervise_RequiresExplicitManagedMode(t *testing.T) { } } +// TestBackendSupervise_ShutdownTimeoutArgument 覆盖增补 1 C9 的参数契约: +// 正整数秒、合法范围 1~120、默认 5;越界或非整数映射 INVALID_ARGUMENT 并 +// 在建立任何后端资源之前失败关闭。 +func TestBackendSupervise_ShutdownTimeoutArgument(t *testing.T) { + t.Parallel() + + accepted := []struct { + name string + args []string + want time.Duration + }{ + {name: "default", want: 5 * time.Second}, + {name: "lower bound", args: []string{"--shutdown-timeout", "1"}, want: time.Second}, + {name: "upper bound", args: []string{"--shutdown-timeout", "120"}, want: 120 * time.Second}, + {name: "middle", args: []string{"--shutdown-timeout", "30"}, want: 30 * time.Second}, + } + for _, test := range accepted { + t.Run("accepted/"+test.name, func(t *testing.T) { + t.Parallel() + var captured backend.Request + var stdout, stderr bytes.Buffer + args := []string{"--app-root", t.TempDir(), "--output", "ndjson", "backend", "supervise", "--mode", "managed"} + args = append(args, test.args...) + code := Execute( + context.Background(), + args, + IO{In: strings.NewReader(""), Out: &stdout, Err: &stderr}, + WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time) (backendService, error) { + return backendServiceFunc(func(_ context.Context, request backend.Request) error { + captured = request + return nil + }), nil + }), + ) + if code != protocol.ExitCodeSuccess { + t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) + } + if got := captured.ShutdownTimeout; got != test.want { + t.Fatalf("shutdown timeout = %v, want %v", got, test.want) + } + }) + } + + rejected := []struct { + name string + value string + }{ + {name: "zero", value: "0"}, + {name: "above upper bound", value: "121"}, + {name: "negative", value: "-1"}, + {name: "not an integer", value: "abc"}, + {name: "fractional", value: "1.5"}, + {name: "empty", value: ""}, + } + for _, test := range rejected { + t.Run("rejected/"+test.name, func(t *testing.T) { + t.Parallel() + var factoryCalls int + var stdout, stderr bytes.Buffer + code := Execute( + context.Background(), + []string{ + "--app-root", t.TempDir(), "--output", "ndjson", + "backend", "supervise", "--mode", "managed", "--shutdown-timeout", test.value, + }, + IO{In: strings.NewReader(""), Out: &stdout, Err: &stderr}, + WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time) (backendService, error) { + factoryCalls++ + return backendServiceFunc(func(context.Context, backend.Request) error { return nil }), nil + }), + ) + if factoryCalls != 0 { + t.Fatalf("backend factory calls = %d, want 0", factoryCalls) + } + definition, ok := protocol.LookupErrorDefinition(protocol.CodeInvalidArgument) + if !ok { + t.Fatal("INVALID_ARGUMENT definition is missing") + } + if code != definition.ExitCode { + t.Fatalf("exit code = %d, want %d; stderr=%q", code, definition.ExitCode, stderr.String()) + } + events := parseNDJSON(t, stdout.String()) + result := events[len(events)-1] + if got := eventString(result, "code"); got != string(protocol.CodeInvalidArgument) { + t.Fatalf("result code = %q, want INVALID_ARGUMENT", got) + } + details, ok := result.object["details"].(map[string]any) + if !ok || details["field"] != "shutdown-timeout" { + t.Fatalf("result details = %#v, want field=shutdown-timeout", result.object["details"]) + } + }) + } +} + func TestBackendDevelopment_CLIResolvesExplicitRepoFromCWD(t *testing.T) { cwd := t.TempDir() var captured backend.Request From 9d21bc1ae177e796467f9b4d81877a95510e3cd9 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 22:02:21 +0200 Subject: [PATCH 15/57] =?UTF-8?q?feat:=20dependencies=20sync=20=E6=8C=89?= =?UTF-8?q?=E5=8C=85=E7=B4=A2=E5=BC=95=E9=95=9C=E5=83=8F=E8=BD=AE=E6=8D=A2?= =?UTF-8?q?=E6=94=B9=E5=86=99=E9=94=81=E5=89=AF=E6=9C=AC=20(T13.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每个镜像源在受管临时项目目录里用改写后的锁执行 --frozen;plan 末位的官方源 改用 repo 原锁与 --locked,即 C10 的回退。--mirror-only 因此天然不回退。 临时目录在成功、失败与取消三条路径上都经 filesystem 受控删除收口。 Co-Authored-By: Claude Opus 5 --- internal/uv/dependencies.go | 111 ++++-- internal/uv/dependencies_mirror.go | 479 ++++++++++++++++++++++++ internal/uv/dependencies_mirror_test.go | 362 ++++++++++++++++++ internal/uv/dependencies_test.go | 86 ++--- 4 files changed, 956 insertions(+), 82 deletions(-) create mode 100644 internal/uv/dependencies_mirror.go create mode 100644 internal/uv/dependencies_mirror_test.go diff --git a/internal/uv/dependencies.go b/internal/uv/dependencies.go index 5f652c2..a90604b 100644 --- a/internal/uv/dependencies.go +++ b/internal/uv/dependencies.go @@ -26,6 +26,8 @@ type DependenciesRequest struct { Commit string MirrorPolicy mirror.Policy Line LineFunc + // Attempt 在镜像轮换的每次尝试开始前报告当前源,可为 nil。 + Attempt MirrorAttemptFunc } // DependenciesResult 保存锁文件检查或同步后的稳定结果。 @@ -33,13 +35,24 @@ type DependenciesResult struct { LockfileChecked bool Synchronized bool Rebuilt bool + // SourceKind 恒为 package-index,同步执行过才有值。 + SourceKind string + // Source 是实际完成同步的源 key;回退原锁时为官方源 key,离线时为空。 + Source string + // AttemptCount 是实际执行 uv sync 的次数,含回退那次。 + AttemptCount int + // LockRewritten 报告成功那次是否用了改写后的锁副本。 + LockRewritten bool } // DependenciesService 负责锁文件契约、项目模式同步和 managed venv 重建。 type DependenciesService struct { - layout *config.Layout - runner Runner - remover TreeRemover + layout *config.Layout + runner Runner + remover TreeRemover + catalog *mirror.Catalog + rotator sourceRotator + stagingRemover TreeRemover } // NewDependenciesService 创建主项目依赖服务。 @@ -47,11 +60,43 @@ func NewDependenciesService( layout *config.Layout, runner Runner, remover TreeRemover, + options ...DependenciesOption, ) (*DependenciesService, error) { if layout == nil || runner == nil || remover == nil { return nil, errors.New("dependencies service dependencies are incomplete") } - return &DependenciesService{layout: layout, runner: runner, remover: remover}, nil + configured := dependenciesOptions{stagingRemover: filesystemDependencyRemover{layout: layout}} + for index, option := range options { + if option == nil { + return nil, fmt.Errorf("dependencies option at index %d is nil", index) + } + if err := option(&configured); err != nil { + return nil, err + } + } + if configured.catalog == nil { + catalog, err := mirror.DefaultCatalog() + if err != nil { + return nil, fmt.Errorf("build dependencies mirror catalog: %w", err) + } + configured.catalog = catalog + } + if configured.rotator == nil { + // 每个源只尝试一次,见 runStagedSync 的说明。 + rotator, err := mirror.NewRotator(mirror.WithMaxSourceAttempts(1)) + if err != nil { + return nil, fmt.Errorf("build dependencies mirror rotator: %w", err) + } + configured.rotator = rotator + } + return &DependenciesService{ + layout: layout, + runner: runner, + remover: remover, + catalog: configured.catalog, + rotator: configured.rotator, + stagingRemover: configured.stagingRemover, + }, nil } // Check 只读检查 uv.lock 与现有主项目环境是否保持同步。 @@ -85,7 +130,10 @@ func (s *DependenciesService) Check( return DependenciesResult{LockfileChecked: true, Synchronized: true}, nil } -// Sync 在只读锁文件检查通过后执行固定的锁定依赖同步。 +// Sync 在只读锁文件检查通过后执行锁定依赖同步。 +// +// 在线时按 C10 走包索引镜像轮换:每个镜像用改写后的锁副本安装,全部失败后 +// 回退到 repo 原锁。离线时完全不改写,沿用原锁并只注入 UV_OFFLINE=1。 func (s *DependenciesService) Sync( ctx context.Context, request DependenciesRequest, @@ -96,36 +144,43 @@ func (s *DependenciesService) Sync( if err := s.checkLockfile(ctx, request); err != nil { return DependenciesResult{}, err } - options := s.runOptions(request, protocol.StageDependenciesSync) if request.MirrorPolicy.Offline() { - options = withOfflineUV(options) + return s.syncOffline(ctx, request) } - result, err := s.runner.Run(ctx, []string{ - "sync", - "--project", - request.ProjectDir, - "--python", - request.PythonVersion, - "--locked", - "--no-default-groups", - "--no-install-workspace", - }, options) + return s.syncWithMirrors(ctx, request) +} + +func (s *DependenciesService) syncOffline( + ctx context.Context, + request DependenciesRequest, +) (DependenciesResult, error) { + result, err := s.runner.Run( + ctx, + lockedSyncArguments(request.ProjectDir, request.PythonVersion), + withOfflineUV(s.runOptions(request, protocol.StageDependenciesSync)), + ) if err != nil || result.ExitCode != 0 { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return DependenciesResult{}, err } - if request.MirrorPolicy.Offline() { - return DependenciesResult{}, newError( - protocol.CodeNetworkUnavailable, - protocol.StageDependenciesSync, - "离线缓存不足,操作需要网络", - map[string]any{"sourceKind": mirror.KindPackageIndex.String(), "exitCode": result.ExitCode}, - nonNilRunError(err), - ) - } - return DependenciesResult{}, dependencySyncError(result, err) + return DependenciesResult{}, newError( + protocol.CodeNetworkUnavailable, + protocol.StageDependenciesSync, + "离线缓存不足,操作需要网络", + map[string]any{ + "sourceKind": mirror.KindPackageIndex.String(), + "exitCode": result.ExitCode, + "attemptCount": 1, + }, + nonNilRunError(err), + ) } - return DependenciesResult{LockfileChecked: true, Synchronized: true}, nil + return DependenciesResult{ + LockfileChecked: true, + Synchronized: true, + SourceKind: mirror.KindPackageIndex.String(), + AttemptCount: 1, + }, nil } func (s *DependenciesService) checkLockfile(ctx context.Context, request DependenciesRequest) error { diff --git a/internal/uv/dependencies_mirror.go b/internal/uv/dependencies_mirror.go new file mode 100644 index 0000000..8ef87b1 --- /dev/null +++ b/internal/uv/dependencies_mirror.go @@ -0,0 +1,479 @@ +package uv + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/config" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/filesystem" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/logging" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" +) + +// stagingCleanupTimeout 是临时项目目录收口的有界预算。 +// +// 清理走脱离业务取消的 context,因此需要自己的超时,否则取消后可能永远等下去。 +const stagingCleanupTimeout = 15 * time.Second + +// MirrorAttempt 描述依赖同步镜像轮换中的一次尝试。 +type MirrorAttempt struct { + SourceKind string + Source string + SourceTry int + GlobalTry int + // Fallback 为真表示这次用的是 repo 原锁与 --locked,即 C10 的回退路径。 + Fallback bool +} + +// MirrorAttemptFunc 在每次尝试开始前报告当前源;返回错误会中止整轮轮换。 +type MirrorAttemptFunc func(ctx context.Context, attempt MirrorAttempt) error + +// DependenciesOption 配置 DependenciesService 的可注入依赖。 +type DependenciesOption func(*dependenciesOptions) error + +type dependenciesOptions struct { + catalog *mirror.Catalog + rotator sourceRotator + stagingRemover TreeRemover +} + +// WithDependenciesCatalog 注入包索引源目录。 +func WithDependenciesCatalog(catalog *mirror.Catalog) DependenciesOption { + return func(options *dependenciesOptions) error { + if options == nil || catalog == nil { + return errors.New("dependencies mirror catalog is invalid") + } + options.catalog = catalog + return nil + } +} + +// WithDependenciesRotator 注入镜像轮换器。 +func WithDependenciesRotator(rotator sourceRotator) DependenciesOption { + return func(options *dependenciesOptions) error { + if options == nil || rotator == nil { + return errors.New("dependencies mirror rotator is invalid") + } + options.rotator = rotator + return nil + } +} + +// WithDependenciesStagingRemover 注入临时项目目录的受控删除能力。 +func WithDependenciesStagingRemover(remover TreeRemover) DependenciesOption { + return func(options *dependenciesOptions) error { + if options == nil || remover == nil { + return errors.New("dependencies staging remover is invalid") + } + options.stagingRemover = remover + return nil + } +} + +// syncWithMirrors 按镜像轮换执行 uv sync。 +// +// 每个镜像源在受管临时项目目录里用改写后的锁副本执行 --frozen 安装;plan 末位的 +// 官方源改用 repo 原锁与 --locked,这就是 C10 的「全部镜像失败后回退原锁」。 +// --mirror-only 时 BuildPlan 本就不放官方源进 plan,因此不需要额外的分支来禁止回退。 +func (s *DependenciesService) syncWithMirrors( + ctx context.Context, + request DependenciesRequest, +) (DependenciesResult, error) { + lock, err := readManagedRegularFile(s.lockfilePath(request.ProjectDir), maxUVLockFileBytes) + if err != nil { + return DependenciesResult{}, newError( + protocol.CodeLockfileMissing, + protocol.StageDependenciesSync, + "项目锁文件不可读取", + map[string]any{}, + err, + ) + } + projectFile, err := readManagedRegularFile( + filepath.Join(request.ProjectDir, "pyproject.toml"), + maxPyProjectFileBytes, + ) + if err != nil { + return DependenciesResult{}, newError( + protocol.CodeDependencySyncFailed, + protocol.StageDependenciesSync, + "项目声明文件不可读取", + map[string]any{}, + err, + ) + } + digest := sha256.Sum256(lock) + target, err := mirror.NewTarget(mirror.TargetSpec{LockDigest: hex.EncodeToString(digest[:])}) + if err != nil { + return DependenciesResult{}, fmt.Errorf("build package index mirror target: %w", err) + } + plan, err := s.buildPackageIndexPlan(request.MirrorPolicy) + if err != nil { + return DependenciesResult{}, err + } + + state := mirrorSyncState{} + rotationResult, rotationErr := s.rotator.Run( + ctx, + plan, + target, + func(attemptCtx context.Context, attempt mirror.Attempt) mirror.AttemptOutcome { + return s.runMirrorAttempt(attemptCtx, request, attempt, string(lock), string(projectFile), &state) + }, + ) + if rotationErr == nil { + return DependenciesResult{ + LockfileChecked: true, + Synchronized: true, + SourceKind: mirror.KindPackageIndex.String(), + Source: rotationResult.Source.Key(), + AttemptCount: state.attempts, + LockRewritten: !rotationResult.Source.Official(), + }, nil + } + return DependenciesResult{}, s.mapRotationFailure(ctx, plan, state, rotationErr) +} + +// buildPackageIndexPlan 生成包索引源的尝试顺序。 +// +// 显式 --mirror package-index= 由 BuildPlan 排在最前(C10 的 2026-09-01 修订)。 +// 用户显式指定却选不出源时必须失败关闭,不能静默换成别的源;只有 Policy 自身结构 +// 不合法(例如零值 Policy)才退回目录默认顺序,与 internal/uv 其他网络路径一致。 +func (s *DependenciesService) buildPackageIndexPlan(policy mirror.Policy) (mirror.Plan, error) { + plan, err := mirror.BuildPlan(s.catalog, policy, mirror.KindPackageIndex) + if err == nil { + return plan, nil + } + if errors.Is(err, mirror.ErrPolicyRejected) { + return mirror.Plan{}, newError( + protocol.CodeInvalidArgument, + protocol.StageDependenciesSync, + "镜像源选择无效", + map[string]any{"sourceKind": mirror.KindPackageIndex.String()}, + err, + ) + } + defaultPolicy, defaultErr := mirror.NewPolicy(mirror.PolicySpec{Preferred: map[mirror.Kind]string{}}) + if defaultErr != nil { + return mirror.Plan{}, fmt.Errorf("build default package index policy: %w", defaultErr) + } + plan, defaultErr = mirror.BuildPlan(s.catalog, defaultPolicy, mirror.KindPackageIndex) + if defaultErr != nil { + return mirror.Plan{}, fmt.Errorf("build package index mirror plan: %w", errors.Join(err, defaultErr)) + } + return plan, nil +} + +// mirrorSyncState 累积轮换过程中的可变事实,只在 Rotator 的串行回调里被写。 +type mirrorSyncState struct { + attempts int + lastResult UVResult + lastErr error + notifyErr error +} + +func (s *DependenciesService) runMirrorAttempt( + ctx context.Context, + request DependenciesRequest, + attempt mirror.Attempt, + lock string, + projectFile string, + state *mirrorSyncState, +) mirror.AttemptOutcome { + fallback := attempt.Source.Official() + state.attempts++ + if err := notifyMirrorAttempt(ctx, request.Attempt, MirrorAttempt{ + SourceKind: attempt.Source.Kind().String(), + Source: attempt.Source.Key(), + SourceTry: attempt.SourceTry, + GlobalTry: attempt.GlobalTry, + Fallback: fallback, + }); err != nil { + // 报告失败意味着协议输出已经不可用,继续换源没有意义: + // TargetFailure 是 Rotator 唯一会立刻结束整轮的失败结局。 + state.notifyErr = err + return mirror.AttemptOutcome{ + Kind: mirror.OutcomeTargetFailure, + FailureKind: mirror.FailureKind("attempt_report"), + Err: err, + } + } + if fallback { + result, runErr := s.runner.Run( + ctx, + lockedSyncArguments(request.ProjectDir, request.PythonVersion), + s.runOptions(request, protocol.StageDependenciesSync), + ) + return s.finishAttempt(state, result, runErr) + } + rewrite, ok := attempt.Source.PackageIndexRewrite() + if !ok { + err := errors.New("package index source has no rewrite prefixes") + state.lastErr = err + return mirror.AttemptOutcome{ + Kind: mirror.OutcomeSwitchSource, + FailureKind: mirror.FailureKind("missing_rewrite"), + Err: err, + } + } + result, runErr := s.runStagedSync(ctx, request, rewrite, lock, projectFile) + return s.finishAttempt(state, result, runErr) +} + +func (s *DependenciesService) finishAttempt( + state *mirrorSyncState, + result UVResult, + err error, +) mirror.AttemptOutcome { + state.lastResult = result + if err == nil && result.ExitCode == 0 { + state.lastErr = nil + return mirror.AttemptOutcome{Kind: mirror.OutcomeSucceeded} + } + state.lastErr = nonNilRunError(err) + // 每个源只尝试一次:uv sync 是重操作,uv 自身对单个 artifact 已有重试, + // 同源重试要重建临时目录并重跑整条安装流程,断网时只会把等待时间翻倍。 + return mirror.AttemptOutcome{ + Kind: mirror.OutcomeSwitchSource, + FailureKind: mirror.FailureKind("uv_exec"), + Err: state.lastErr, + } +} + +// runStagedSync 在受管临时项目目录里用改写后的锁执行一次 --frozen 安装。 +// +// 目录在返回前一定被删除:成功、失败与取消都走同一个 defer,取消时使用脱离 +// 业务 context 的收口预算,因此不会把临时目录留在盘上。 +func (s *DependenciesService) runStagedSync( + ctx context.Context, + request DependenciesRequest, + rewrite mirror.PackageIndexRewrite, + lock string, + projectFile string, +) (result UVResult, returnErr error) { + stagingDir, err := s.layout.DependencySyncDir(request.OperationID) + if err != nil { + return UVResult{}, err + } + // 先做一次幂等清理:上一个源用完的目录已经删了,但进程崩溃可能留下残留, + // 而 PrepareManagedDirectory 要求目标不存在。 + if err := s.removeStagingProject(ctx, request.OperationID, stagingDir); err != nil { + return UVResult{}, err + } + defer func() { + cleanupCtx, cancel := stagingCleanupContext(ctx) + defer cancel() + if cleanupErr := s.removeStagingProject(cleanupCtx, request.OperationID, stagingDir); cleanupErr != nil { + returnErr = errors.Join(returnErr, cleanupErr) + } + }() + rewritten := rewriteLockfile(lock, rewrite) + if err := writeStagingProject(ctx, s.layout, stagingDir, projectFile, rewritten.Lock); err != nil { + return UVResult{}, err + } + return s.runner.Run( + ctx, + mirrorSyncArguments(stagingDir, request.PythonVersion), + s.runOptions(request, protocol.StageDependenciesSync), + ) +} + +func (s *DependenciesService) removeStagingProject( + ctx context.Context, + operationID string, + stagingDir string, +) error { + if s.stagingRemover == nil { + return errors.New("dependencies staging remover is unavailable") + } + result, err := s.stagingRemover.RemoveTree(ctx, filesystem.DeleteRequest{ + Kind: filesystem.DeleteDependencySync, + Target: stagingDir, + OperationID: operationID, + Reason: "remove dependency sync staging project", + }) + if err != nil { + return fmt.Errorf("remove dependency sync staging project: %w", err) + } + if result.Partial { + return errors.New("dependency sync staging project removal was partial") + } + return nil +} + +func (s *DependenciesService) mapRotationFailure( + ctx context.Context, + plan mirror.Plan, + state mirrorSyncState, + rotationErr error, +) error { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + if errors.Is(rotationErr, context.Canceled) || errors.Is(rotationErr, context.DeadlineExceeded) { + return rotationErr + } + if state.notifyErr != nil { + return state.notifyErr + } + details := map[string]any{ + "sourceKind": mirror.KindPackageIndex.String(), + "attemptCount": state.attempts, + "exitCode": state.lastResult.ExitCode, + } + var rotationError *mirror.RotationError + if errors.As(rotationErr, &rotationError) && rotationError.Code() == protocol.CodeMirrorExhausted { + if planHasOfficialSource(plan) { + // 回退那次也失败了,语义与今天的单次 --locked 失败完全一致。 + return newError( + protocol.CodeDependencySyncFailed, + protocol.StageDependenciesSync, + "Python 依赖同步失败", + details, + errors.Join(state.lastErr, rotationErr), + ) + } + return newError( + protocol.CodeMirrorExhausted, + protocol.StageDependenciesSync, + "所有镜像源均不可用", + details, + rotationErr, + ) + } + return rotationErr +} + +func planHasOfficialSource(plan mirror.Plan) bool { + for _, source := range plan.Sources() { + if source.Official() { + return true + } + } + return false +} + +func notifyMirrorAttempt( + ctx context.Context, + notify MirrorAttemptFunc, + attempt MirrorAttempt, +) error { + if notify == nil { + return nil + } + return notify(ctx, attempt) +} + +func mirrorSyncArguments(projectDir, pythonVersion string) []string { + return []string{ + "sync", + "--project", + projectDir, + "--python", + pythonVersion, + "--frozen", + "--no-default-groups", + "--no-install-workspace", + } +} + +func lockedSyncArguments(projectDir, pythonVersion string) []string { + return []string{ + "sync", + "--project", + projectDir, + "--python", + pythonVersion, + "--locked", + "--no-default-groups", + "--no-install-workspace", + } +} + +func stagingCleanupContext(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.WithoutCancel(ctx), stagingCleanupTimeout) +} + +// writeStagingProject 在受管临时目录里写入 pyproject.toml 与改写后的锁副本。 +// +// --no-install-workspace 排除了根项目本身,因此临时项目只需要这两个文件, +// 不需要 README、LICENSE 或任何源码。 +func writeStagingProject( + ctx context.Context, + layout *config.Layout, + stagingDir string, + projectFile string, + lock string, +) (returnErr error) { + if err := ensureManagedDirectory(ctx, layout, filepath.Dir(stagingDir)); err != nil { + return err + } + lease, err := filesystem.PrepareManagedDirectory(ctx, layout, stagingDir) + if err != nil { + return err + } + defer func() { + if closeErr := lease.Close(); closeErr != nil { + returnErr = errors.Join(returnErr, closeErr) + } + }() + if err := writeStagingFile(stagingDir, "pyproject.toml", projectFile); err != nil { + return err + } + return writeStagingFile(stagingDir, "uv.lock", lock) +} + +func writeStagingFile(stagingDir, name, contents string) (returnErr error) { + info, err := os.Lstat(stagingDir) + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("dependency sync staging project is not a regular directory") + } + file, err := os.OpenFile( + filepath.Join(stagingDir, name), + os.O_WRONLY|os.O_CREATE|os.O_EXCL, + 0o600, + ) + if err != nil { + return err + } + defer func() { + if closeErr := file.Close(); closeErr != nil { + returnErr = errors.Join(returnErr, closeErr) + } + }() + _, err = io.WriteString(file, contents) + return err +} + +// filesystemDependencyRemover 用受控删除移除依赖同步的临时项目目录。 +type filesystemDependencyRemover struct{ layout *config.Layout } + +func (r filesystemDependencyRemover) RemoveTree( + ctx context.Context, + request filesystem.DeleteRequest, +) (filesystem.DeleteResult, error) { + if r.layout == nil { + return filesystem.DeleteResult{}, errors.New("dependency staging remover layout is invalid") + } + logger, err := logging.New(ctx, r.layout, io.Discard, "dependencies-sync", request.OperationID) + if err != nil { + return filesystem.DeleteResult{}, err + } + operator, err := filesystem.New(ctx, r.layout, deletionLogger{logger: logger}) + if err != nil { + return filesystem.DeleteResult{}, errors.Join(err, logger.Close()) + } + result, removeErr := operator.RemoveTree(ctx, request) + return result, errors.Join(removeErr, logger.Close()) +} diff --git a/internal/uv/dependencies_mirror_test.go b/internal/uv/dependencies_mirror_test.go new file mode 100644 index 0000000..72b762a --- /dev/null +++ b/internal/uv/dependencies_mirror_test.go @@ -0,0 +1,362 @@ +package uv + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/config" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/filesystem" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" +) + +// mirrorTestLock 是带两处官方前缀的锁夹具,便于断言改写确实发生。 +const mirrorTestLock = `version = 1 + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] +` + +const mirrorTestPyProject = "[project]\nname = \"auto-mas\"\nrequires-python = \">=3.12,<3.13\"\n" + +type mirrorSyncFixture struct { + layout *config.Layout + runner *fakeDependenciesRunner + service *DependenciesService + attempts []MirrorAttempt +} + +func newMirrorSyncFixture(t *testing.T) *mirrorSyncFixture { + t.Helper() + root := t.TempDir() + layout, err := config.NewLayout(root, filepath.Dir(root)) + if err != nil { + t.Fatalf("NewLayout() error = %v", err) + } + if err := os.MkdirAll(layout.RepoDir(), 0o700); err != nil { + t.Fatalf("MkdirAll(repo) error = %v", err) + } + if err := os.WriteFile(layout.UVLockFile(), []byte(mirrorTestLock), 0o600); err != nil { + t.Fatalf("WriteFile(uv.lock) error = %v", err) + } + if err := os.WriteFile(layout.PyProjectFile(), []byte(mirrorTestPyProject), 0o600); err != nil { + t.Fatalf("WriteFile(pyproject.toml) error = %v", err) + } + fixture := &mirrorSyncFixture{layout: layout, runner: &fakeDependenciesRunner{}} + service, err := NewDependenciesService( + layout, + fixture.runner, + &fakeTreeRemover{}, + WithDependenciesStagingRemover(managedTreeRemover{layout: layout}), + ) + if err != nil { + t.Fatalf("NewDependenciesService() error = %v", err) + } + fixture.service = service + return fixture +} + +func (f *mirrorSyncFixture) request(t *testing.T, spec mirror.PolicySpec) DependenciesRequest { + t.Helper() + if spec.Preferred == nil { + spec.Preferred = map[mirror.Kind]string{} + } + policy, err := mirror.NewPolicy(spec) + if err != nil { + t.Fatalf("NewPolicy() error = %v", err) + } + request := dependencyTestRequest(f.layout) + request.MirrorPolicy = policy + request.Attempt = func(_ context.Context, attempt MirrorAttempt) error { + f.attempts = append(f.attempts, attempt) + return nil + } + return request +} + +// stagingDir 返回本次操作的临时项目目录路径。 +func (f *mirrorSyncFixture) stagingDir(t *testing.T) string { + t.Helper() + path, err := f.layout.DependencySyncDir(dependencyTestRequest(f.layout).OperationID) + if err != nil { + t.Fatalf("DependencySyncDir() error = %v", err) + } + return path +} + +func (f *mirrorSyncFixture) assertRepositoryUntouched(t *testing.T) { + t.Helper() + lock, err := os.ReadFile(f.layout.UVLockFile()) + if err != nil { + t.Fatalf("ReadFile(uv.lock) error = %v", err) + } + if string(lock) != mirrorTestLock { + t.Error("repo/uv.lock changed during dependency sync") + } + project, err := os.ReadFile(f.layout.PyProjectFile()) + if err != nil { + t.Fatalf("ReadFile(pyproject.toml) error = %v", err) + } + if string(project) != mirrorTestPyProject { + t.Error("repo/pyproject.toml changed during dependency sync") + } +} + +func (f *mirrorSyncFixture) assertNoStagingLeftovers(t *testing.T) { + t.Helper() + staging := f.stagingDir(t) + if _, err := os.Lstat(staging); !errors.Is(err, os.ErrNotExist) { + t.Errorf("staging directory %q stat error = %v, want not exist", staging, err) + } +} + +// managedTreeRemover 用真实受控删除移除临时项目目录,使测试能断言目录确已消失。 +type managedTreeRemover struct{ layout *config.Layout } + +func (r managedTreeRemover) RemoveTree( + ctx context.Context, + request filesystem.DeleteRequest, +) (filesystem.DeleteResult, error) { + operator, err := filesystem.New(ctx, r.layout, silentDeleteAuditor{}) + if err != nil { + return filesystem.DeleteResult{}, err + } + return operator.RemoveTree(ctx, request) +} + +type silentDeleteAuditor struct{} + +func (silentDeleteAuditor) RecordDeletion(context.Context, filesystem.DeleteAuditRecord) error { + return nil +} + +func mirrorSyncArgsFor(projectDir string) []string { + return []string{ + "sync", "--project", projectDir, "--python", "3.12.10", + "--frozen", "--no-default-groups", "--no-install-workspace", + } +} + +func lockedSyncArgsFor(projectDir string) []string { + return []string{ + "sync", "--project", projectDir, "--python", "3.12.10", + "--locked", "--no-default-groups", "--no-install-workspace", + } +} + +func TestDependencies_SyncRotatesToTheNextMirrorOnFailure(t *testing.T) { + fixture := newMirrorSyncFixture(t) + staging := fixture.stagingDir(t) + var observedLocks []string + fixture.runner.onRun = func(args []string, _ RunOptions) { + if len(args) < 3 || args[1] != "--project" || args[2] != staging { + return + } + lock, err := os.ReadFile(filepath.Join(staging, "uv.lock")) + if err != nil { + t.Errorf("ReadFile(staging uv.lock) error = %v", err) + return + } + project, err := os.ReadFile(filepath.Join(staging, "pyproject.toml")) + if err != nil { + t.Errorf("ReadFile(staging pyproject.toml) error = %v", err) + return + } + if string(project) != mirrorTestPyProject { + t.Errorf("staging pyproject.toml = %q, want the repository copy", project) + } + observedLocks = append(observedLocks, string(lock)) + } + fixture.runner.responses = []fakeRunnerResponse{ + {}, + {result: UVResult{ExitCode: 1}, err: errors.New("aliyun is unreachable")}, + {}, + } + + result, err := fixture.service.Sync(t.Context(), fixture.request(t, mirror.PolicySpec{})) + if err != nil { + t.Fatalf("Sync() error = %v", err) + } + if result.Source != "tsinghua" || result.SourceKind != mirror.KindPackageIndex.String() { + t.Fatalf("result source = %q/%q, want tsinghua/package-index", result.Source, result.SourceKind) + } + if result.AttemptCount != 2 || !result.LockRewritten || !result.Synchronized { + t.Fatalf("result = %#v, want 2 attempts with a rewritten lock", result) + } + if got, want := len(fixture.runner.calls), 3; got != want { + t.Fatalf("runner calls = %d, want %d", got, want) + } + for index := 1; index <= 2; index++ { + if got := fixture.runner.calls[index].args; !reflect.DeepEqual(got, mirrorSyncArgsFor(staging)) { + t.Fatalf("sync args[%d] = %#v, want %#v", index, got, mirrorSyncArgsFor(staging)) + } + } + if len(observedLocks) != 2 { + t.Fatalf("observed staging locks = %d, want 2", len(observedLocks)) + } + wants := []string{"https://mirrors.aliyun.com/pypi/", "https://pypi.tuna.tsinghua.edu.cn/"} + for index, want := range wants { + if !containsAll(observedLocks[index], want+"simple", want+"packages/") { + t.Errorf("staging lock[%d] does not use %s: %q", index, want, observedLocks[index]) + } + if containsAll(observedLocks[index], "https://pypi.org/simple") { + t.Errorf("staging lock[%d] still contains the official index", index) + } + } + wantAttempts := []MirrorAttempt{ + {SourceKind: "package-index", Source: "aliyun", SourceTry: 1, GlobalTry: 1}, + {SourceKind: "package-index", Source: "tsinghua", SourceTry: 1, GlobalTry: 2}, + } + if !reflect.DeepEqual(fixture.attempts, wantAttempts) { + t.Errorf("attempts = %#v, want %#v", fixture.attempts, wantAttempts) + } + fixture.assertRepositoryUntouched(t) + fixture.assertNoStagingLeftovers(t) +} + +func TestDependencies_SyncFallsBackToTheOriginalLock(t *testing.T) { + fixture := newMirrorSyncFixture(t) + fixture.runner.responses = []fakeRunnerResponse{ + {}, + {result: UVResult{ExitCode: 1}, err: errors.New("aliyun failed")}, + {result: UVResult{ExitCode: 1}, err: errors.New("tsinghua failed")}, + {result: UVResult{ExitCode: 1}, err: errors.New("ustc failed")}, + {}, + } + + result, err := fixture.service.Sync(t.Context(), fixture.request(t, mirror.PolicySpec{})) + if err != nil { + t.Fatalf("Sync() error = %v", err) + } + if result.Source != "pypi" || result.AttemptCount != 4 || result.LockRewritten { + t.Fatalf("result = %#v, want the official source without a rewritten lock", result) + } + last := fixture.runner.calls[len(fixture.runner.calls)-1] + if got := last.args; !reflect.DeepEqual(got, lockedSyncArgsFor(fixture.layout.RepoDir())) { + t.Fatalf("fallback args = %#v, want %#v", got, lockedSyncArgsFor(fixture.layout.RepoDir())) + } + if !fixture.attempts[len(fixture.attempts)-1].Fallback { + t.Error("last attempt Fallback = false, want true") + } + fixture.assertRepositoryUntouched(t) + fixture.assertNoStagingLeftovers(t) +} + +func TestDependencies_SyncMirrorOnlyDoesNotFallBack(t *testing.T) { + fixture := newMirrorSyncFixture(t) + fixture.runner.responses = []fakeRunnerResponse{ + {}, + {result: UVResult{ExitCode: 1}, err: errors.New("aliyun failed")}, + {result: UVResult{ExitCode: 1}, err: errors.New("tsinghua failed")}, + {result: UVResult{ExitCode: 1}, err: errors.New("ustc failed")}, + } + + _, err := fixture.service.Sync(t.Context(), fixture.request(t, mirror.PolicySpec{MirrorOnly: true})) + assertPythonCode(t, err, protocol.CodeMirrorExhausted) + if got, want := len(fixture.attempts), 3; got != want { + t.Fatalf("attempts = %d, want %d", got, want) + } + for _, attempt := range fixture.attempts { + if attempt.Fallback || attempt.Source == "pypi" { + t.Fatalf("mirror-only attempted the official source: %#v", attempt) + } + } + fixture.assertRepositoryUntouched(t) + fixture.assertNoStagingLeftovers(t) +} + +func TestDependencies_SyncMapsExhaustedRotationWithFallbackToSyncFailure(t *testing.T) { + fixture := newMirrorSyncFixture(t) + fixture.runner.responses = []fakeRunnerResponse{ + {}, + {result: UVResult{ExitCode: 1}, err: errors.New("aliyun failed")}, + {result: UVResult{ExitCode: 1}, err: errors.New("tsinghua failed")}, + {result: UVResult{ExitCode: 1}, err: errors.New("ustc failed")}, + {result: UVResult{ExitCode: 2}, err: errors.New("pypi failed")}, + } + + _, err := fixture.service.Sync(t.Context(), fixture.request(t, mirror.PolicySpec{})) + assertPythonCode(t, err, protocol.CodeDependencySyncFailed) + if got, want := len(fixture.attempts), 4; got != want { + t.Fatalf("attempts = %d, want %d", got, want) + } + fixture.assertRepositoryUntouched(t) + fixture.assertNoStagingLeftovers(t) +} + +func TestDependencies_SyncOfflineSkipsRewriteEntirely(t *testing.T) { + fixture := newMirrorSyncFixture(t) + fixture.runner.responses = []fakeRunnerResponse{{}, {}} + + result, err := fixture.service.Sync(t.Context(), fixture.request(t, mirror.PolicySpec{Offline: true})) + if err != nil { + t.Fatalf("Sync() error = %v", err) + } + if result.Source != "" || result.LockRewritten || result.AttemptCount != 1 { + t.Fatalf("result = %#v, want a single offline attempt without a source", result) + } + if got, want := len(fixture.runner.calls), 2; got != want { + t.Fatalf("runner calls = %d, want %d", got, want) + } + if got := fixture.runner.calls[1].args; !reflect.DeepEqual(got, lockedSyncArgsFor(fixture.layout.RepoDir())) { + t.Fatalf("offline sync args = %#v, want %#v", got, lockedSyncArgsFor(fixture.layout.RepoDir())) + } + if got := fixture.runner.calls[1].options.Environment[uvOfflineEnv]; got != "1" { + t.Fatalf("offline environment = %q, want 1", got) + } + if len(fixture.attempts) != 0 { + t.Errorf("attempts = %#v, want none for offline", fixture.attempts) + } + fixture.assertRepositoryUntouched(t) + fixture.assertNoStagingLeftovers(t) +} + +func TestDependencies_SyncCancellationRemovesStagingProject(t *testing.T) { + fixture := newMirrorSyncFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + staging := fixture.stagingDir(t) + stagingSeenOnDisk := false + fixture.runner.onRun = func(args []string, _ RunOptions) { + if len(args) < 3 || args[1] != "--project" || args[2] != staging { + return + } + if info, err := os.Lstat(staging); err == nil && info.IsDir() { + stagingSeenOnDisk = true + } + cancel() + } + fixture.runner.responses = []fakeRunnerResponse{ + {}, + {result: UVResult{ExitCode: 1}, err: context.Canceled}, + } + + _, err := fixture.service.Sync(ctx, fixture.request(t, mirror.PolicySpec{})) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Sync() error = %v, want context.Canceled", err) + } + if !stagingSeenOnDisk { + t.Fatal("staging directory was never created; the cancellation path is not exercised") + } + fixture.assertRepositoryUntouched(t) + fixture.assertNoStagingLeftovers(t) +} + +func containsAll(text string, parts ...string) bool { + for _, part := range parts { + if !strings.Contains(text, part) { + return false + } + } + return true +} diff --git a/internal/uv/dependencies_test.go b/internal/uv/dependencies_test.go index c2dba01..4e23300 100644 --- a/internal/uv/dependencies_test.go +++ b/internal/uv/dependencies_test.go @@ -89,39 +89,28 @@ func TestDependencies_CheckDetectsUnsynchronizedEnvironment(t *testing.T) { } func TestDependencies_SyncArguments(t *testing.T) { - root := t.TempDir() - layout, err := config.NewLayout(root, filepath.Dir(root)) - if err != nil { - t.Fatalf("NewLayout() error = %v", err) - } - writeLockfile(t, layout.UVLockFile()) - runner := &fakeDependenciesRunner{ - responses: []fakeRunnerResponse{ - {result: UVResult{}}, - {result: UVResult{}}, - }, - } - service, err := NewDependenciesService(layout, runner, &fakeTreeRemover{}) - if err != nil { - t.Fatalf("NewDependenciesService() error = %v", err) - } - result, err := service.Sync(context.Background(), dependencyTestRequest(layout)) + fixture := newMirrorSyncFixture(t) + fixture.runner.responses = []fakeRunnerResponse{{}, {}} + + result, err := fixture.service.Sync(context.Background(), fixture.request(t, mirror.PolicySpec{})) if err != nil { t.Fatalf("Sync() error = %v", err) } if !result.Synchronized || !result.LockfileChecked { t.Fatalf("Sync() result = %#v, want checked and synchronized", result) } - if got, want := len(runner.calls), 2; got != want { - t.Fatalf("runner calls = %d, want %d", got, want) + if result.Source != "aliyun" || result.AttemptCount != 1 || !result.LockRewritten { + t.Fatalf("Sync() result = %#v, want the first mirror on a rewritten lock", result) } - want := []string{ - "sync", "--project", layout.RepoDir(), "--python", "3.12.10", - "--locked", "--no-default-groups", "--no-install-workspace", + if got, want := len(fixture.runner.calls), 2; got != want { + t.Fatalf("runner calls = %d, want %d", got, want) } - if got := runner.calls[1].args; !reflect.DeepEqual(got, want) { + want := mirrorSyncArgsFor(fixture.stagingDir(t)) + if got := fixture.runner.calls[1].args; !reflect.DeepEqual(got, want) { t.Fatalf("sync args = %#v, want %#v", got, want) } + fixture.assertRepositoryUntouched(t) + fixture.assertNoStagingLeftovers(t) } func TestDependencies_LockfileCheckPreservesLockSources(t *testing.T) { @@ -142,17 +131,9 @@ func TestDependencies_LockfileCheckPreservesLockSources(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - root := t.TempDir() - layout, err := config.NewLayout(root, filepath.Dir(root)) - if err != nil { - t.Fatalf("NewLayout() error = %v", err) - } - writeLockfile(t, layout.UVLockFile()) - runner := &fakeDependenciesRunner{} - service, err := NewDependenciesService(layout, runner, &fakeTreeRemover{}) - if err != nil { - t.Fatalf("NewDependenciesService() error = %v", err) - } + fixture := newMirrorSyncFixture(t) + layout := fixture.layout + runner := fixture.runner policy, err := mirror.NewPolicy(test.policySpec) if err != nil { t.Fatalf("NewPolicy() error = %v", err) @@ -160,7 +141,7 @@ func TestDependencies_LockfileCheckPreservesLockSources(t *testing.T) { request := dependencyTestRequest(layout) request.MirrorPolicy = policy - if _, err := service.Sync(t.Context(), request); err != nil { + if _, err := fixture.service.Sync(t.Context(), request); err != nil { t.Fatalf("Sync() error = %v", err) } if got, want := len(runner.calls), 2; got != want { @@ -181,9 +162,10 @@ func TestDependencies_LockfileCheckPreservesLockSources(t *testing.T) { } syncCall := runner.calls[1] - wantSyncArgs := []string{ - "sync", "--project", layout.RepoDir(), "--python", "3.12.10", - "--locked", "--no-default-groups", "--no-install-workspace", + // 在线时同步跑在改写后的锁副本上,离线完全不改写、沿用原锁。 + wantSyncArgs := mirrorSyncArgsFor(fixture.stagingDir(t)) + if test.wantOffline { + wantSyncArgs = lockedSyncArgsFor(layout.RepoDir()) } if got := syncCall.args; !reflect.DeepEqual(got, wantSyncArgs) { t.Fatalf("sync args = %#v, want %#v", got, wantSyncArgs) @@ -240,26 +222,17 @@ func TestDependencies_PackageIndexOverrideRejected(t *testing.T) { } func TestDependencies_OnlineSyncFailureMapsToDependencySyncFailed(t *testing.T) { - root := t.TempDir() - layout, err := config.NewLayout(root, filepath.Dir(root)) - if err != nil { - t.Fatalf("NewLayout() error = %v", err) - } - writeLockfile(t, layout.UVLockFile()) - runner := &fakeDependenciesRunner{responses: []fakeRunnerResponse{ - {}, - {result: UVResult{ExitCode: 1}, err: errors.New("download failed")}, - }} - service, err := NewDependenciesService(layout, runner, &fakeTreeRemover{}) - if err != nil { - t.Fatalf("NewDependenciesService() error = %v", err) - } + fixture := newMirrorSyncFixture(t) + failure := fakeRunnerResponse{result: UVResult{ExitCode: 1}, err: errors.New("download failed")} + fixture.runner.responses = []fakeRunnerResponse{{}, failure, failure, failure, failure} - _, err = service.Sync(t.Context(), dependencyTestRequest(layout)) + _, err := fixture.service.Sync(t.Context(), fixture.request(t, mirror.PolicySpec{})) assertPythonCode(t, err, protocol.CodeDependencySyncFailed) - if got, want := len(runner.calls), 2; got != want { + // 一次锁检查 + 三个镜像 + 一次原锁回退。 + if got, want := len(fixture.runner.calls), 5; got != want { t.Fatalf("runner calls = %d, want %d", got, want) } + fixture.assertNoStagingLeftovers(t) } func TestDependencies_OfflineFailureMapsToNetworkUnavailable(t *testing.T) { @@ -343,6 +316,8 @@ type fakeDependenciesCall struct { type fakeDependenciesRunner struct { responses []fakeRunnerResponse calls []fakeDependenciesCall + // onRun 在返回预置响应前观察一次调用,用于断言临时项目目录的即时状态。 + onRun func(args []string, options RunOptions) } func (r *fakeDependenciesRunner) Run(_ context.Context, args []string, options RunOptions) (UVResult, error) { @@ -350,6 +325,9 @@ func (r *fakeDependenciesRunner) Run(_ context.Context, args []string, options R args: append([]string(nil), args...), options: options, }) + if r.onRun != nil { + r.onRun(append([]string(nil), args...), options) + } if len(r.responses) == 0 { return UVResult{}, nil } From 81029eaa5187e6c5cd0ea1919f16bade038314d4 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 22:05:46 +0200 Subject: [PATCH 16/57] =?UTF-8?q?feat:=20=E6=98=BE=E5=BC=8F=E5=8C=85?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=E9=A6=96=E9=80=89=E6=94=B9=E4=B8=BA=E6=8E=92?= =?UTF-8?q?=E5=9C=A8=E8=BD=AE=E6=8D=A2=E6=9C=80=E5=89=8D=20(T13.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 C10 的 2026-09-01 修订删除三处 INVALID_ARGUMENT 拒绝(uv validateRequest、 cli rejectPackageIndexOverride 及 bootstrap/repair 调用)。不存在的 key 仍由 mirror.BuildPlan 拒绝,语义不变。 Co-Authored-By: Claude Opus 5 --- internal/cli/bootstrap.go | 5 --- internal/cli/errors.go | 26 ----------- internal/cli/m5_test.go | 56 +++++++++--------------- internal/cli/repair.go | 4 -- internal/uv/dependencies.go | 12 ++---- internal/uv/dependencies_test.go | 74 +++++++++++++++++++++----------- 6 files changed, 73 insertions(+), 104 deletions(-) diff --git a/internal/cli/bootstrap.go b/internal/cli/bootstrap.go index 97b2d3f..9dcad0f 100644 --- a/internal/cli/bootstrap.go +++ b/internal/cli/bootstrap.go @@ -62,11 +62,6 @@ func runBootstrap( cause: err, } } - // 必须在建状态库、抢 mutation 锁、下载 uv 之前拒绝,否则一个参数错误要等到 - // 依赖同步阶段才报出来,而那时 uv、仓库与 Python 都已落盘。 - if err := rejectPackageIndexOverride(deps.global.mirrorPolicy, protocol.StageBootstrap); err != nil { - return sessionSuccess{}, err - } store, err := deps.options.environmentStateStoreFactory(ctx, deps.global.layout, deps.options.clock) if err != nil { return sessionSuccess{}, stateStoreError(protocol.StageBootstrap, err) diff --git a/internal/cli/errors.go b/internal/cli/errors.go index 07c6715..82abf14 100644 --- a/internal/cli/errors.go +++ b/internal/cli/errors.go @@ -1,10 +1,8 @@ package cli import ( - "errors" "fmt" - "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" ) @@ -31,30 +29,6 @@ type committedOperationError interface { Committed() bool } -// rejectPackageIndexOverride 在产生任何副作用之前拒绝显式 package-index 首选。 -// -// 架构设计要求 dependencies check/sync/rebuild、bootstrap 和顶层 repair 对显式 -// `--mirror package-index=<键>` 返回 INVALID_ARGUMENT,且必须在调用 uv 之前失败关闭。 -// uv 侧的 validateRequest 也有同样的检查,但那里已经在「uv 已下载、仓库已同步、 -// Python 已安装」之后,一个纯参数错误要付出数百 MB 下载的代价。 -// 这里复用完全一致的错误码、消息与 details,因此谁先命中对调用方都是同一个错误。 -func rejectPackageIndexOverride(policy mirror.Policy, stage protocol.Stage) error { - source, ok := policy.Preferred(mirror.KindPackageIndex) - if !ok { - return nil - } - return &commandError{ - code: protocol.CodeInvalidArgument, - stage: stage, - message: "锁定依赖不支持覆盖包索引", - details: map[string]any{ - "sourceKind": mirror.KindPackageIndex.String(), - "source": source, - }, - cause: errors.New("package index override conflicts with locked sources"), - } -} - // commandError 是 cli 内部使用的通用命令错误,同时承载协议映射字段。 type commandError struct { code protocol.Code diff --git a/internal/cli/m5_test.go b/internal/cli/m5_test.go index e42828a..034cd02 100644 --- a/internal/cli/m5_test.go +++ b/internal/cli/m5_test.go @@ -164,21 +164,19 @@ func TestBootstrapCommand_OrderAndStates(t *testing.T) { if got, ok := environment.pythonRequest.MirrorPolicy.Preferred(mirror.KindPython); !ok || got != "github" { t.Fatalf("Python request preference = %q/%t, want github/true", got, ok) } - // 原本这里用 package-index=pypi 断言镜像策略透传,但架构设计要求 bootstrap - // 对显式 package-index 首选直接返回 INVALID_ARGUMENT,该用例只是因为依赖服务 - // 被替换成假实现才没撞上。改用 git 首选验证同一条透传路径, - // 拒绝行为由 TestM5CommandsRejectPackageIndexOverrideBeforeSideEffects 覆盖。 + // 用 git 首选验证依赖请求的策略透传路径;package-index 首选的透传与「不再拒绝」 + // 由 TestM5CommandsAcceptPackageIndexPreference 覆盖。 if got, ok := environment.dependencyRequest.MirrorPolicy.Preferred(mirror.KindGit); !ok || got != "cnb" { t.Fatalf("dependency request preference = %q/%t, want cnb/true", got, ok) } } -// TestM5CommandsRejectPackageIndexOverrideBeforeSideEffects 锁定架构设计的要求: -// bootstrap 与顶层 repair 对显式 --mirror package-index=<键> 返回 INVALID_ARGUMENT, -// 且必须在调用 uv 之前失败关闭。断言重点是「一次副作用都没发生」—— -// 既没抢 mutation 锁,也没开状态库、没下载 uv、没同步仓库。 -// 修复前该拒绝发生在 uv/仓库/Python 全部落盘之后的依赖同步阶段。 -func TestM5CommandsRejectPackageIndexOverrideBeforeSideEffects(t *testing.T) { +// TestM5CommandsAcceptPackageIndexPreference 锁定增补 1 C10 的 2026-09-01 修订: +// bootstrap 与顶层 repair 不再对显式 --mirror package-index=<键> 返回 INVALID_ARGUMENT, +// 而是把它原样透传给依赖服务,由后者排在镜像尝试顺序最前。 +// 修订理由:改写锁副本不是覆盖索引,--locked 校验仍对原锁做,因此显式指定与自动轮换 +// 本就是同一条路径,只差顺序;保留拒绝会让用户想优先用某个可达镜像时反被判参数错误。 +func TestM5CommandsAcceptPackageIndexPreference(t *testing.T) { tests := []struct { name string args []string @@ -199,7 +197,7 @@ func TestM5CommandsRejectPackageIndexOverrideBeforeSideEffects(t *testing.T) { []string{ "--app-root", root, "--output", "ndjson", - "--mirror", "package-index=pypi", + "--mirror", "package-index=aliyun", }, test.args..., ) @@ -220,38 +218,24 @@ func TestM5CommandsRejectPackageIndexOverrideBeforeSideEffects(t *testing.T) { return log, nil }), ) - if code != int(protocol.ExitCodeInvalidArgument) { - t.Fatalf("exit code = %d, want %d; stderr=%q", code, protocol.ExitCodeInvalidArgument, stderr.String()) + if code != int(protocol.ExitCodeSuccess) { + t.Fatalf("exit code = %d, want %d; stderr=%q", code, protocol.ExitCodeSuccess, stderr.String()) } - if len(log.calls) != 0 { - t.Fatalf("calls = %#v, want no side effect before rejection", log.calls) + if environment.syncCalls != 1 { + t.Fatalf("dependency sync calls = %d, want 1", environment.syncCalls) } - if len(store.writes) != 0 { - t.Fatalf("state writes = %#v, want none", store.writes) - } - if environment.uvRepairCalls != 0 || environment.pythonPrepareCalls != 0 || environment.syncCalls != 0 { - t.Fatalf( - "environment calls = uvRepair %d python %d sync %d, want all zero", - environment.uvRepairCalls, - environment.pythonPrepareCalls, - environment.syncCalls, - ) + got, ok := environment.dependencyRequest.MirrorPolicy.Preferred(mirror.KindPackageIndex) + if !ok || got != "aliyun" { + t.Fatalf("dependency request package-index preference = %q/%t, want aliyun/true", got, ok) } events := parseNDJSON(t, stdout.String()) - var errorEvent, resultEvent parsedEvent for _, event := range events { - switch eventType(event) { - case string(protocol.TypeError): - errorEvent = event - case string(protocol.TypeResult): - resultEvent = event + if eventType(event) == string(protocol.TypeError) { + t.Fatalf("unexpected error event: %#v", event.object) } } - if got := eventString(errorEvent, "code"); got != string(protocol.CodeInvalidArgument) { - t.Errorf("error code = %q, want INVALID_ARGUMENT", got) - } - if got := eventString(resultEvent, "code"); got != string(protocol.CodeInvalidArgument) { - t.Errorf("result code = %q, want INVALID_ARGUMENT", got) + if got := eventString(events[len(events)-1], "code"); got != "OK" { + t.Errorf("result code = %q, want OK", got) } }) } diff --git a/internal/cli/repair.go b/internal/cli/repair.go index b706e34..95a6fe5 100644 --- a/internal/cli/repair.go +++ b/internal/cli/repair.go @@ -37,10 +37,6 @@ func runRepair( deps *deps, emitter *protocol.Emitter, ) (success sessionSuccess, returnErr error) { - // repair 也会走 SyncDependencies,同样必须在任何副作用之前拒绝包索引覆盖。 - if err := rejectPackageIndexOverride(deps.global.mirrorPolicy, protocol.StageRepair); err != nil { - return sessionSuccess{}, err - } store, err := deps.options.environmentStateStoreFactory(ctx, deps.global.layout, deps.options.clock) if err != nil { return sessionSuccess{}, stateStoreError(protocol.StageRepair, err) diff --git a/internal/uv/dependencies.go b/internal/uv/dependencies.go index a90604b..3c8bfc4 100644 --- a/internal/uv/dependencies.go +++ b/internal/uv/dependencies.go @@ -286,15 +286,9 @@ func (s *DependenciesService) validateRequest( return fmt.Errorf("%s is invalid", name) } } - if source, ok := request.MirrorPolicy.Preferred(mirror.KindPackageIndex); ok { - return newError( - protocol.CodeInvalidArgument, - protocol.StageDependenciesCheck, - "锁定依赖不支持覆盖包索引", - map[string]any{"sourceKind": mirror.KindPackageIndex.String(), "source": source}, - errors.New("package index override conflicts with locked sources"), - ) - } + // 显式 --mirror package-index=<键> 不再是参数错误:改写不是覆盖索引, + // --locked 校验仍对原锁做,因此显式指定只是把该源排在尝试顺序最前 + // (见增补 1 C10 的 2026-09-01 修订)。不存在的 key 仍由 BuildPlan 拒绝。 return nil } diff --git a/internal/uv/dependencies_test.go b/internal/uv/dependencies_test.go index 4e23300..bb305c1 100644 --- a/internal/uv/dependencies_test.go +++ b/internal/uv/dependencies_test.go @@ -182,45 +182,71 @@ func TestDependencies_LockfileCheckPreservesLockSources(t *testing.T) { } } -func TestDependencies_PackageIndexOverrideRejected(t *testing.T) { +// TestDependencies_SyncPrefersExplicitSource 锁定 C10 的 2026-09-01 修订: +// 显式 --mirror package-index=<键> 不再是参数错误,而是把该源排在尝试顺序最前, +// 与自动轮换走同一条改写路径。显式指定官方源时第一次就用原锁。 +func TestDependencies_SyncPrefersExplicitSource(t *testing.T) { tests := []struct { - name string - key string + name string + key string + wantRewritten bool }{ - {name: "mirror", key: "tsinghua"}, + {name: "mirror", key: "ustc", wantRewritten: true}, {name: "official", key: "pypi"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - root := t.TempDir() - layout, err := config.NewLayout(root, filepath.Dir(root)) - if err != nil { - t.Fatalf("NewLayout() error = %v", err) - } - writeLockfile(t, layout.UVLockFile()) - runner := &fakeDependenciesRunner{} - service, err := NewDependenciesService(layout, runner, &fakeTreeRemover{}) - if err != nil { - t.Fatalf("NewDependenciesService() error = %v", err) - } - policy, err := mirror.NewPolicy(mirror.PolicySpec{Preferred: map[mirror.Kind]string{ + fixture := newMirrorSyncFixture(t) + fixture.runner.responses = []fakeRunnerResponse{{}, {}} + request := fixture.request(t, mirror.PolicySpec{Preferred: map[mirror.Kind]string{ mirror.KindPackageIndex: test.key, }}) + + result, err := fixture.service.Sync(t.Context(), request) if err != nil { - t.Fatalf("NewPolicy() error = %v", err) + t.Fatalf("Sync() error = %v", err) } - request := dependencyTestRequest(layout) - request.MirrorPolicy = policy - - _, err = service.Sync(t.Context(), request) - assertPythonCode(t, err, protocol.CodeInvalidArgument) - if got := len(runner.calls); got != 0 { - t.Fatalf("runner calls = %d, want 0", got) + if result.Source != test.key || result.AttemptCount != 1 { + t.Fatalf("result = %#v, want %q on the first attempt", result, test.key) + } + if result.LockRewritten != test.wantRewritten { + t.Fatalf("LockRewritten = %t, want %t", result.LockRewritten, test.wantRewritten) + } + if got, want := len(fixture.attempts), 1; got != want { + t.Fatalf("attempts = %d, want %d", got, want) + } + if fixture.attempts[0].Source != test.key { + t.Fatalf("first attempt source = %q, want %q", fixture.attempts[0].Source, test.key) + } + wantArgs := mirrorSyncArgsFor(fixture.stagingDir(t)) + if !test.wantRewritten { + wantArgs = lockedSyncArgsFor(fixture.layout.RepoDir()) } + if got := fixture.runner.calls[1].args; !reflect.DeepEqual(got, wantArgs) { + t.Fatalf("sync args = %#v, want %#v", got, wantArgs) + } + fixture.assertRepositoryUntouched(t) + fixture.assertNoStagingLeftovers(t) }) } } +// TestDependencies_SyncRejectsUnknownSourceKey 证明策略校验本身没有放松: +// 目录里不存在的 key 仍然是参数错误,绝不静默换成别的源。 +func TestDependencies_SyncRejectsUnknownSourceKey(t *testing.T) { + fixture := newMirrorSyncFixture(t) + request := fixture.request(t, mirror.PolicySpec{Preferred: map[mirror.Kind]string{ + mirror.KindPackageIndex: "unknown-mirror", + }}) + + _, err := fixture.service.Sync(t.Context(), request) + assertPythonCode(t, err, protocol.CodeInvalidArgument) + if got, want := len(fixture.runner.calls), 1; got != want { + t.Fatalf("runner calls = %d, want %d (lock check only)", got, want) + } + fixture.assertNoStagingLeftovers(t) +} + func TestDependencies_OnlineSyncFailureMapsToDependencySyncFailed(t *testing.T) { fixture := newMirrorSyncFixture(t) failure := fakeRunnerResponse{result: UVResult{ExitCode: 1}, err: errors.New("download failed")} From f12f070b0ea6e47bc2be212d2cecea56ac0f8c87 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 22:09:18 +0200 Subject: [PATCH 17/57] =?UTF-8?q?feat:=20dependencies=20sync=20=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E9=95=9C=E5=83=8F=E6=BA=90=E4=B8=8E=E5=B0=9D=E8=AF=95?= =?UTF-8?q?=E6=AC=A1=E6=95=B0=20(T13.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit result.details 增加 sourceKind/source/attemptCount/lockRewritten;每次镜像尝试 发一条 dependencies.sync progress 供展示。bootstrap 与 repair 同样报告尝试。 Co-Authored-By: Claude Opus 5 --- internal/cli/bootstrap.go | 1 + internal/cli/dependencies.go | 11 ++- internal/cli/environment_support.go | 19 ++++++ internal/cli/m5_test.go | 102 +++++++++++++++++++++++++++- internal/cli/repair.go | 1 + 5 files changed, 131 insertions(+), 3 deletions(-) diff --git a/internal/cli/bootstrap.go b/internal/cli/bootstrap.go index 9dcad0f..36cd174 100644 --- a/internal/cli/bootstrap.go +++ b/internal/cli/bootstrap.go @@ -319,6 +319,7 @@ func runBootstrap( Commit: revision.Commit(), MirrorPolicy: deps.global.mirrorPolicy, Line: uvLogLine(operationLogger), + Attempt: mirrorAttemptProgress(emitter), }) if err != nil { return sessionSuccess{}, persistM5FailureWithLifecycle(ctx, emitter, store, deps.global.layout, initial, revision, uvExecutable, pythonResult.Spec, operationLogger, machine, protocol.StageDependenciesSync, err) diff --git a/internal/cli/dependencies.go b/internal/cli/dependencies.go index 347dafd..d630e04 100644 --- a/internal/cli/dependencies.go +++ b/internal/cli/dependencies.go @@ -290,7 +290,15 @@ func runMutatingDependencyAction( return sessionSuccess{ message: "主项目依赖同步完成", status: string(protocol.StateReadyToStart), - details: map[string]any{"version": check.Version, "commit": check.Commit, "synchronized": result.Synchronized}, + details: map[string]any{ + "version": check.Version, + "commit": check.Commit, + "synchronized": result.Synchronized, + "sourceKind": result.SourceKind, + "source": result.Source, + "attemptCount": result.AttemptCount, + "lockRewritten": result.LockRewritten, + }, }, nil } @@ -359,6 +367,7 @@ func dependencyRequest( Commit: check.Commit, MirrorPolicy: deps.global.mirrorPolicy, Line: line, + Attempt: mirrorAttemptProgress(emitter), } } diff --git a/internal/cli/environment_support.go b/internal/cli/environment_support.go index 5417e5e..3d2d97b 100644 --- a/internal/cli/environment_support.go +++ b/internal/cli/environment_support.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "os" "path/filepath" "strconv" @@ -646,6 +647,24 @@ func mutationCloseError(cause error) error { } } +// mirrorAttemptProgress 把依赖同步的每次镜像尝试报成一条 progress。 +// +// progress 没有 details 字段,message 只供人类查看;机器可读的源与尝试次数 +// 由 result.details 承载(C10 第 7 条)。log 事件在本协议里专指受管进程输出 +// 转发(能力标识 log.stream),依赖同步没有这种输出,因此不占用它。 +func mirrorAttemptProgress(emitter *protocol.Emitter) uv.MirrorAttemptFunc { + if emitter == nil { + return nil + } + return func(_ context.Context, attempt uv.MirrorAttempt) error { + message := fmt.Sprintf("正在从镜像源 %s 同步锁定依赖", attempt.Source) + if attempt.Fallback { + message = fmt.Sprintf("镜像源均不可用,正在从官方源 %s 按原锁同步依赖", attempt.Source) + } + return emitM5Progress(emitter, protocol.StageDependenciesSync, protocol.ProgressRunning, message) + } +} + func emitM5Progress( emitter *protocol.Emitter, stage protocol.Stage, diff --git a/internal/cli/m5_test.go b/internal/cli/m5_test.go index 034cd02..ae913a3 100644 --- a/internal/cli/m5_test.go +++ b/internal/cli/m5_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "io" "os" "path/filepath" @@ -1081,14 +1082,111 @@ func (s *m5TestEnvironment) CheckPython(context.Context, uv.PythonRequest) (uv.P return uv.PythonCheckResult{Spec: uv.PythonSpec{Version: uv.PythonVersion{Major: 3, Minor: 12, Patch: 10}}}, nil } -func (s *m5TestEnvironment) SyncDependencies(_ context.Context, request uv.DependenciesRequest) (uv.DependenciesResult, error) { +func (s *m5TestEnvironment) SyncDependencies(ctx context.Context, request uv.DependenciesRequest) (uv.DependenciesResult, error) { s.dependencyRequest = request s.syncCalls++ *s.calls = append(*s.calls, "dependencies") + if request.Attempt != nil { + // 模拟一次镜像轮换:首选失败后由次选完成,调用方据此发 progress。 + for index, attempt := range m5TestMirrorAttempts { + if err := request.Attempt(ctx, attempt); err != nil { + return uv.DependenciesResult{}, fmt.Errorf("report attempt %d: %w", index, err) + } + } + } if s.dependencyErr != nil { return uv.DependenciesResult{}, s.dependencyErr } - return uv.DependenciesResult{LockfileChecked: true, Synchronized: true}, nil + return uv.DependenciesResult{ + LockfileChecked: true, + Synchronized: true, + SourceKind: "package-index", + Source: "tsinghua", + AttemptCount: 2, + LockRewritten: true, + }, nil +} + +// m5TestMirrorAttempts 是假依赖服务回放的镜像尝试序列。 +var m5TestMirrorAttempts = []uv.MirrorAttempt{ + {SourceKind: "package-index", Source: "aliyun", SourceTry: 1, GlobalTry: 1}, + {SourceKind: "package-index", Source: "tsinghua", SourceTry: 1, GlobalTry: 2}, +} + +// TestDependenciesSync_ResultReportsMirrorSource 锁定 C10 第 7 条: +// dependencies sync 的 result.details 报告本次实际使用的包索引源与尝试次数, +// 每次尝试另发一条 progress 供人类查看(progress 没有 details,机器只读 result)。 +func TestDependenciesSync_ResultReportsMirrorSource(t *testing.T) { + root := t.TempDir() + log := &m5TestLog{} + environment := &m5TestEnvironment{calls: &log.calls} + workspace := &m5TestWorkspace{calls: &log.calls} + store := &m5TestStateStore{ + calls: &log.calls, + initial: state.EnvironmentState{ + Status: protocol.StateEnvironmentBroken, + LastSuccessful: state.Revision{ + Version: "v5.4.0", + Commit: "0123456789abcdef0123456789abcdef01234567", + }, + }, + } + var stdout, stderr bytes.Buffer + code := Execute( + context.Background(), + []string{"--app-root", root, "--output", "ndjson", "dependencies", "sync"}, + IO{In: strings.NewReader(""), Out: &stdout, Err: &stderr}, + WithCWD(root), + WithEnvironmentFactory(func(*config.Layout) (environmentService, error) { return environment, nil }), + WithWorkspaceFactory(func(*config.Layout) (workspaceService, error) { return workspace, nil }), + WithEnvironmentStateStoreFactory(func(context.Context, *config.Layout, func() time.Time) (environmentStateStore, error) { + return store, nil + }), + WithMutationCoordinatorFactory(func(context.Context, *config.Layout) (gitrepo.MutationCoordinator, error) { + return &m5TestCoordinator{calls: &log.calls}, nil + }), + WithWorkspaceLoggerFactory(func(context.Context, *config.Layout, io.Writer, string, string, func() time.Time) (workspaceLogger, error) { + return log, nil + }), + ) + if code != int(protocol.ExitCodeSuccess) { + t.Fatalf("exit code = %d, want 0; stderr=%q; stdout=%q", code, stderr.String(), stdout.String()) + } + events := parseNDJSON(t, stdout.String()) + result := events[len(events)-1] + details, ok := result.object["details"].(map[string]any) + if !ok { + t.Fatalf("result details = %#v, want object", result.object["details"]) + } + wants := map[string]any{ + "sourceKind": "package-index", + "source": "tsinghua", + "attemptCount": float64(2), + "lockRewritten": true, + } + for key, want := range wants { + if got := details[key]; got != want { + t.Errorf("result details[%q] = %#v, want %#v", key, got, want) + } + } + progressMessages := make([]string, 0, len(events)) + for _, event := range events { + if eventType(event) == string(protocol.TypeProgress) && eventString(event, "stage") == string(protocol.StageDependenciesSync) { + progressMessages = append(progressMessages, eventString(event, "message")) + } + } + for _, attempt := range m5TestMirrorAttempts { + found := false + for _, message := range progressMessages { + if strings.Contains(message, attempt.Source) { + found = true + break + } + } + if !found { + t.Errorf("no dependencies.sync progress mentions source %q; got %#v", attempt.Source, progressMessages) + } + } } func (s *m5TestEnvironment) CheckDependencies(context.Context, uv.DependenciesRequest) (uv.DependenciesResult, error) { diff --git a/internal/cli/repair.go b/internal/cli/repair.go index 95a6fe5..f60e781 100644 --- a/internal/cli/repair.go +++ b/internal/cli/repair.go @@ -240,6 +240,7 @@ func runRepair( Commit: check.Commit, MirrorPolicy: deps.global.mirrorPolicy, Line: uvLogLine(logger), + Attempt: mirrorAttemptProgress(emitter), } if err := advanceM5Transaction(ctx, store, &transaction, protocol.StageDependenciesRebuild); err != nil { return sessionSuccess{}, err From f2047460b34c5639460db6440d14ef8a4f39a92f Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 22:11:11 +0200 Subject: [PATCH 18/57] =?UTF-8?q?test:=20=E8=AE=A9=E5=81=87=20uv=20?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E6=94=B9=E5=86=99=E5=90=8E=E7=9A=84=E4=B8=B4?= =?UTF-8?q?=E6=97=B6=E9=A1=B9=E7=9B=AE=E5=B9=B6=E6=8C=89=E9=94=81=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E6=B3=A8=E5=85=A5=E5=A4=B1=E8=B4=A5=20(T13.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 规则新增 argumentsContain 与 lockContains 两个条件,调用记录新增 projectDir 与 lockIndexPrefixes,动作新增 readyFile/releaseFile 用于精确注入取消。 Co-Authored-By: Claude Opus 5 --- testdata/fakeuv/main.go | 128 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 3 deletions(-) diff --git a/testdata/fakeuv/main.go b/testdata/fakeuv/main.go index 157cb67..9f935e7 100644 --- a/testdata/fakeuv/main.go +++ b/testdata/fakeuv/main.go @@ -7,6 +7,8 @@ import ( "os" "os/exec" "path/filepath" + "sort" + "strings" "time" ) @@ -18,9 +20,37 @@ type replayConfig struct { type replayRule struct { replayAction ArgumentsPrefix []string `json:"argumentsPrefix"` + // ArgumentsContain 要求这些参数全部出现,用于匹配 --frozen 这类与位置无关的开关。 + ArgumentsContain []string `json:"argumentsContain"` + // LockContains 要求 --project 指向目录里的 uv.lock 含该子串, + // 用于按「改写成了哪个镜像」注入失败。 + LockContains string `json:"lockContains"` +} + +// matches 报告规则的全部条件是否都满足;没有任何条件的规则不匹配。 +func (r replayRule) matches(arguments []string, lock string) bool { + if len(r.ArgumentsPrefix) == 0 && len(r.ArgumentsContain) == 0 && r.LockContains == "" { + return false + } + if len(r.ArgumentsPrefix) > 0 && !hasArgumentsPrefix(arguments, r.ArgumentsPrefix) { + return false + } + for _, needle := range r.ArgumentsContain { + if !containsArgument(arguments, needle) { + return false + } + } + if r.LockContains != "" && !strings.Contains(lock, r.LockContains) { + return false + } + return true } type replayAction struct { + // ReadyFile 在动作开始时写出,ReleaseFile 出现前进程不返回; + // 两者配合可以让测试在假 uv 正在运行时精确注入取消。 + ReadyFile string `json:"readyFile"` + ReleaseFile string `json:"releaseFile"` ExitCode int `json:"exitCode"` Stdout []string `json:"stdout"` Stderr []string `json:"stderr"` @@ -43,6 +73,10 @@ type replayEvent struct { type invocationRecord struct { Arguments []string `json:"arguments"` Environment map[string]string `json:"environment"` + // ProjectDir 是 --project 的取值,LockIndexPrefixes 是该目录 uv.lock 里出现的 + // 索引与 artifact 前缀(去重排序),测试据此断言实际用的是哪个镜像源。 + ProjectDir string `json:"projectDir,omitempty"` + LockIndexPrefixes []string `json:"lockIndexPrefixes,omitempty"` } func main() { @@ -58,9 +92,12 @@ func main() { os.Exit(91) } } + arguments := os.Args[1:] + projectDir := argumentValue(arguments, "--project") + lock := readProjectLock(projectDir) action := config.replayAction for _, rule := range config.Rules { - if hasArgumentsPrefix(os.Args[1:], rule.ArgumentsPrefix) { + if rule.matches(arguments, lock) { action = rule.replayAction break } @@ -74,8 +111,10 @@ func main() { } } record := invocationRecord{ - Arguments: append([]string(nil), os.Args[1:]...), - Environment: environment, + Arguments: append([]string(nil), arguments...), + Environment: environment, + ProjectDir: projectDir, + LockIndexPrefixes: lockIndexPrefixes(lock), } file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { @@ -127,6 +166,18 @@ func main() { } os.Exit(0) } + if action.ReadyFile != "" { + if err := writeSignalFile(action.ReadyFile, []byte("ready\n")); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(96) + } + } + if action.ReleaseFile != "" { + if err := waitForSignalFile(action.ReleaseFile); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(96) + } + } if action.DelayMS > 0 { time.Sleep(time.Duration(action.DelayMS) * time.Millisecond) } @@ -153,6 +204,77 @@ func main() { os.Exit(action.ExitCode) } +// argumentValue 返回 name 后面紧跟的那个参数值。 +func argumentValue(arguments []string, name string) string { + for index := 0; index+1 < len(arguments); index++ { + if arguments[index] == name { + return arguments[index+1] + } + } + return "" +} + +func containsArgument(arguments []string, needle string) bool { + for _, argument := range arguments { + if argument == needle { + return true + } + } + return false +} + +// readProjectLock 读取 --project 目录里的 uv.lock;缺失时返回空串。 +func readProjectLock(projectDir string) string { + if projectDir == "" { + return "" + } + payload, err := os.ReadFile(filepath.Join(projectDir, "uv.lock")) + if err != nil { + return "" + } + return string(payload) +} + +// lockIndexPrefixes 提取锁文本里出现的索引与 artifact 前缀,去重后排序。 +func lockIndexPrefixes(lock string) []string { + unique := make(map[string]struct{}) + rest := lock + for { + start := strings.Index(rest, "https://") + if start < 0 { + break + } + rest = rest[start:] + end := strings.IndexAny(rest, "\"' \t\r\n,") + token := rest + if end >= 0 { + token = rest[:end] + rest = rest[end:] + } + if prefix, ok := indexPrefixOf(token); ok { + unique[prefix] = struct{}{} + } + if end < 0 { + break + } + } + prefixes := make([]string, 0, len(unique)) + for prefix := range unique { + prefixes = append(prefixes, prefix) + } + sort.Strings(prefixes) + return prefixes +} + +func indexPrefixOf(url string) (string, bool) { + for _, marker := range []string{"/simple", "/packages/"} { + if index := strings.Index(url, marker); index >= 0 { + return url[:index+len(marker)], true + } + } + return "", false +} + func hasArgumentsPrefix(arguments, prefix []string) bool { if len(prefix) == 0 || len(arguments) < len(prefix) { return false From e5ef0abf8d946a5f7249509956f746172eba97cd Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 22:13:09 +0200 Subject: [PATCH 19/57] =?UTF-8?q?fix(backend):=20=E4=BC=98=E9=9B=85?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E4=B8=8D=E5=86=8D=E8=AF=AF=E6=8A=A5=20BACKEN?= =?UTF-8?q?D=5FFORCE=5FTERMINATED=20(T13.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机联调发现后端 0.4s 内自行退出(exit 0、无残留)却仍收到 BACKEND_FORCE_TERMINATED,并进入 result.details.warnings。 根因在 windowsJob.snapshot:它先查 Job 的 pid 列表、再查 Toolhelp32 进程表,两次查询不是原子的,刚退出的根进程会出现在前者而不在后者, 于是整个快照报 "process entry is missing";cleanupProcess 把该错误 当成「有残留后代」直接判 forced。探针在本机 20/20 复现,是必然误报。 snapshot 现在把「成员在两次查询之间退出」视为正常过渡态跳过(pid 无效 导致的 OpenProcess 失败同理),其余错误仍上报;cleanupProcess 在 Exited 之后再排除根进程自身,只有真正的存活后代才判 forced。 真有残留后代时仍强制回收并发出警告,由对照组 E2E 锁定。 --- internal/backend/e2e_windows_test.go | 114 ++++++++++++++++++++--- internal/backend/supervisor.go | 18 +++- internal/process/job_windows.go | 11 ++- internal/process/managed_windows_test.go | 34 +++++++ testdata/fakebackend/main.go | 40 ++++---- 5 files changed, 184 insertions(+), 33 deletions(-) diff --git a/internal/backend/e2e_windows_test.go b/internal/backend/e2e_windows_test.go index ece0c71..3bf0d6b 100644 --- a/internal/backend/e2e_windows_test.go +++ b/internal/backend/e2e_windows_test.go @@ -39,18 +39,19 @@ const ( ) type backendE2EConfig struct { - ListenAddress string `json:"listenAddress,omitempty"` - PIDFile string `json:"pidFile,omitempty"` - WorkingDirFile string `json:"workingDirFile,omitempty"` - GrandchildPIDFile string `json:"grandchildPidFile,omitempty"` - SpawnGrandchild bool `json:"spawnGrandchild,omitempty"` - GrandchildLifetimeMS int `json:"grandchildLifetimeMs,omitempty"` - LeaveGrandchildOnCrash bool `json:"leaveGrandchildOnCrash,omitempty"` - Health []backendE2EHealth `json:"health,omitempty"` - CloseStatus int `json:"closeStatus,omitempty"` - CrashAfterHealthRequests int `json:"crashAfterHealthRequests,omitempty"` - CrashExitCode int `json:"crashExitCode,omitempty"` - Events []backendE2EEvent `json:"events,omitempty"` + ListenAddress string `json:"listenAddress,omitempty"` + PIDFile string `json:"pidFile,omitempty"` + WorkingDirFile string `json:"workingDirFile,omitempty"` + GrandchildPIDFile string `json:"grandchildPidFile,omitempty"` + SpawnGrandchild bool `json:"spawnGrandchild,omitempty"` + GrandchildLifetimeMS int `json:"grandchildLifetimeMs,omitempty"` + LeaveGrandchildOnCrash bool `json:"leaveGrandchildOnCrash,omitempty"` + LeaveGrandchildOnShutdown bool `json:"leaveGrandchildOnShutdown,omitempty"` + Health []backendE2EHealth `json:"health,omitempty"` + CloseStatus int `json:"closeStatus,omitempty"` + CrashAfterHealthRequests int `json:"crashAfterHealthRequests,omitempty"` + CrashExitCode int `json:"crashExitCode,omitempty"` + Events []backendE2EEvent `json:"events,omitempty"` } type backendE2EHealth struct { @@ -291,6 +292,34 @@ func (f *backendE2EFixture) captureGeneration(t *testing.T, running protocol.Sta return generation } +// captureGenerationWithoutGrandchild 与 captureGeneration 相同,但用于没有配置 +// 孙进程的场景:grandchild.pid 永远不会出现,等它只会白白超时。 +func (f *backendE2EFixture) captureGenerationWithoutGrandchild(t *testing.T, running protocol.StateEvent) backendE2EPIDGeneration { + t.Helper() + rootPID, ok := e2EUint32(running.Details["pid"]) + if !ok { + t.Fatalf("running details pid = %#v, want uint32", running.Details["pid"]) + } + if rootFilePID := waitE2EPIDFile(t, f.rootPID); rootFilePID != rootPID { + t.Fatalf("uv root PID file = %d, running state pid = %d", rootFilePID, rootPID) + } + generation := backendE2EPIDGeneration{ + rootPID: rootPID, + pythonPID: waitE2EPIDFile(t, f.config.PIDFile), + } + for _, pid := range []uint32{generation.rootPID, generation.pythonPID} { + handle, err := openE2ESyncHandle(pid) + if err != nil { + if closeErr := generation.close(); closeErr != nil { + err = errors.Join(err, closeErr) + } + t.Fatalf("OpenProcess(%d) for active generation: %v", pid, err) + } + generation.handles = append(generation.handles, handle) + } + return generation +} + func (g *backendE2EPIDGeneration) wait(t *testing.T) { t.Helper() if g == nil { @@ -555,6 +584,12 @@ func TestBackendE2E_LifecycleSpawnReadyShutdown(t *testing.T) { } assertE2EDevelopmentUVEnvironment(t, fixture) assertE2EDevelopmentWorkingDir(t, fixture) + // 后代随父进程一起退出,属于优雅路径,不得出现强制回收警告。 + for _, warning := range fixture.emitter.warningsSnapshot() { + if warning.Code == string(protocol.CodeBackendForceTerminated) { + t.Fatalf("graceful lifecycle emitted %s: details=%#v", warning.Code, warning.Details) + } + } assertE2EStateSequence(t, fixture.emitter.statesSnapshot(), protocol.StateStartingBackend, protocol.StateRunning, protocol.StateStoppingBackend, protocol.StateStopped) assertE2EPersistentLog(t, running, "lifecycle") assertE2ETimelineBefore(t, fixture.emitter, "state:"+string(protocol.StateStartingBackend), "log:lifecycle ") @@ -649,6 +684,61 @@ func assertE2EDevelopmentUVEnvironment(t *testing.T, fixture *backendE2EFixture) } } +// TestBackendE2E_GracefulShutdownDoesNotWarnForceTerminated 复现并锁定一个真机误报: +// 后端收到 close 后自行退出(exit 0、无残留进程),Runtime 却仍发出 +// BACKEND_FORCE_TERMINATED 并把它带进 result.details.warnings。根因是根进程刚退出时 +// 的进程表过渡态被当成了「有存活后代」,而不是后端真的没关干净。 +func TestBackendE2E_GracefulShutdownDoesNotWarnForceTerminated(t *testing.T) { + fixture := newBackendE2EFixture(t, backendE2EConfig{ + Events: e2EOutputEvents("graceful"), + }) + done := fixture.supervise(t.Context()) + running := fixture.emitter.waitState(t, protocol.StateRunning, 1) + generation := fixture.captureGenerationWithoutGrandchild(t, running) + fixture.submitShutdown(t) + if err := <-done; err != nil { + t.Fatalf("Supervise() error = %v, want nil", err) + } + fixture.emitter.waitState(t, protocol.StateStopped, 1) + fixture.assertResourcesReleased(t, &generation) + for _, warning := range fixture.emitter.warningsSnapshot() { + if warning.Code == string(protocol.CodeBackendForceTerminated) { + t.Fatalf("graceful shutdown emitted %s: details=%#v", warning.Code, warning.Details) + } + } +} + +// TestBackendE2E_ShutdownWithSurvivingDescendantWarnsForceTerminated 是上一条的对照组: +// 后端优雅退出但故意漏下一个仍在运行的孙进程。这种情况必须仍然强制回收整棵树并 +// 发出 BACKEND_FORCE_TERMINATED——修误报不能顺手把真实的残留也一并放过。 +func TestBackendE2E_ShutdownWithSurvivingDescendantWarnsForceTerminated(t *testing.T) { + fixture := newBackendE2EFixture(t, backendE2EConfig{ + SpawnGrandchild: true, + GrandchildLifetimeMS: 60_000, + LeaveGrandchildOnShutdown: true, + Events: e2EOutputEvents("surviving"), + }) + done := fixture.supervise(t.Context()) + running := fixture.emitter.waitState(t, protocol.StateRunning, 1) + generation := fixture.captureGeneration(t, running) + fixture.submitShutdown(t) + if err := <-done; err != nil { + t.Fatalf("Supervise() error = %v, want nil", err) + } + fixture.emitter.waitState(t, protocol.StateStopped, 1) + // assertResourcesReleased 会等孙进程退出,证明它确实被 Job 回收了。 + fixture.assertResourcesReleased(t, &generation) + forced := false + for _, warning := range fixture.emitter.warningsSnapshot() { + if warning.Code == string(protocol.CodeBackendForceTerminated) { + forced = true + } + } + if !forced { + t.Fatalf("warnings = %#v, want %s for a surviving descendant", fixture.emitter.warningsSnapshot(), protocol.CodeBackendForceTerminated) + } +} + func TestBackendE2E_PreReadyExit(t *testing.T) { fixture := newBackendE2EFixture(t, backendE2EConfig{ Health: []backendE2EHealth{{Ready: false, BackgroundStatus: "starting", Protocol: 1}}, diff --git a/internal/backend/supervisor.go b/internal/backend/supervisor.go index ffa50e8..f08806c 100644 --- a/internal/backend/supervisor.go +++ b/internal/backend/supervisor.go @@ -702,10 +702,7 @@ func (s *ManagedSupervisor) cleanupProcess(ctx context.Context, proc ManagedProc // proc.Wait 等待读者直到长预算耗尽。 members, err := proc.Snapshot() snapshotErr = err - if err != nil { - outcome.forced = true - processErr = errors.Join(processErr, mapCleanupProcessError("terminate", proc.Terminate(1))) - } else if len(members) > 0 { + if err != nil || hasSurvivingDescendant(members, proc.PID()) { outcome.forced = true processErr = errors.Join(processErr, mapCleanupProcessError("terminate", proc.Terminate(1))) } @@ -770,6 +767,19 @@ func (s *ManagedSupervisor) cleanupProcess(ctx context.Context, proc ManagedProc return outcome } +// hasSurvivingDescendant 判断根进程退出后 Job 里是否还留着别的成员。 +// 根进程自身可能因为进程表尚未收敛而短暂留在快照里,而 Exited 已经证明它退出了; +// 把它算成残留会让优雅关闭误报 BACKEND_FORCE_TERMINATED。真正的后代(例如后端 +// 漏掉的 worker)仍然会被识别出来并强制回收。 +func hasSurvivingDescendant(members []process.Info, rootPID uint32) bool { + for _, member := range members { + if member.PID != rootPID { + return true + } + } + return false +} + func withoutExpectedCancellation(err error, keepDeadline bool) error { if err == nil { return nil diff --git a/internal/process/job_windows.go b/internal/process/job_windows.go index b4b6719..ee57280 100644 --- a/internal/process/job_windows.go +++ b/internal/process/job_windows.go @@ -137,10 +137,19 @@ func (j *windowsJob) snapshot() ([]Info, error) { for _, pid := range pids { entry, ok := entries[pid] if !ok { - return nil, fmt.Errorf("query process job member %d identity: process entry is missing", pid) + // 成员在两次查询之间退出了:pid 列表来自 Job,进程表来自 Toolhelp32 + // 快照,两者不是原子的。已经不存在的进程本就不属于「仍在树里」, + // 跳过它——让整个快照失败会把优雅退出误判成需要强制回收的残留树, + // 进而系统性误报 BACKEND_FORCE_TERMINATED。 + continue } path, pathErr := processImagePath(pid) if pathErr != nil { + // 同一个过渡态的另一种表现:pid 已经无效,OpenProcess 直接拒绝。 + // 其余错误(权限、系统故障)仍必须上报,不能被静默。 + if errors.Is(pathErr, windows.ERROR_INVALID_PARAMETER) { + continue + } return nil, fmt.Errorf("query process job member %d image: %w", pid, pathErr) } if path == "" { diff --git a/internal/process/managed_windows_test.go b/internal/process/managed_windows_test.go index 4ab7b49..ddde92a 100644 --- a/internal/process/managed_windows_test.go +++ b/internal/process/managed_windows_test.go @@ -256,6 +256,40 @@ func TestJob_QueryConfirmsTreeEmpty(t *testing.T) { } } +// TestJob_SnapshotAfterRootExitReportsEmptyTreeWithoutError 锁定一个曾造成 +// BACKEND_FORCE_TERMINATED 系统性误报的过渡态:snapshot 先查 Job 的 pid 列表、 +// 再查 Toolhelp32,刚退出的根进程会出现在前者而不在后者。那不是故障,是「成员在 +// 两次查询之间退出了」,必须当成已退出跳过,而不是让整个快照失败。 +func TestJob_SnapshotAfterRootExitReportsEmptyTreeWithoutError(t *testing.T) { + // 该竞态与调度相关,重复若干轮以免偶然的时序掩盖回归。 + for attempt := range 5 { + spec, signal, release := testManagedSpec(t, managedChildRootRole) + managed, err := StartManaged(t.Context(), spec) + if err != nil { + t.Fatal(err) + } + _ = waitTestSignal(t, signal) + if err := os.WriteFile(release, []byte("release"), 0o600); err != nil { + t.Fatal(err) + } + <-managed.Exited() + members, snapshotErr := managed.Snapshot() + if snapshotErr != nil { + t.Fatalf("attempt %d: Snapshot() right after root exit error = %v, want nil", attempt, snapshotErr) + } + for _, member := range members { + if member.PID == managed.PID() { + continue + } + t.Fatalf("attempt %d: snapshot after root exit = %#v, want no surviving descendant", attempt, members) + } + waitManagedSuccess(t, managed) + if err := managed.Close(); err != nil { + t.Fatal(err) + } + } +} + func TestJobE2E_FastChildSpawnIsAlreadyInJob(t *testing.T) { managed, signal, _ := startTestManaged(t.Context(), t, managedChildSpawnerRole) defer cleanupTestManaged(t, managed) diff --git a/testdata/fakebackend/main.go b/testdata/fakebackend/main.go index 800b369..f83aa66 100644 --- a/testdata/fakebackend/main.go +++ b/testdata/fakebackend/main.go @@ -36,20 +36,24 @@ type fakeBackendConfig struct { PIDFile string `json:"pidFile"` // WorkingDirFile 让假后端报告自己的 os.Getwd(),供 T13.1 端到端断言 // Runtime 设定的工作目录真的生效;父进程侧的 StartSpec 断言证明不了这件事。 - WorkingDirFile string `json:"workingDirFile"` - GrandchildPIDFile string `json:"grandchildPidFile"` - SpawnGrandchild bool `json:"spawnGrandchild"` - GrandchildLifetimeMS int `json:"grandchildLifetimeMs"` - LeaveGrandchildOnCrash bool `json:"leaveGrandchildOnCrash"` - Health []healthResponse `json:"health"` - HealthRaw []string `json:"healthRaw"` - HealthHTTPStatus []int `json:"healthHttpStatus"` - CloseStatus int `json:"closeStatus"` - CrashAfterHealthRequests int `json:"crashAfterHealthRequests"` - CrashExitCode int `json:"crashExitCode"` - Events []outputEvent `json:"events"` - Stdout []outputEvent `json:"stdout"` - Stderr []outputEvent `json:"stderr"` + WorkingDirFile string `json:"workingDirFile"` + GrandchildPIDFile string `json:"grandchildPidFile"` + SpawnGrandchild bool `json:"spawnGrandchild"` + GrandchildLifetimeMS int `json:"grandchildLifetimeMs"` + // LeaveGrandchildOnCrash / LeaveGrandchildOnShutdown 都让孙进程脱离父进程的 + // liveness 管道并跳过自清理,区别只是在崩溃还是优雅关闭路径上留下它。 + // 后者用于证明「真有存活后代」时 Runtime 仍会强制回收并发出警告。 + LeaveGrandchildOnCrash bool `json:"leaveGrandchildOnCrash"` + LeaveGrandchildOnShutdown bool `json:"leaveGrandchildOnShutdown"` + Health []healthResponse `json:"health"` + HealthRaw []string `json:"healthRaw"` + HealthHTTPStatus []int `json:"healthHttpStatus"` + CloseStatus int `json:"closeStatus"` + CrashAfterHealthRequests int `json:"crashAfterHealthRequests"` + CrashExitCode int `json:"crashExitCode"` + Events []outputEvent `json:"events"` + Stdout []outputEvent `json:"stdout"` + Stderr []outputEvent `json:"stderr"` } type healthResponse struct { @@ -281,6 +285,9 @@ func runFakeBackend() int { } return code case <-shutdownRequests: + if config.LeaveGrandchildOnShutdown { + cleanupGrandchild = false + } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) err := server.Shutdown(ctx) cancel() @@ -435,7 +442,8 @@ func startGrandchild(config fakeBackendConfig) (*grandchildProcess, error) { ctx, cancel := context.WithCancel(context.Background()) var parentRead *os.File var parentWrite *os.File - if config.LeaveGrandchildOnCrash { + detached := config.LeaveGrandchildOnCrash || config.LeaveGrandchildOnShutdown + if detached { parentRead, err = os.Open(os.DevNull) if err != nil { cancel() @@ -454,7 +462,7 @@ func startGrandchild(config fakeBackendConfig) (*grandchildProcess, error) { fakeBackendRoleEnv+"="+grandchildRole, grandchildLifetimeEnv+"="+strconv.Itoa(config.GrandchildLifetimeMS), ) - if config.LeaveGrandchildOnCrash { + if detached { command.Env = append(command.Env, grandchildDetachedEnv+"=1") } command.Stdin = parentRead From cf7598a303b8437e717b378bdc551d6cf43403f6 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 22:14:07 +0200 Subject: [PATCH 20/57] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20T13.3=20?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=83=85=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- doc/current/README.md | 6 +++- ...73\345\212\241\346\213\206\345\210\206.md" | 34 ++++++++++++++++++- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5f3256a..df0eb43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Vue/Electron/Python 的改写、CI/CD 发布流程本身。 | M10 工程可维护性收敛 | T10.1 文档信息架构已完成;后续阶段见维护设计 | | M11 跨平台适配(Linux/macOS) | 规划中(决策 D7,2026-08-04 立项);仅 T11.1 设计任务可执行 | | M12 遥测与错误观测(Sentry-only) | T12.1、T12.2、T12.4、T12.5、T12.7 已完成;T12.6 本地发布配置已提交,待 Repository secret 与授权后的远端验收;T12.3/Umami 已取消 | -| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;契约与文档已定稿,T13.1~T13.6 全部未开始 | +| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;T13.1(后端工作目录)、T13.2(Job 允许显式脱离)、T13.3(关闭超时参数化,含优雅关闭误报修复)**已完成**;T13.4~T13.6 未开始 | 代码现状: diff --git a/doc/current/README.md b/doc/current/README.md index 291ad93..925a5cd 100644 --- a/doc/current/README.md +++ b/doc/current/README.md @@ -14,7 +14,11 @@ M7 GitHub CI/CD 发布均已完成;设计和审查记录分别归档到 - [M12 遥测与错误观测设计](./M12/设计-T12.1-遥测与错误观测.md):Sentry-only, DSN 配置边界、子进程环境隔离、错误白名单和失败静默契约;历史 Umami 迁移已取消; - [M12 实施计划](./M12/计划-T12.1-遥测与错误观测.md):T12.1~T12.6 的 TDD 步骤、 - 验收命令和提交边界。 + 验收命令和提交边界; +- `M13/` 三份设计([T13.1 后端工作目录](./M13/设计-T13.1-后端工作目录与绝对入口路径.md)、 + [T13.2 Job 允许显式脱离](./M13/设计-T13.2-Job-Object-允许显式脱离.md)、 + [T13.3 关闭超时参数化](./M13/设计-T13.3-关闭超时参数化.md)):设计与计划合并为一份, + 三项均已完成,待 T13.4~T13.6 收口后一并归档。 任务完成后的处理规则: diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index 966594f..2b8257d 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -950,10 +950,42 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 均 exit 0;`go test -race ./... -count=1` exit 0(GCC 目录已前置到 PATH)。 **缺口**:「跑着游戏关 AUTO-MAS」需 AUTO-MAS TODO-PY-11 落地后跨仓联合验收; Runtime 单侧开这一位不产生任何可观察的行为变化。 -- [ ] **T13.3 关闭超时参数化**(S) +- [x] **T13.3 关闭超时参数化**(S) ✅ 2026-09-01 `39cf370`(含误报修复 `e5ef0ab`) - 依赖:M6;契约 [增补 1 C9](./契约补充-v1-增补1.md#c9关闭预算参数化);默认值调整依赖 TODO-PY-12 的实测数据 - 内容:把 `internal/backend/control.go:20` 的 `defaultShutdownTimeout` 从编译期常量改为 `backend supervise` 的选项,正整数秒、合法范围 `1`~`120`、默认 `5`。越界或非数字按参数错误映射 `INVALID_ARGUMENT`(退出码 2、不可重试)。就绪侧预算(`internal/health/checker.go:21-24`)本次**不动**。**本任务只加开关,不改默认值**——在有实测数据之前改默认值等于用猜测替换猜测。 - 验收:表驱动测试覆盖合法值、边界 `1` 与 `120`、越界与非数字;不传该选项时的行为与改动前完全一致;用「收到 close 后固定不退出」的假后端证明配置值真实生效(到达 Job 兜底关闭的时刻随配置变化);既有关闭与单次重启契约测试无回退。 + - 证据:`backend supervise --shutdown-timeout <秒>`,正整数、`1`~`120`、默认 `5`; + `--help` 已列出该选项。选项刻意收 string 自行 `strconv.Atoi`——pflag 的 int 解析失败 + 发生在 Cobra 解析阶段,只会走 stderr 诊断通道,产不出 `INVALID_ARGUMENT` 的 `result`。 + `TestBackendSupervise_ShutdownTimeoutArgument` 覆盖默认/1/120/30 四个接受用例与 + `0`/`121`/`-1`/`abc`/`1.5`/空串六个拒绝用例(拒绝时 backend factory 零调用、 + `details.field=shutdown-timeout`);`TestBackend_ShutdownTimeoutBudgetComesFromRequest` + 用记录关闭上下文 deadline 的假 closer 证明预算随配置变化,并覆盖「预算大于后端退出耗时 + → 优雅收场」与「预算先到期 → Job 兜底 + `BACKEND_FORCE_TERMINATED`」两条分支; + `TestBackend_ShutdownTimeoutFallsBackToDependencyAndDefault` 锁定 + `Request` > `Dependencies` > 编译期 5 秒的三级回退。就绪侧预算与 `defaultRestartDelay` 未动。 + - 附带修复(`e5ef0ab`):真机联调发现优雅关闭被系统性误报为强制终止——后端收到 close + 后 0.4 秒自行退出(exit 0、无残留进程),Runtime 仍发 `BACKEND_FORCE_TERMINATED` + 并计入 `result.details.warnings`。根因是 `internal/process/job_windows.go` 的 + `snapshot()` 先查 Job pid 列表、再查 Toolhelp32 进程表,两次查询非原子,刚退出的 + 根进程出现在前者而不在后者,整个快照报 `process entry is missing`; + `supervisor.go` 的 `cleanupProcess` 把该错误当成「有残留后代」直接判 forced。 + 一次性探针在本机 **20/20** 复现,属必然误报而非偶发。修法两层:`snapshot()` 把 + 「成员在两次查询之间退出」(含 pid 失效导致的 `OpenProcess` 失败)视为正常过渡态跳过, + 其余错误仍上报;`cleanupProcess` 在 `Exited` 之后排除根进程自身,只有真正的存活后代 + 才判 forced。红灯证据:`TestJob_SnapshotAfterRootExitReportsEmptyTreeWithoutError` + 报 `process entry is missing`、`TestBackendE2E_GracefulShutdownDoesNotWarnForceTerminated` + 报 `exitCode:0` 的强制终止警告;对照组 + `TestBackendE2E_ShutdownWithSurvivingDescendantWarnsForceTerminated`(后端故意漏下 + 一个存活孙进程)在修复前后**均通过**,证明真实残留仍被强制回收并告警。 + - 验证:gofmt/vet/build/`go test ./... -count=1`/`git diff --check` 全绿(各 exit 0); + `go test ./internal/backend -run TestBackendE2E -count=5` exit 0(不 flaky); + `go test ./internal/protocol -count=100` 与 `go test ./internal/process -count=100` + 均 exit 0;`go test -race ./... -count=1` exit 0。真机复跑同一联调脚本 + (真后端 `v5.5.0-beta.3`,development 模式):修复前日志有 2 处 + `BACKEND_FORCE_TERMINATED`(warning 事件 + `result.warnings`),修复后 **0 处**, + `result.details` 只剩 `controlCommandId`,`shutdown→exit` 0.47 秒、退出码 0。 + **缺口**:默认值是否需要从 5 秒上调,仍待 AUTO-MAS TODO-PY-12 的实测数据。 - [ ] **T13.4 `dependencies sync` 的镜像改写与轮换**(L) - 依赖:M5;契约 [增补 1 C10](./契约补充-v1-增补1.md#c10主项目依赖的镜像轮换);完整验收需 AUTO-MAS TODO-PY-6 产出真实 `uv.lock` 的发布分支 - 内容:`uv lock --check` 阶段不动(仍对 `repo/uv.lock` 原锁执行、不带包索引覆盖参数);`uv sync` 阶段改为按 `internal/mirror` 的 `KindPackageIndex` 目录与既有轮换规则逐个尝试——读原锁到内存做两处前缀改写(`https://pypi.org/simple` → `<镜像 base>/simple`,`https://files.pythonhosted.org/packages/` → `<镜像 base>/packages/`),与 `repo/pyproject.toml` 一起放进受管根内的临时项目目录,`UV_PROJECT_ENVIRONMENT` 指向真实受管 venv,执行 `uv sync --frozen --no-default-groups --no-install-workspace`;失败换下一个源;全部失败回退原锁(PyPI)。`--mirror-only` 不做这次回退,`--offline` 完全不改写。临时项目目录由 Runtime 创建并在成功与失败路径上都删除,归「可丢弃缓存」。`dependencies.sync` 事件的 `details` 报告本次实际使用的源 key。 From a53bab71a87698d0d79fac9c8893175dba4c52f4 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 22:15:59 +0200 Subject: [PATCH 21/57] =?UTF-8?q?test:=20=E8=A6=86=E7=9B=96=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E5=90=8C=E6=AD=A5=E9=95=9C=E5=83=8F=E8=BD=AE=E6=8D=A2?= =?UTF-8?q?=E7=9A=84=E7=BB=84=E4=BB=B6=E7=9F=A9=E9=98=B5=20(T13.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 七条收场:换源、回退原锁、全失败、离线不改写、mirror-only 不回退、 显式首选排最前、取消收口。每条都断言临时目录不残留且 repo 文件字节不变。 Co-Authored-By: Claude Opus 5 --- internal/cli/m13_component_test.go | 447 +++++++++++++++++++++++++++++ internal/cli/m5_component_test.go | 2 +- 2 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 internal/cli/m13_component_test.go diff --git a/internal/cli/m13_component_test.go b/internal/cli/m13_component_test.go new file mode 100644 index 0000000..0b9d202 --- /dev/null +++ b/internal/cli/m13_component_test.go @@ -0,0 +1,447 @@ +package cli + +import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/config" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/uv" +) + +// m13ComponentLock 是组件夹具使用的 uv.lock:形态与真实锁一致,含两处官方前缀, +// 因此镜像改写、回退与「repo 字节不变」都能在真实文件上断言。 +const m13ComponentLock = `version = 1 +revision = 2 +requires-python = ">=3.12, <3.13" + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] +` + +const ( + m13AliyunPrefix = "https://mirrors.aliyun.com/pypi/" + m13TsinghuaPrefix = "https://pypi.tuna.tsinghua.edu.cn/" + m13USTCPrefix = "https://pypi.mirrors.ustc.edu.cn/" + m13OfficialIndex = "https://pypi.org/simple" +) + +// TestM13Component_DependencySyncMirrorRotation 用真实 uv 服务 + 假 uv 覆盖 C10 +// 的全部收场:换源、回退原锁、全失败、离线不改写、mirror-only 不回退、 +// 显式首选排最前,以及取消。每个子用例都断言临时项目目录不残留、 +// repo 内的 uv.lock 与 pyproject.toml 字节不变。 +func TestM13Component_DependencySyncMirrorRotation(t *testing.T) { + failFrozen := m13Rule{ + "argumentsContain": []string{"--frozen"}, + "exitCode": 1, + "stderr": []string{"mirror is unreachable"}, + } + tests := []struct { + name string + rules []m13Rule + arguments []string + wantCode protocol.Code + wantSource string + wantRewritten bool + wantAttempts float64 + wantLockPrefix string + }{ + { + name: "first mirror fails then the next one succeeds", + rules: []m13Rule{{ + "argumentsContain": []string{"--frozen"}, + "lockContains": m13AliyunPrefix, + "exitCode": 1, + "stderr": []string{"aliyun is unreachable"}, + }}, + arguments: []string{"dependencies", "sync"}, + wantCode: protocol.CodeOK, + wantSource: "tsinghua", + wantRewritten: true, + wantAttempts: 2, + wantLockPrefix: m13TsinghuaPrefix, + }, + { + name: "all mirrors fail and the original lock succeeds", + rules: []m13Rule{failFrozen}, + arguments: []string{"dependencies", "sync"}, + wantCode: protocol.CodeOK, + wantSource: "pypi", + wantAttempts: 4, + wantLockPrefix: m13OfficialIndex, + }, + { + name: "every source fails including the original lock", + rules: []m13Rule{failFrozen, { + "argumentsContain": []string{"--locked"}, + "exitCode": 1, + "stderr": []string{"pypi is unreachable"}, + }}, + arguments: []string{"dependencies", "sync"}, + wantCode: protocol.CodeDependencySyncFailed, + }, + { + name: "offline never rewrites the lock", + arguments: []string{"--offline", "dependencies", "sync"}, + wantCode: protocol.CodeOK, + wantSource: "", + wantAttempts: 1, + wantLockPrefix: m13OfficialIndex, + }, + { + name: "mirror only does not fall back to the official source", + rules: []m13Rule{failFrozen}, + arguments: []string{"--mirror-only", "dependencies", "sync"}, + wantCode: protocol.CodeMirrorExhausted, + }, + { + name: "an explicit preference is attempted first", + arguments: []string{"--mirror", "package-index=ustc", "dependencies", "sync"}, + wantCode: protocol.CodeOK, + wantSource: "ustc", + wantRewritten: true, + wantAttempts: 1, + wantLockPrefix: m13USTCPrefix, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := newM13ComponentFixture(t) + fixture.reconfigure(t, test.rules) + + stdout, code := fixture.run(t, strings.NewReader(""), test.arguments...) + result := m13ResultEvent(t, stdout) + if got := eventString(result, "code"); got != string(test.wantCode) { + t.Fatalf("result code = %q, want %q; stdout=%q", got, test.wantCode, stdout) + } + wantExit := m13ExitCodeFor(t, test.wantCode) + if code != wantExit { + t.Fatalf("exit code = %d, want %d; stdout=%q", code, wantExit, stdout) + } + if test.wantCode == protocol.CodeOK { + details, ok := result.object["details"].(map[string]any) + if !ok { + t.Fatalf("result details = %#v, want object", result.object["details"]) + } + wants := map[string]any{ + "sourceKind": "package-index", + "source": test.wantSource, + "attemptCount": test.wantAttempts, + "lockRewritten": test.wantRewritten, + } + for key, want := range wants { + if got := details[key]; got != want { + t.Errorf("result details[%q] = %#v, want %#v", key, got, want) + } + } + } + if test.wantLockPrefix != "" { + fixture.assertLastSyncUsedPrefix(t, test.wantLockPrefix) + } + if test.wantCode == protocol.CodeMirrorExhausted { + fixture.assertNeverUsedPrefix(t, m13OfficialIndex) + } + fixture.assertRepositoryUntouched(t) + fixture.assertNoStagingLeftovers(t) + }) + } +} + +// TestM13Component_DependencySyncCancellationRemovesStagingProject 证明取消发生在 +// 一次镜像尝试进行中时,临时项目目录仍然被收口。假 uv 先写 ready 文件再等 release, +// 测试确认 ready 出现后才把 cancel 写进 stdin,因此取消一定落在尝试中。 +func TestM13Component_DependencySyncCancellationRemovesStagingProject(t *testing.T) { + fixture := newM13ComponentFixture(t) + signals := t.TempDir() + readyPath := filepath.Join(signals, "ready") + releasePath := filepath.Join(signals, "release") + fixture.reconfigure(t, []m13Rule{{ + "argumentsContain": []string{"--frozen"}, + "readyFile": readyPath, + "releaseFile": releasePath, + "exitCode": 1, + }}) + + reader, writer := io.Pipe() + defer func() { + if err := writer.Close(); err != nil && !errors.Is(err, io.ErrClosedPipe) { + t.Errorf("close stdin writer: %v", err) + } + }() + go func() { + if err := m13WaitForFile(readyPath); err != nil { + return + } + _, _ = io.WriteString( + writer, + `{"protocol":1,"command":"cancel","commandId":"01J00000000000000000000099"}`+"\n", + ) + // 兜底:正常路径下取消会 kill 假 uv;万一没 kill 成功, + // release 文件让它自行退出,测试不至于挂死。 + time.AfterFunc(5*time.Second, func() { + _ = os.WriteFile(releasePath, []byte("go\n"), 0o600) + }) + }() + + stdout, code := fixture.run(t, reader, "dependencies", "sync") + if code != protocol.ExitCodeOperationCancelled { + t.Fatalf("exit code = %d, want %d; stdout=%q", code, protocol.ExitCodeOperationCancelled, stdout) + } + if _, err := os.Stat(readyPath); err != nil { + t.Fatalf("ready file stat error = %v, want the mirror attempt to have started", err) + } + fixture.assertRepositoryUntouched(t) + fixture.assertNoStagingLeftovers(t) +} + +type m13Rule map[string]any + +type m13ComponentFixture struct { + appRoot string + layout *config.Layout + options []Option + configPath string + recordPath string + snapshot map[string]string +} + +// newM13ComponentFixture 先用「全部成功」的假 uv 跑一次 bootstrap, +// 让受管环境进入 ready_to_start,随后的 dependencies sync 才是被测对象。 +func newM13ComponentFixture(t *testing.T) *m13ComponentFixture { + t.Helper() + appRoot := t.TempDir() + layout, err := config.NewLayout(appRoot, appRoot) + if err != nil { + t.Fatalf("config.NewLayout() error = %v", err) + } + gitFixture := newM5ComponentGitFixture(t) + fakeUV := buildM5ComponentFakeUV(t) + workspace := t.TempDir() + fixture := &m13ComponentFixture{ + appRoot: appRoot, + layout: layout, + configPath: filepath.Join(workspace, "fake-uv-config.json"), + recordPath: filepath.Join(workspace, "fake-uv-record.jsonl"), + } + t.Setenv("FAKE_UV_CONFIG", fixture.configPath) + t.Setenv("FAKE_UV_RECORD", fixture.recordPath) + fixture.writeConfig(t, nil) + fixture.options = m5ComponentOptions(t, appRoot, gitFixture, fakeUV) + runM5ComponentCommand(t, appRoot, fixture.options, "bootstrap", "--version", gitFixture.version) + fixture.snapshot = map[string]string{ + layout.UVLockFile(): m13FileDigest(t, layout.UVLockFile()), + layout.PyProjectFile(): m13FileDigest(t, layout.PyProjectFile()), + } + return fixture +} + +// reconfigure 换上本用例的失败注入规则,并丢弃 bootstrap 阶段的调用记录。 +func (f *m13ComponentFixture) reconfigure(t *testing.T, rules []m13Rule) { + t.Helper() + f.writeConfig(t, rules) + if err := os.Remove(f.recordPath); err != nil && !errors.Is(err, os.ErrNotExist) { + t.Fatalf("remove fake uv record: %v", err) + } +} + +func (f *m13ComponentFixture) writeConfig(t *testing.T, extra []m13Rule) { + t.Helper() + pythonExecutable := filepath.Join(f.layout.PythonDir(), "cpython-3.12.10", "python.exe") + rules := []m13Rule{ + {"argumentsPrefix": []string{"--version"}, "stdout": []string{"uv " + uv.FixedVersion}}, + {"argumentsPrefix": []string{"python", "list"}, "stdout": []string{`[{"version":"3.12.10"}]`}}, + {"argumentsPrefix": []string{"python", "install"}, "createDirectories": []string{filepath.Dir(pythonExecutable)}}, + {"argumentsPrefix": []string{"python", "find"}, "stdout": []string{pythonExecutable}}, + {"argumentsPrefix": []string{"lock"}}, + } + rules = append(rules, extra...) + rules = append(rules, m13Rule{ + "argumentsPrefix": []string{"sync"}, + "createDirectories": []string{f.layout.VenvDir()}, + }) + payload, err := json.Marshal(map[string]any{"exitCode": 99, "rules": rules}) + if err != nil { + t.Fatalf("json.Marshal(fake uv config) error = %v", err) + } + if err := os.WriteFile(f.configPath, payload, 0o600); err != nil { + t.Fatalf("WriteFile(fake uv config) error = %v", err) + } +} + +func (f *m13ComponentFixture) run(t *testing.T, stdin io.Reader, arguments ...string) (string, int) { + t.Helper() + var stdout, stderr bytes.Buffer + args := append([]string{"--app-root", f.appRoot, "--output", "ndjson"}, arguments...) + code := Execute( + t.Context(), + args, + IO{In: stdin, Out: &stdout, Err: &stderr}, + f.options..., + ) + return stdout.String(), code +} + +func (f *m13ComponentFixture) assertRepositoryUntouched(t *testing.T) { + t.Helper() + for path, want := range f.snapshot { + if got := m13FileDigest(t, path); got != want { + t.Errorf("%q digest = %s, want %s (repository files must stay byte-identical)", path, got, want) + } + } +} + +func (f *m13ComponentFixture) assertNoStagingLeftovers(t *testing.T) { + t.Helper() + root := filepath.Join(f.layout.BuildCacheDir(), "dependencies") + entries, err := os.ReadDir(root) + if errors.Is(err, os.ErrNotExist) { + return + } + if err != nil { + t.Fatalf("ReadDir(%q) error = %v", root, err) + } + if len(entries) == 0 { + return + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + sort.Strings(names) + t.Errorf("dependency staging leftovers in %q: %v", root, names) +} + +// assertLastSyncUsedPrefix 断言最后一次 uv sync 看到的锁确实指向该前缀。 +func (f *m13ComponentFixture) assertLastSyncUsedPrefix(t *testing.T, prefix string) { + t.Helper() + invocations := f.syncInvocations(t) + if len(invocations) == 0 { + t.Fatal("no uv sync invocation was recorded") + } + last := invocations[len(invocations)-1] + for _, got := range last.LockIndexPrefixes { + if strings.HasPrefix(got, prefix) { + return + } + } + t.Errorf("last sync lock prefixes = %v, want one starting with %q", last.LockIndexPrefixes, prefix) +} + +func (f *m13ComponentFixture) assertNeverUsedPrefix(t *testing.T, prefix string) { + t.Helper() + for _, invocation := range f.syncInvocations(t) { + for _, got := range invocation.LockIndexPrefixes { + if strings.HasPrefix(got, prefix) { + t.Errorf("a uv sync used forbidden prefix %q (arguments=%v)", prefix, invocation.Arguments) + } + } + } +} + +type m13Invocation struct { + Arguments []string `json:"arguments"` + ProjectDir string `json:"projectDir"` + LockIndexPrefixes []string `json:"lockIndexPrefixes"` +} + +func (f *m13ComponentFixture) syncInvocations(t *testing.T) []m13Invocation { + t.Helper() + file, err := os.Open(f.recordPath) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + t.Fatalf("os.Open(fake uv record) error = %v", err) + } + defer func() { + if closeErr := file.Close(); closeErr != nil { + t.Errorf("fake uv record Close() error = %v", closeErr) + } + }() + invocations := make([]m13Invocation, 0, 8) + scanner := bufio.NewScanner(file) + for scanner.Scan() { + var invocation m13Invocation + if err := json.Unmarshal(scanner.Bytes(), &invocation); err != nil { + t.Fatalf("decode fake uv record: %v", err) + } + if len(invocation.Arguments) > 0 && invocation.Arguments[0] == "sync" { + invocations = append(invocations, invocation) + } + } + if err := scanner.Err(); err != nil { + t.Fatalf("scan fake uv record: %v", err) + } + return invocations +} + +func m13ResultEvent(t *testing.T, output string) parsedEvent { + t.Helper() + events := parseNDJSON(t, output) + for _, event := range events { + if eventType(event) == string(protocol.TypeResult) { + return event + } + } + t.Fatalf("result event is missing from %q", output) + return parsedEvent{} +} + +func m13ExitCodeFor(t *testing.T, code protocol.Code) int { + t.Helper() + if code == protocol.CodeOK { + return protocol.ExitCodeSuccess + } + definition, ok := protocol.LookupErrorDefinition(code) + if !ok { + t.Fatalf("error definition for %q is missing", code) + } + return definition.ExitCode +} + +func m13FileDigest(t *testing.T, path string) string { + t.Helper() + payload, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", path, err) + } + digest := sha256.Sum256(payload) + return hex.EncodeToString(digest[:]) +} + +// m13WaitForFile 轮询等待信号文件出现,超时即放弃(调用方据此不再注入取消)。 +func m13WaitForFile(path string) error { + deadline, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + ticker := time.NewTicker(5 * time.Millisecond) + defer ticker.Stop() + for { + if _, err := os.Stat(path); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + select { + case <-deadline.Done(): + return deadline.Err() + case <-ticker.C: + } + } +} diff --git a/internal/cli/m5_component_test.go b/internal/cli/m5_component_test.go index b6f6f9d..fb48c55 100644 --- a/internal/cli/m5_component_test.go +++ b/internal/cli/m5_component_test.go @@ -388,7 +388,7 @@ func newM5ComponentGitFixture(t *testing.T) *m5ComponentGitFixture { files := map[string]string{ ".python-version": "3.12.10\n", "pyproject.toml": "[project]\nrequires-python = \">=3.12,<3.13\"\n", - "uv.lock": "version = 1\n", + "uv.lock": m13ComponentLock, "res/version.json": "{\"version\":\"v5.4.0\"}\n", } worktree, err := repository.Worktree() From 850c61b87f149bf322ec7e5155ad24f891b09df6 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 22:17:08 +0200 Subject: [PATCH 22/57] =?UTF-8?q?test:=20=E5=A5=91=E7=BA=A6=E9=94=81?= =?UTF-8?q?=E5=AE=9A=20dependencies=20sync=20=E7=9A=84=E9=95=9C=E5=83=8F?= =?UTF-8?q?=E6=BA=90=20details=20(T13.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- internal/cli/contract_m5_test.go | 40 ++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/internal/cli/contract_m5_test.go b/internal/cli/contract_m5_test.go index dc0a135..4a5e776 100644 --- a/internal/cli/contract_m5_test.go +++ b/internal/cli/contract_m5_test.go @@ -11,6 +11,7 @@ import ( "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/config" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/gitrepo" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol/contracttest" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/state" @@ -108,6 +109,7 @@ func m5ContractRunner(command string, stage protocol.Stage, arguments ...string) t.Errorf("exit code = %d, want %d; stderr=%q", code, wantExit, stderr.String()) } assertM5ContractStage(t, terminal, stage, stdout.String()) + assertM5ContractMirrorDetails(t, terminal, command, stdout.String()) return contracttest.Transcript{Stdout: stdout.Bytes()} } } @@ -127,6 +129,44 @@ func m5ContractInitialState(command string) state.EnvironmentState { } } +// assertM5ContractMirrorDetails 锁定 C10 第 7 条:dependencies sync 成功时 +// result.details 必须报告包索引源与尝试次数,字段名与类型不得漂移。 +func assertM5ContractMirrorDetails( + t *testing.T, + terminal contracttest.Terminal, + command string, + output string, +) { + t.Helper() + if terminal != contracttest.TerminalSuccess || command != "dependencies sync" { + return + } + events := parseNDJSON(t, output) + for _, event := range events { + if eventType(event) != string(protocol.TypeResult) { + continue + } + details, ok := event.object["details"].(map[string]any) + if !ok { + t.Fatalf("result details = %#v, want object", event.object["details"]) + } + if got, ok := details["sourceKind"].(string); !ok || got != mirror.KindPackageIndex.String() { + t.Errorf("result details[sourceKind] = %#v, want %q", details["sourceKind"], mirror.KindPackageIndex) + } + if _, ok := details["source"].(string); !ok { + t.Errorf("result details[source] = %#v, want string", details["source"]) + } + if got, ok := details["attemptCount"].(float64); !ok || got < 1 { + t.Errorf("result details[attemptCount] = %#v, want a positive number", details["attemptCount"]) + } + if _, ok := details["lockRewritten"].(bool); !ok { + t.Errorf("result details[lockRewritten] = %#v, want bool", details["lockRewritten"]) + } + return + } + t.Fatal("result event is missing") +} + func assertM5ContractStage(t *testing.T, terminal contracttest.Terminal, want protocol.Stage, output string) { t.Helper() if terminal != contracttest.TerminalFailure { From bba97c9f9348e824ddad02446ef998abd0045cfa Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 22:20:32 +0200 Subject: [PATCH 23/57] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=E5=8C=85?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=E7=9A=84=20--default-index=20=E6=AD=BB?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E8=B7=AF=E5=BE=84=20(T13.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 该分支没有任何调用方,且正是 C10 明令禁止的做法——传 --default-index 会让 uv 判定锁需要更新并破坏 --locked 不变量。留着它是陷阱,不是备用方案。 Co-Authored-By: Claude Opus 5 --- internal/uv/network.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/uv/network.go b/internal/uv/network.go index f9ade46..e126cf6 100644 --- a/internal/uv/network.go +++ b/internal/uv/network.go @@ -91,9 +91,10 @@ func (e *networkExecutor) run( switch kind { case mirror.KindPython: attemptOptions.Environment[uvPythonInstallMirrorEnv] = attempt.Source.BaseURL() - case mirror.KindPackageIndex: - attemptArgs = append(attemptArgs, "--default-index", attempt.Source.BaseURL()) default: + // 包索引不走这条路径:锁定依赖靠改写锁副本参与轮换,绝不能传 + // --default-index —— 那会让 uv 判定锁需要更新并破坏 --locked + // 不变量(增补 1 C10 的实验依据第 1 条已实测)。 return mirror.AttemptOutcome{ Kind: mirror.OutcomeTargetFailure, FailureKind: mirror.FailureKind("unsupported_kind"), From eef95dbb57b25efa5d27fe7296552b8d9d7b7257 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 22:21:31 +0200 Subject: [PATCH 24/57] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20T13.4=20?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E5=90=8C=E6=AD=A5=E9=95=9C=E5=83=8F=E6=94=B9?= =?UTF-8?q?=E5=86=99=E5=AE=8C=E6=88=90=E6=83=85=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- ...34\345\203\217\346\224\271\345\206\231.md" | 28 +++++++++++++++++++ ...73\345\212\241\346\213\206\345\210\206.md" | 3 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git "a/doc/current/M13/\350\256\276\350\256\241-T13.4-\344\276\235\350\265\226\345\220\214\346\255\245\351\225\234\345\203\217\346\224\271\345\206\231.md" "b/doc/current/M13/\350\256\276\350\256\241-T13.4-\344\276\235\350\265\226\345\220\214\346\255\245\351\225\234\345\203\217\346\224\271\345\206\231.md" index c3e2da1..5631c71 100644 --- "a/doc/current/M13/\350\256\276\350\256\241-T13.4-\344\276\235\350\265\226\345\220\214\346\255\245\351\225\234\345\203\217\346\224\271\345\206\231.md" +++ "b/doc/current/M13/\350\256\276\350\256\241-T13.4-\344\276\235\350\265\226\345\220\214\346\255\245\351\225\234\345\203\217\346\224\271\345\206\231.md" @@ -4,6 +4,7 @@ - 契约:[增补 1 C10](../../契约补充-v1-增补1.md#c10主项目依赖的镜像轮换)(含 2026-09-01 两处修订) - 架构:[架构设计「项目依赖同步」「镜像与网络策略」](../../架构设计.md) - 基线:`docs/dev-baseline-contract-20260831@f5ac9c9`;文档修订提交 `bf18964` +- 状态:**已实现**(2026-09-01,收口于 `bba97c9`);实现提交见第 9 节 --- @@ -74,6 +75,10 @@ Sync(request) `UV_PROJECT_ENVIRONMENT` 仍由 `UVRunner` 指向真实受管 venv,临时目录只提供项目元数据; 4. 无论成功、失败还是取消,都在返回前经 `filesystem` 受控删除移除该目录。 +子进程的**工作目录保持 `repo` 不变**(`RunOptions.ProjectDir` 不改):`--project` 已经 +明确指定了项目根,cwd 不参与解析;让 cwd 留在 repo 既避免了「刚跑完就要删掉的目录正被 +某个进程当作 cwd」这类 Windows 删除失败,也把这次改动对既有执行环境的影响压到零。 + `--no-install-workspace` 排除根项目本身,因此临时目录只需这两个文件——不需要 README、 LICENSE 或源码(C10 实验依据第 5 条已实测)。 @@ -308,3 +313,26 @@ if (-not ($out -match '--- PASS:')) { throw "no PASS line" } 标准验证门(AGENTS.md 5.2)全绿;轮换路径涉及并发(`mirror.Rotator`),追加 `go test -race ./... -count=1`。完成后按 6.4 回写 `doc/任务拆分.md`(独立 docs 提交)。 + +--- + +## 9. 实现提交 + +| 提交 | 内容 | +| --- | --- | +| `bf18964` | 文档:修订 C10(显式首选排最前、改写前缀显式声明),同步架构设计与任务拆分 | +| `b34053d` | 文档:本设计与计划 | +| `81f47a3` | Task 1:`internal/mirror` 的 `PackageIndexSpec` / `PackageIndexRewrite` 与目录显式前缀 | +| `f20cd1b` | Task 2:`rewriteLockfile` 单遍前缀替换 | +| `a94228f` | Task 3:`Layout.DependencySyncDir` 与 `filesystem.DeleteDependencySync` | +| `9d21bc1` | Task 4:镜像轮换、临时项目目录生命周期与错误映射 | +| `81029ea` | Task 5:删除三处显式包索引首选拒绝 | +| `f12f070` | Task 7:`result.details` 四字段与每次尝试的 progress | +| `f204746` | Task 6:`testdata/fakeuv` 的 `argumentsContain` / `lockContains` / 调用记录 / ready-release | +| `a53bab7` | Task 8:组件矩阵七条收场 | +| `850c61b` | Task 9:契约锁定 details 字段 | +| `bba97c9` | 审查修复:移除 `network.go` 里包索引的 `--default-index` 死代码路径 | + +计划外的一处清理(`bba97c9`):`internal/uv/network.go` 曾有一个「给包索引加 +`--default-index`」的分支,没有任何调用方,而它正是 C10 明令禁止的做法——传该参数会让 +uv 判定锁需要更新并破坏 `--locked`。留着是陷阱而不是备用方案,因此在审查阶段删除。 diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index 1a9993d..1ba93d2 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -926,7 +926,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 - 依赖:M6;契约 [增补 1 C9](./契约补充-v1-增补1.md#c9关闭预算参数化);默认值调整依赖 TODO-PY-12 的实测数据 - 内容:把 `internal/backend/control.go:20` 的 `defaultShutdownTimeout` 从编译期常量改为 `backend supervise` 的选项,正整数秒、合法范围 `1`~`120`、默认 `5`。越界或非数字按参数错误映射 `INVALID_ARGUMENT`(退出码 2、不可重试)。就绪侧预算(`internal/health/checker.go:21-24`)本次**不动**。**本任务只加开关,不改默认值**——在有实测数据之前改默认值等于用猜测替换猜测。 - 验收:表驱动测试覆盖合法值、边界 `1` 与 `120`、越界与非数字;不传该选项时的行为与改动前完全一致;用「收到 close 后固定不退出」的假后端证明配置值真实生效(到达 Job 兜底关闭的时刻随配置变化);既有关闭与单次重启契约测试无回退。 -- [ ] **T13.4 `dependencies sync` 的镜像改写与轮换**(L) +- [x] **T13.4 `dependencies sync` 的镜像改写与轮换**(L)✅ 2026-09-01 `bba97c9` - 依赖:M5;契约 [增补 1 C10](./契约补充-v1-增补1.md#c10主项目依赖的镜像轮换);完整验收需 AUTO-MAS TODO-PY-6 产出真实 `uv.lock` 的发布分支 - 内容:`uv lock --check` 阶段不动(仍对 `repo/uv.lock` 原锁执行、不带包索引覆盖参数);`uv sync` 阶段改为按 `internal/mirror` 的 `KindPackageIndex` 目录与既有轮换规则逐个尝试——读原锁到内存做两处前缀改写(`https://pypi.org/simple` → `<镜像 base>/simple`,`https://files.pythonhosted.org/packages/` → `<镜像 base>/packages/`),与 `repo/pyproject.toml` 一起放进受管根内的临时项目目录,`UV_PROJECT_ENVIRONMENT` 指向真实受管 venv,执行 `uv sync --frozen --no-default-groups --no-install-workspace`;失败换下一个源;全部失败回退原锁(PyPI)。`--mirror-only` 不做这次回退,`--offline` 完全不改写。临时项目目录由 Runtime 创建并在成功与失败路径上都删除,归「可丢弃缓存」。`dependencies.sync` 事件的 `details` 报告本次实际使用的源 key。 - **2026-09-01 修订**(见 [增补 1 C10](./契约补充-v1-增补1.md#c10主项目依赖的镜像轮换) 顶部的修订提示):`internal/uv/dependencies.go:234-242` 的 `INVALID_ARGUMENT` 拒绝,连同 `internal/cli/errors.go` 的 `rejectPackageIndexOverride`(bootstrap 与 repair 的前置拒绝)一并删除;显式 `--mirror package-index=` 改为把该源排在尝试顺序最前,走同一条改写路径。`internal/mirror/defaults.go` 的 4 个 `KindPackageIndex` 源此前是死代码(唯一消费方拒绝使用),本条正是它的正当用途;每个源的 `simple` / `packages` 改写前缀由目录**显式声明**,不由 `baseURL` 推导(官方源的 artifact 在 `files.pythonhosted.org`,推导会得到不存在的地址)。 @@ -1137,6 +1137,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 | 日期 | 变更 | 操作者 | | --- | --- | --- | +| 2026-09-01 | 完成 **T13.4**(实现收口于 `bba97c9`,设计与计划见 `doc/current/M13/设计-T13.4-依赖同步镜像改写.md`):`uv lock --check` 阶段与 `--locked` 校验口径不变,`uv sync` 改为按 `KindPackageIndex` 目录轮换——每个镜像在受管临时项目目录(`runtime/cache/build/dependencies/`)里用改写后的锁副本执行 `--frozen`,plan 末位的官方源改用 `repo` 原锁与 `--locked`,**回退即 plan 末位**,因此 `--mirror-only` 不回退不需要额外分支(`BuildPlan` 本就不放官方源进 plan,耗尽后映射 `MIRROR_EXHAUSTED`),`--offline` 完全不改写。每源只尝试一次(`OutcomeSwitchSource`):`uv sync` 是重操作、uv 自身已对单个 artifact 重试,同源重试只会让断网时的等待翻倍。临时目录经 `filesystem.PrepareManagedDirectory` 创建、`DeleteDependencySync` 受控删除,成功/失败/取消三条路径都收口(取消走 `context.WithoutCancel` + 15 秒预算),全程不写 `repo/` 内任何文件。改写前缀由目录**显式声明**而非推导(官方源 artifact 在 `files.pythonhosted.org`)。事件形态在零契约新增的前提下落地:每次尝试发一条 `dependencies.sync` progress(`ProgressEvent` 无 `details`,message 仅供展示),机器可读事实进 `result.details` 的 `sourceKind` / `source` / `attemptCount` / `lockRewritten`;`log` 事件按架构定义专指受管进程输出转发(能力标识 `log.stream`),`warning` 需要新错误码而 C10 禁止新增,两者都不占用。测试:`internal/mirror` 前缀不变量、`rewriteLockfile` 五项不变量(含哈希逐字不变与幂等)、`internal/uv` 六项轮换/收口用例、`internal/cli` 组件矩阵七条收场(含 stdin 取消时临时目录收口)与契约字段锁定。审查阶段另删除 `internal/uv/network.go` 里包索引的 `--default-index` 死代码路径。标准验证门与 `go test -race ./... -count=1` 均为退出码 0 | Claude | | 2026-09-01 | T13.4 实施期间按红线第 2 条先改文档,修订 **C10** 两处:其一,显式 `--mirror package-index=` 不再返回 `INVALID_ARGUMENT`,改为把该源排在尝试顺序最前、与自动轮换走同一条锁改写路径——**改写不是覆盖索引**,`uv lock --check` 仍对 `repo/uv.lock` 原锁执行、`uv sync` 消费的是改写后的锁副本而非 `--default-index`,所以「显式指定」与「自动轮换」在实现上是同一件事、只差顺序;定稿时保留拒绝会形成「自动允许、显式拒绝」的不对称,用户想优先用某个已知可达的镜像反而被判参数错误。落地时同步删除 `internal/cli/errors.go` 的 `rejectPackageIndexOverride`(bootstrap 与 repair 的前置拒绝)。其二,改写用的 `simple` / `packages` 两个前缀改为在 `internal/mirror` 目录中**显式声明**,不再由 `baseURL` 去掉结尾 `simple/` 推导——官方源的 artifact 在 `files.pythonhosted.org`,与索引不同 host,推导会得到不存在的 `https://pypi.org/packages/`;三家镜像同 host 只是巧合。同日实测记入 C10:`aliyun` / `tsinghua` 两个前缀均 HTTP 200 且无跳转,`ustc` 的索引 302 到 `mirrors.ustc.edu.cn/pypi/simple`、artifact 302 到清华(可用,与 `tsinghua` 冗余但不冲突),目录中**没有**腾讯云源。`doc/架构设计.md`「项目依赖同步」同步修订,T13.4 条目与验收项同步更新;`--mirror-only`、`--offline`、临时目录生命周期与 `details` 报告口径均不变,协议仍为 v1、不新增字段/stage/state/错误码 | Claude | | 2026-08-31 | 新增 [协议 v1 契约补充 增补 1](./契约补充-v1-增补1.md)(**C6~C11**,协议仍为 v1,不新增或改名任何字段、stage、state 与错误码),并立项里程碑 **M13「dev 基线接入配套」**(T13.1~T13.6)。六条定稿:**C6** managed 下后端子进程 cwd = app-root、入口传绝对路径 `/repo/main.py`(现实现 `internal/uv/managed.go:83` 用 uv 的 project dir,会把用户数据建在 `repo/` 里并被 `workspace sync` 整体替换掉);**C7** 受监督优先级扩展到端口(固定 36163,忽略 `AUTO_MAS_HTTP_PORT` 与 `.env`/`AUTO_MAS_ENV`),并修订 C2 第 1 条(`protocol` 改为后端自报而非回显注入值,`version`/`commit` 仍回显)与 C4 第 1 条(受监督但非管理员时记 warning 并继续运行,不再要求非零退出);**C8** 游戏与模拟器不随后端退出,Runtime 开 `JOB_OBJECT_LIMIT_BREAKAWAY_OK`、AUTO-MAS 只在拉起游戏/模拟器时带 `CREATE_BREAKAWAY_FROM_JOB`(`DETACHED_PROCESS` 不脱离 Job);**C9** `backend supervise` 新增关闭超时选项,默认仍 5 秒,待实测数据再调;**C10** 锁文件在 PyPI 上生成,`dependencies sync` 改为改写锁副本内两处下载地址前缀参与镜像轮换、失败逐个换源、全部失败回退原锁,安全性由锁内 `sha256` 与 uv 的 `--frozen` 校验保证,`dependencies.go:234` 拒绝覆盖包索引的逻辑保留;**C11** MaaFW 运行池只统一基础设施——新增注入 `AUTO_MAS_UV_CACHE_DIR`/`AUTO_MAS_UV_PYTHON_INSTALL_DIR`/`AUTO_MAS_MIRROR_PACKAGE_INDEX`/`AUTO_MAS_MIRROR_PYTHON`,池目录重新分类,「装什么」仍留在后端。按红线第 2 条先改文档:`doc/架构设计.md` 在目录模型、后端启动、项目依赖同步、镜像与网络策略、CLI 设计、插件环境职责边界、健康检查与关闭契约、Lite/Full 边界和数据分类九处补入增补段落,并补全 dev 基线下后端生成目录的分类;`doc/契约补充-v1.md` 顶部与 C2/C4 两条加指向增补的修订标注;`doc/README.md` 权威优先级更新为「增补 1 > 契约补充-v1 > 架构设计 > 任务拆分」。本次不写任何 Go 代码,M13 全部任务保持未开始 | Claude | | 2026-08-31 | 登记决策 **D12** 并按 dev 基线重写第 5.1 节:AUTO-MAS 侧配合改造基线由 dev_v2 改为 `dev@3c422093`(dev_v2 自 2026-08-06 停更,两条分支自 07-29 分叉后各走四百余提交),D4 标注为已由 D12 取代并保留原文追溯。5.1 的 TODO-PY-1~8 编号沿用、内容与落点整体换成 dev 现状:**TODO-PY-3** 目标文件 `app/plugins/uv_backend.py` 在 dev 不存在,落点改为 MaaFW 的 `automas_maafw_agent_env/env.py:687-691`(运行池那处已由 AUTO-MAS PR #478 做好 `AUTO_MAS_UV_EXE` 注入优先);**TODO-PY-7 不适用于 dev 基线**(dev 无插件系统:无 `app/plugins/`、无 `app/core/plugins/`、`plugins/` 下无内容、Electron 无 `pluginBootstrapService.ts`),保留编号避免引用悬空,dev_v2 插件系统若并回则与 TODO-PY-3 一起重算;其余各条按契约第 02 节 P-1~P-8 收窄或扩写。5.1 末尾新增「MaaFW 专项」小节 **TODO-PY-9~13**(取自契约第 03 节 M-1~M-5 与第 04 节 T-1~T-4,逐条标注落在 AUTO-MAS、Runtime 还是两侧)。第 4 章加注左列仍为 dev_v2 时期内容且插件一行不适用;5.2 加注基线同 5.1、待 R 段定稿后重排;5.3 未改动。本次只改文档,不涉及任何 Go 代码或已冻结契约 | Claude | From 140212901fe2d5e4a0f09b521bf21f4397312435 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 22:33:38 +0200 Subject: [PATCH 25/57] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20T13.5=20?= =?UTF-8?q?=E5=8F=97=E7=AE=A1=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD=E4=B8=8E?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E6=BA=90=E6=B3=A8=E5=85=A5=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E4=B8=8E=E8=AE=A1=E5=88=92=20(T13.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...17\346\272\220\346\263\250\345\205\245.md" | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 "doc/current/M13/\350\256\276\350\256\241-T13.5-\345\217\227\347\256\241\345\237\272\347\241\200\350\256\276\346\226\275\344\270\216\351\225\234\345\203\217\346\272\220\346\263\250\345\205\245.md" diff --git "a/doc/current/M13/\350\256\276\350\256\241-T13.5-\345\217\227\347\256\241\345\237\272\347\241\200\350\256\276\346\226\275\344\270\216\351\225\234\345\203\217\346\272\220\346\263\250\345\205\245.md" "b/doc/current/M13/\350\256\276\350\256\241-T13.5-\345\217\227\347\256\241\345\237\272\347\241\200\350\256\276\346\226\275\344\270\216\351\225\234\345\203\217\346\272\220\346\263\250\345\205\245.md" new file mode 100644 index 0000000..ce80be4 --- /dev/null +++ "b/doc/current/M13/\350\256\276\350\256\241-T13.5-\345\217\227\347\256\241\345\237\272\347\241\200\350\256\276\346\226\275\344\270\216\351\225\234\345\203\217\346\272\220\346\263\250\345\205\245.md" @@ -0,0 +1,184 @@ +# 设计与计划:T13.5 向后端开放受管基础设施与有序镜像源 + +- 任务:`doc/任务拆分.md` **T13.5** +- 契约:[增补 1 C11](../../契约补充-v1-增补1.md#c11maafw-运行池的基础设施共享与镜像源下发) 与同文档「新增注入环境变量」 +- 依赖:T13.4(`internal/mirror` 的消费面已由它稳定),M6 +- 成对:AUTO-MAS `TODO-PY-13` + +--- + +## 1. 目标 + +`backend supervise` 在 **managed 与 development 两种模式**下启动后端时,额外注入四个环境变量, +使 MaaFW 运行池与 Runtime 主项目共用同一份 uv 缓存、同一份受管 Python,并拿到 Runtime +已解析好的**有序镜像源列表**自行按序重试。 + +| 环境变量 | 取值 | +| --- | --- | +| `AUTO_MAS_UV_CACHE_DIR` | 受管 uv 缓存目录的规范化绝对路径 | +| `AUTO_MAS_UV_PYTHON_INSTALL_DIR` | 受管 Python 安装目录的规范化绝对路径 | +| `AUTO_MAS_MIRROR_PACKAGE_INDEX` | `;` 分隔的包索引源列表,顺序即尝试顺序,官方源末位 | +| `AUTO_MAS_MIRROR_PYTHON` | `;` 分隔的 Python 分发源列表,格式同上 | + +## 2. 范围与边界 + +### 负责 + +1. 四个变量并入 `internal/uv` 的**受控监督环境集合**:宿主同名变量被清除、 + `RunOptions.Environment` 不能覆盖(与 `AUTO_MAS_SUPERVISED` 等五个键同一条纪律); +2. 有序列表由 `internal/backend` 用已有的 `mirror.BuildPlan` 解析后传入 `StartManaged`; +3. `--mirror-only` 不含官方源、`--offline` 注入空串——两者都由 `BuildPlan` 现成语义给出, + 本任务**不新增 mirror API**。 + +### 不负责 + +- 不读取 MaaFW 项目的依赖声明、不解析池依赖、不维护池安装清单、不决定装哪个 Python + 或哪些包,不新增池专用命令 / stage / 错误码(C11 与红线第 4 条); +- 不改变 `UV_*` 的注入面:T12.7 的保留键清理行为一个字节都不动,宿主 `UV_*` 仍不可重新注入; +- **不做** T13.5 条目后半段的「MaaFW 池目录重新分类进 repair/cleanup」。理由见第 6 节。 + +## 3. 数据流(本任务最需要说清的一条) + +```text +CLI --mirror / --offline / --mirror-only + └─ flags.go: mirror.NewPolicy(...) → globalOptions.mirrorPolicy + └─ backendFactory(ctx, layout, stderr, clock, policy) ← 新增参数 + └─ NewProductionManagedSupervisor(..., policy) + └─ backend.Dependencies.MirrorPolicy ← 新增字段 + └─ ManagedSupervisor(同时持有 layout 与 catalog) + └─ supervisionInfrastructure() + mirror.BuildPlan(catalog, policy, KindPackageIndex) + mirror.BuildPlan(catalog, policy, KindPython) + layout.UVCacheDir() / layout.PythonDir() + └─ uv.ManagedOptions.Infrastructure + └─ uv.StartManaged → supervision map → 子进程环境 +``` + +- **谁算 plan**:`internal/backend`。它是唯一同时持有 `layout` 与 `mirror.Policy` 的地方; + `internal/uv` 不认识 policy,`internal/cli` 不该替 backend 决定启动参数。 +- **谁传给 `StartManaged`**:`supervisor.go` 的无控制路径与 `control.go` 的受控路径 + (**managed 与 development 共用 `startControlAttempt` 这一处**,因此两种模式天然都注入; + 单次自动重启复用同一处,第二代进程拿到同样的四个值)。 +- `Source.BaseURL()` 是下发值(package-index 即 simple 索引 URL,带尾斜杠)。 + T13.4 新增的 `Source.PackageIndexRewrite()` 只服务锁改写,本任务不使用。 + +### 3.1 `BuildPlan` 的失败语义 + +沿用 `internal/uv` 已有口径(`dependencies_mirror.go:150` 与 `network.go:139` 同一形状): + +- `errors.Is(err, mirror.ErrPolicyRejected)` → **失败关闭**,映射 `INVALID_ARGUMENT` + (用户显式指定了一个选不出来的源,静默换源是错的); +- 其他错误(例如零值 `mirror.Policy`——测试夹具直接构造 `Dependencies` 时的常态) + → 退回目录默认 policy,行为与不带任何镜像参数一致。 + +### 3.2 `ManagedOptions` 的新字段 + +```go +type SupervisionInfrastructure struct { + UVCacheDir string + PythonInstallDir string + PackageIndexSources []string + PythonSources []string +} +``` + +- 两个目录由 backend 显式传 `layout.UVCacheDir()` / `layout.PythonDir()`; + **为空时回退到本次 uv 调用实际解析出的 `CacheDir` / `PythonInstallDir`**, + 于是 `AUTO_MAS_UV_CACHE_DIR == UV_CACHE_DIR`、 + `AUTO_MAS_UV_PYTHON_INSTALL_DIR == UV_PYTHON_INSTALL_DIR` 恒成立—— + C11 要的是「共用一份缓存」,下发值一旦与 uv 自己用的目录分叉,这条契约就名存实亡。 + 该等式由单测直接断言,不依赖调用方是否记得传。 +- 注入值取 `filepath.Clean`,并要求绝对路径,否则失败关闭; +- 列表用 `;` 拼接;空切片(`--offline`)拼出空串,**键仍然存在**(契约表两列都是「必填」, + 空串是合法取值,键缺失不是); +- 任何一项含 `;` 直接失败关闭(契约:每项是绝对 HTTPS URL 且不得出现未编码的 `;`)。 + `mirror.Source` 已保证 https、无 query/fragment,这里只补最后一道。 + +### 3.3 受控注入集合 + +`canonicalSupervisionEnvironmentKey` 的名单加四个键。这一条同时带来三个后果, +它们正是 C11 想要的:宿主的同名变量被 `buildEnvironmentWithSupervision` 清除、 +`RunOptions.Environment` 里的同名项被丢弃、大小写混合的变体也被规范化掉。 + +## 4. 影响面 + +| 文件 | 改动 | +| --- | --- | +| `internal/uv/runner.go` | 四个常量、受控名单、`;` 与绝对路径校验 | +| `internal/uv/managed.go` | `ManagedOptions.Infrastructure`、四个键并入 `supervision` | +| `internal/backend/types.go` | `Dependencies.MirrorPolicy` | +| `internal/backend/supervisor.go` | `catalog` 字段、`supervisionInfrastructure()`、无控制路径传参 | +| `internal/backend/control.go` | 受控路径(managed + development + 重启)传参 | +| `internal/backend/production_windows.go` | 新增 policy 参数 | +| `internal/cli/options.go`、`cli.go`、`backend.go` | `backendFactory` 多一个 `mirror.Policy` 参数 | +| `testdata/fakebackend` | 新增 `environmentFile`,把收到的四个变量落盘 | + +不新增协议字段、stage、state、错误码;协议仍为 v1。 + +## 5. 验收对照 + +| 验收项 | 覆盖方式 | +| --- | --- | +| 两种模式都注入、格式正确 | `internal/uv` 单测(键存在、值正确)+ `internal/backend` managed/development 各一条断言 | +| 顺序与 mirror 解析后的尝试顺序逐项一致 | 单测把 `plan.Sources()` 与 `;` 切分结果逐项比对 | +| `--mirror-only` 不含官方源、`--offline` 空串 | `internal/backend` 表驱动子用例 | +| `UV_*` 注入面无变化 | 既有 T12.7 测试不回退;新增断言宿主同名 `AUTO_MAS_*` 被清除 | +| 池目录新分类 | **未覆盖**,见第 6 节 | + +## 6. 本次不做:MaaFW 池目录的重新分类 + +T13.5 条目后半段要求把 `config/maafw_runtime_pool/`、`config/maafw_agent_venvs/` 里的 +venv/解释器/缓存改归「可重建」并纳入 `repair` / `cleanup`。本次**不做**,理由: + +1. `config/` 现在是 `ProtectedRootDirs` 里的受保护根,要在它内部开一个可删除子树, + 属于删除安全边界的实质放宽,应当独立设计、独立审查; +2. 这两个目录名来自尚未合入的 MaaFW 内置层分支(`work/maafw-embedded-20260830`), + 在它落地前按名字删 `config/` 下的东西就是红线第 6 条说的「猜测」; +3. 与本次的注入面没有代码耦合,拆开合入不产生冲突。 + +因此 T13.5 在任务拆分里保持 `🚧`,只勾注入这一半,并把该半段作为遗留项登记。 + +--- + +## 7. 实施计划 + +> 每个 Task 一个提交;先红灯(确认失败原因正确)再最小实现。 +> 每条 `-run` 定向验证都要同时确认「退出码 0」「无 `[no tests to run]`」「出现 `--- PASS:`」。 + +### Task 1 — `internal/uv` 注入四个受控键 + +- 文件:`internal/uv/runner.go`、`internal/uv/managed.go`、`internal/uv/managed_test.go` +- 红灯: + - `TestManaged_InjectsInfrastructureAndMirrorSources` —— 断言四键存在、目录被 Clean、 + 列表按 `;` 有序、`AUTO_MAS_UV_CACHE_DIR` 等于 `UV_CACHE_DIR`; + - `TestManaged_InfrastructureKeysAreControlled` —— 宿主与 `RunOptions.Environment` + 里大小写混合的同名变量都被清除并被受控值覆盖; + - `TestManaged_RejectsInvalidInfrastructure` —— 相对路径、含 `;` 的源都失败关闭。 +- 绿灯:常量 + `SupervisionInfrastructure` + `resolveSupervisionInfrastructure`。 +- 验证:`go test ./internal/uv -run '^TestManaged_' -count=1` + +### Task 2 — `internal/backend` 解析 plan 并传入 + +- 文件:`internal/backend/types.go`、`supervisor.go`、`control.go`、 + `production_windows.go`、`supervisor_test.go`、`control_test.go`、`development_test.go` +- 红灯: + - `TestBackendManaged_PassesInfrastructureAndMirrorPlan`(managed,无控制路径); + - `TestBackendDevelopment_PassesInfrastructureAndMirrorPlan`(development,受控路径); + - `TestBackendSupervision_MirrorPolicyShapesInjectedSources`(表驱动:默认 / 显式首选 / + `--mirror-only` / `--offline` / 非法首选 → `INVALID_ARGUMENT`)。 +- 验证:`go test ./internal/backend -run '^TestBackend(Managed|Development|Supervision)_' -count=1` + +### Task 3 — CLI 接线与 E2E 落盘证明 + +- 文件:`internal/cli/options.go`、`cli.go`、`backend.go`、`backend_test.go`、 + `testdata/fakebackend/main.go`、`internal/backend/e2e_windows_test.go` +- 红灯: + - `TestBackendSupervise_PassesMirrorPolicyToFactory`(CLI 把全局 policy 交给工厂); + - E2E `assertE2EBackendInfrastructureEnvironment` —— 假后端把自己进程里读到的四个变量 + 写进文件,证明 uv 之后的**真实子进程**确实收到,而不只是父进程的 `StartSpec` 里有。 +- 验证:`go test ./internal/cli -run '^TestBackendSupervise_' -count=1`、 + `go test ./internal/backend -run '^TestBackendE2E_Lifecycle' -count=1` + +### 收尾 + +标准验证门全绿 + `go test -race ./... -count=1`(涉及子进程环境组装),回写 `doc/任务拆分.md`。 From 25a04fbdd7b9ea81fc48193ca9b6f487af91602a Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 22:37:26 +0200 Subject: [PATCH 26/57] =?UTF-8?q?feat(uv):=20=E5=8F=97=E7=AE=A1=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E6=B3=A8=E5=85=A5=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD?= =?UTF-8?q?=E4=B8=8E=E6=9C=89=E5=BA=8F=E9=95=9C=E5=83=8F=E6=BA=90=20(T13.5?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/uv/managed.go | 94 ++++++++++++++- internal/uv/managed_test.go | 229 ++++++++++++++++++++++++++++++++++++ internal/uv/runner.go | 13 ++ 3 files changed, 335 insertions(+), 1 deletion(-) diff --git a/internal/uv/managed.go b/internal/uv/managed.go index 9db42e8..c147953 100644 --- a/internal/uv/managed.go +++ b/internal/uv/managed.go @@ -3,6 +3,8 @@ package uv import ( "context" "errors" + "fmt" + "path/filepath" "strings" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/process" @@ -15,10 +17,24 @@ type SupervisionIdentity struct { Commit string } +// SupervisionInfrastructure 描述按增补 1 C11 下发给后端的受管基础设施事实。 +// +// 四项在 managed 与 development 两种模式下都会被注入。两个目录留空时回退到本次 +// uv 调用实际解析出的受管目录,保证下发值与 UV_CACHE_DIR / UV_PYTHON_INSTALL_DIR +// 永远同源——C11 要的是「共用一份缓存与一份解释器」,两者一旦分叉契约即失效。 +// 两个源列表按 Runtime 解析后的尝试顺序排列,空切片对应 --offline 的空串注入。 +type SupervisionInfrastructure struct { + UVCacheDir string + PythonInstallDir string + PackageIndexSources []string + PythonSources []string +} + // ManagedOptions 把通用 uv 选项与长驻监督身份策略分开,避免调用方直接拼受控环境键。 type ManagedOptions struct { RunOptions - Identity *SupervisionIdentity + Identity *SupervisionIdentity + Infrastructure SupervisionInfrastructure } // StartManaged 复用 UVRunner 的路径与环境策略启动长驻 uv,且不提供普通 exec 降级。 @@ -68,6 +84,19 @@ func (r *UVRunner) StartManaged( supervision[autoMASVersion] = options.Identity.Version supervision[autoMASCommit] = options.Identity.Commit } + infrastructure, err := resolveSupervisionInfrastructure(resolved, options.Infrastructure) + if err != nil { + return nil, newError( + protocol.CodeUVExecFailed, + options.Stage, + "uv 执行失败", + map[string]any{}, + err, + ) + } + for key, value := range infrastructure { + supervision[key] = value + } if err := validateRunnerPaths(resolved); err != nil { return nil, newError( protocol.CodeUVExecFailed, @@ -99,6 +128,69 @@ func (r *UVRunner) StartManaged( return managed, nil } +// resolveSupervisionInfrastructure 把 C11 的四个受控键解析成最终注入值。 +// +// 目录必须是绝对路径并被规范化;源列表逐项校验后用 `;` 连接,空列表得到空串 +// 但键仍然存在(契约表两列都是「必填」,空串是合法取值、缺键不是)。 +func resolveSupervisionInfrastructure( + resolved resolvedRunOptions, + requested SupervisionInfrastructure, +) (map[string]string, error) { + cacheDir := requested.UVCacheDir + if cacheDir == "" { + cacheDir = resolved.CacheDir + } + pythonInstallDir := requested.PythonInstallDir + if pythonInstallDir == "" { + pythonInstallDir = resolved.PythonInstallDir + } + cleanCacheDir, err := canonicalSupervisionDirectory(cacheDir) + if err != nil { + return nil, fmt.Errorf("resolve supervised uv cache directory: %w", err) + } + cleanPythonInstallDir, err := canonicalSupervisionDirectory(pythonInstallDir) + if err != nil { + return nil, fmt.Errorf("resolve supervised python install directory: %w", err) + } + packageIndex, err := joinSupervisionMirrorSources(requested.PackageIndexSources) + if err != nil { + return nil, fmt.Errorf("resolve supervised package index sources: %w", err) + } + python, err := joinSupervisionMirrorSources(requested.PythonSources) + if err != nil { + return nil, fmt.Errorf("resolve supervised python sources: %w", err) + } + return map[string]string{ + autoMASUVCacheDir: cleanCacheDir, + autoMASUVPythonInstallDir: cleanPythonInstallDir, + autoMASMirrorPackageIndex: packageIndex, + autoMASMirrorPython: python, + }, nil +} + +func canonicalSupervisionDirectory(path string) (string, error) { + if path == "" || strings.ContainsRune(path, '\x00') { + return "", errors.New("supervised directory is invalid") + } + cleaned := filepath.Clean(path) + if !filepath.IsAbs(cleaned) { + return "", errors.New("supervised directory must be absolute") + } + return cleaned, nil +} + +func joinSupervisionMirrorSources(sources []string) (string, error) { + for _, source := range sources { + if source == "" || strings.ContainsRune(source, '\x00') { + return "", errors.New("supervised mirror source is empty") + } + if strings.Contains(source, mirrorSourceSeparator) { + return "", errors.New("supervised mirror source must not contain the list separator") + } + } + return strings.Join(sources, mirrorSourceSeparator), nil +} + func validateSupervisionIdentity(identity SupervisionIdentity) error { if !validSupervisionVersion(identity.Version) { return errors.New("managed supervision version is invalid") diff --git a/internal/uv/managed_test.go b/internal/uv/managed_test.go index 0c074a1..f5ba829 100644 --- a/internal/uv/managed_test.go +++ b/internal/uv/managed_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "path/filepath" + "slices" "strings" "sync" "testing" @@ -392,3 +393,231 @@ func TestManaged_StartFailureIncludesStableDiagnostics(t *testing.T) { t.Fatalf("StartManaged() details = %#v, want windowsError", details) } } + +// TestManaged_InjectsInfrastructureAndMirrorSources 锁定增补 1 C11 的四个注入变量: +// 两个目录是规范化绝对路径且与 uv 自己使用的目录同源,两个镜像列表按 `;` 保序。 +func TestManaged_InjectsInfrastructureAndMirrorSources(t *testing.T) { + runner := newTestRunner(t) + packageIndex := []string{ + "https://mirrors.aliyun.com/pypi/simple/", + "https://pypi.tuna.tsinghua.edu.cn/simple/", + "https://pypi.org/simple/", + } + pythonSources := []string{ + "https://gh-proxy.com/https://github.com/astral-sh/python-build-standalone/releases/download", + "https://github.com/astral-sh/python-build-standalone/releases/download", + } + recordPath := filepath.Join(t.TempDir(), "managed-infrastructure-record.txt") + managed, err := runner.StartManaged(t.Context(), []string{ + "-test.run=^TestFakeUVProcess$", + }, ManagedOptions{ + RunOptions: RunOptions{ + Stage: protocol.StageBackendSpawn, + Environment: map[string]string{"FAKE_UV_RECORD": recordPath}, + }, + Infrastructure: SupervisionInfrastructure{ + // 刻意传未清理的形态,证明注入值经过 filepath.Clean。 + UVCacheDir: filepath.Join(runner.CacheDir, "sub", ".."), + PythonInstallDir: runner.PythonInstallDir, + PackageIndexSources: packageIndex, + PythonSources: pythonSources, + }, + }, nil) + if err != nil { + t.Fatalf("StartManaged() error = %v", err) + } + waitManagedProcess(t, managed) + record := readTestRecord(t, recordPath) + for key, want := range map[string]string{ + autoMASUVCacheDir: filepath.Clean(runner.CacheDir), + autoMASUVPythonInstallDir: filepath.Clean(runner.PythonInstallDir), + autoMASMirrorPackageIndex: strings.Join(packageIndex, ";"), + autoMASMirrorPython: strings.Join(pythonSources, ";"), + } { + if got := record[key]; got != want { + t.Errorf("environment[%q] = %q, want %q", key, got, want) + } + } + // C11 的目的是「共用一份缓存与一份解释器」:下发值一旦与 uv 自己用的目录 + // 分叉,这条契约就名存实亡,因此把等式本身锁进测试。 + if record[autoMASUVCacheDir] != record[uvCacheDirEnv] { + t.Errorf("AUTO_MAS_UV_CACHE_DIR = %q, want the same value as UV_CACHE_DIR %q", + record[autoMASUVCacheDir], record[uvCacheDirEnv]) + } + if record[autoMASUVPythonInstallDir] != record[uvPythonInstallDirEnv] { + t.Errorf("AUTO_MAS_UV_PYTHON_INSTALL_DIR = %q, want the same value as UV_PYTHON_INSTALL_DIR %q", + record[autoMASUVPythonInstallDir], record[uvPythonInstallDirEnv]) + } + if got := strings.Split(record[autoMASMirrorPackageIndex], ";"); !slices.Equal(got, packageIndex) { + t.Errorf("package index sources = %#v, want %#v in plan order", got, packageIndex) + } +} + +// TestManaged_InjectsEmptyMirrorListsWhenOffline 锁定 --offline 的取值形态: +// 键必须存在且为空串,而不是缺席——契约表两列都写「必填」。 +func TestManaged_InjectsEmptyMirrorListsWhenOffline(t *testing.T) { + runner := newTestRunner(t) + recordPath := filepath.Join(t.TempDir(), "managed-offline-record.txt") + managed, err := runner.StartManaged(t.Context(), []string{ + "-test.run=^TestFakeUVProcess$", + }, ManagedOptions{ + RunOptions: RunOptions{ + Stage: protocol.StageBackendSpawn, + Environment: map[string]string{"FAKE_UV_RECORD": recordPath}, + }, + Infrastructure: SupervisionInfrastructure{ + UVCacheDir: runner.CacheDir, + PythonInstallDir: runner.PythonInstallDir, + }, + }, nil) + if err != nil { + t.Fatalf("StartManaged() error = %v", err) + } + waitManagedProcess(t, managed) + record := readTestRecord(t, recordPath) + for _, key := range []string{autoMASMirrorPackageIndex, autoMASMirrorPython} { + value, ok := record[key] + if !ok { + t.Errorf("environment is missing %q, want the key with an empty value", key) + continue + } + if value != "" { + t.Errorf("environment[%q] = %q, want an empty string", key, value) + } + } +} + +// TestManaged_InfrastructureKeysAreControlled 证明四个键属于受控监督集合: +// 宿主与 RunOptions.Environment 里的同名项(含大小写变体)都被清除并被受控值覆盖。 +func TestManaged_InfrastructureKeysAreControlled(t *testing.T) { + runner := newTestRunner(t) + t.Setenv(autoMASUVCacheDir, `C:\host\stale-cache`) + t.Setenv(autoMASMirrorPackageIndex, "https://host.example/simple/") + recordPath := filepath.Join(t.TempDir(), "managed-controlled-record.txt") + managed, err := runner.StartManaged(t.Context(), []string{ + "-test.run=^TestFakeUVProcess$", + }, ManagedOptions{ + RunOptions: RunOptions{ + Stage: protocol.StageBackendSpawn, + Environment: map[string]string{ + "FAKE_UV_RECORD": recordPath, + strings.ToLower(autoMASUVCacheDir): `C:\option\stale-cache`, + strings.ToLower(autoMASUVPythonInstallDir): `C:\option\stale-python`, + autoMASMirrorPackageIndex: "https://option.example/simple/", + autoMASMirrorPython: "https://option.example/python", + }, + }, + Infrastructure: SupervisionInfrastructure{ + UVCacheDir: runner.CacheDir, + PythonInstallDir: runner.PythonInstallDir, + PackageIndexSources: []string{"https://pypi.org/simple/"}, + PythonSources: []string{"https://github.com/astral-sh/python-build-standalone/releases/download"}, + }, + }, nil) + if err != nil { + t.Fatalf("StartManaged() error = %v", err) + } + waitManagedProcess(t, managed) + record := readTestRecord(t, recordPath) + for key, want := range map[string]string{ + autoMASUVCacheDir: filepath.Clean(runner.CacheDir), + autoMASUVPythonInstallDir: filepath.Clean(runner.PythonInstallDir), + autoMASMirrorPackageIndex: "https://pypi.org/simple/", + autoMASMirrorPython: "https://github.com/astral-sh/python-build-standalone/releases/download", + } { + if got := record[key]; got != want { + t.Errorf("environment[%q] = %q, want the controlled value %q", key, got, want) + } + } + for key := range record { + if strings.EqualFold(key, autoMASUVCacheDir) && key != autoMASUVCacheDir { + t.Errorf("environment contains case variant %q of a controlled supervision key", key) + } + } +} + +// TestManaged_InfrastructureFallsBackToResolvedDirectories 证明调用方不传目录时 +// 回退到本次 uv 调用实际解析出的受管目录,而不是留空破坏契约。 +func TestManaged_InfrastructureFallsBackToResolvedDirectories(t *testing.T) { + runner := newTestRunner(t) + overrideCache := t.TempDir() + overridePython := t.TempDir() + recordPath := filepath.Join(t.TempDir(), "managed-fallback-record.txt") + managed, err := runner.StartManaged(t.Context(), []string{ + "-test.run=^TestFakeUVProcess$", + }, ManagedOptions{ + RunOptions: RunOptions{ + Stage: protocol.StageBackendSpawn, + CacheDir: overrideCache, + PythonInstallDir: overridePython, + Environment: map[string]string{"FAKE_UV_RECORD": recordPath}, + }, + }, nil) + if err != nil { + t.Fatalf("StartManaged() error = %v", err) + } + waitManagedProcess(t, managed) + record := readTestRecord(t, recordPath) + if got, want := record[autoMASUVCacheDir], filepath.Clean(overrideCache); got != want { + t.Errorf("AUTO_MAS_UV_CACHE_DIR = %q, want the resolved cache dir %q", got, want) + } + if got, want := record[autoMASUVPythonInstallDir], filepath.Clean(overridePython); got != want { + t.Errorf("AUTO_MAS_UV_PYTHON_INSTALL_DIR = %q, want the resolved python install dir %q", got, want) + } +} + +// TestManaged_RejectsInvalidInfrastructure 覆盖失败关闭:相对目录与含 `;` 的源 +// 都在 spawn 之前被拒绝,绝不下发一个调用方无法正确切分的列表。 +func TestManaged_RejectsInvalidInfrastructure(t *testing.T) { + tests := []struct { + name string + infrastructure SupervisionInfrastructure + }{ + { + name: "relative cache dir", + infrastructure: SupervisionInfrastructure{UVCacheDir: `relative\cache`}, + }, + { + name: "relative python install dir", + infrastructure: SupervisionInfrastructure{PythonInstallDir: `relative\python`}, + }, + { + name: "package index source contains separator", + infrastructure: SupervisionInfrastructure{ + PackageIndexSources: []string{"https://mirror.example/simple/;https://other.example/simple/"}, + }, + }, + { + name: "python source is empty", + infrastructure: SupervisionInfrastructure{PythonSources: []string{""}}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + runner := newTestRunner(t) + managed, err := runner.StartManaged(t.Context(), []string{"run"}, ManagedOptions{ + RunOptions: RunOptions{Stage: protocol.StageBackendSpawn}, + Infrastructure: test.infrastructure, + }, nil) + if managed != nil || err == nil { + t.Fatalf("StartManaged() = %#v, %v, want validation error", managed, err) + } + }) + } +} + +func waitManagedProcess(t *testing.T, managed *process.ManagedProcess) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + result, err := managed.Wait(ctx) + if err != nil || result.ExitCode != 0 { + t.Fatalf("Wait() = %#v, %v, want exit 0", result, err) + } + if err := managed.WaitEmpty(ctx); err != nil { + t.Fatalf("WaitEmpty() error = %v", err) + } + if err := managed.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } +} diff --git a/internal/uv/runner.go b/internal/uv/runner.go index 7a99199..f69b54f 100644 --- a/internal/uv/runner.go +++ b/internal/uv/runner.go @@ -32,6 +32,15 @@ const ( autoMASVersion = "AUTO_MAS_EXPECTED_VERSION" autoMASCommit = "AUTO_MAS_EXPECTED_COMMIT" autoMASSupervised = "AUTO_MAS_SUPERVISED" + // 以下四个键按增补 1 C11 下发受管基础设施与有序镜像源,与上面五个身份键 + // 同属受监督进程的环境契约:宿主同名变量被清除,调用方也不能经 + // RunOptions.Environment 覆盖。 + autoMASUVCacheDir = "AUTO_MAS_UV_CACHE_DIR" + autoMASUVPythonInstallDir = "AUTO_MAS_UV_PYTHON_INSTALL_DIR" + autoMASMirrorPackageIndex = "AUTO_MAS_MIRROR_PACKAGE_INDEX" + autoMASMirrorPython = "AUTO_MAS_MIRROR_PYTHON" + // mirrorSourceSeparator 是有序源列表的分隔符;单个源里不得出现它。 + mirrorSourceSeparator = ";" autoMASTelemetry = "AUTO_MAS_TELEMETRY" autoMASSentryDSN = "AUTO_MAS_SENTRY_DSN" autoMASSentryEnv = "AUTO_MAS_SENTRY_ENVIRONMENT" @@ -540,6 +549,10 @@ func canonicalSupervisionEnvironmentKey(key string) (string, bool) { autoMASVersion, autoMASCommit, autoMASSupervised, + autoMASUVCacheDir, + autoMASUVPythonInstallDir, + autoMASMirrorPackageIndex, + autoMASMirrorPython, } { if strings.EqualFold(key, managed) { return managed, true From 8918e4b105f738c31b7c0aa121b49bbb19d918ea Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 22:44:14 +0200 Subject: [PATCH 27/57] =?UTF-8?q?feat(backend):=20=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E6=9C=89=E5=BA=8F=E9=95=9C=E5=83=8F=E6=BA=90=E5=B9=B6=E9=9A=8F?= =?UTF-8?q?=E5=8F=97=E7=AE=A1=E7=8E=AF=E5=A2=83=E4=B8=8B=E5=8F=91=20(T13.5?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/backend/control.go | 5 +- internal/backend/development_test.go | 36 +++++ internal/backend/e2e_windows_test.go | 13 +- internal/backend/production_other.go | 2 + internal/backend/production_windows.go | 11 +- internal/backend/supervisor.go | 75 +++++++++- internal/backend/supervisor_test.go | 194 +++++++++++++++++++++++-- internal/backend/types.go | 4 + internal/cli/backend.go | 1 + internal/cli/backend_test.go | 17 ++- internal/cli/cli.go | 4 +- internal/cli/contract_backend_test.go | 3 +- internal/cli/options.go | 3 + internal/cli/telemetry_test.go | 5 +- 14 files changed, 339 insertions(+), 34 deletions(-) diff --git a/internal/backend/control.go b/internal/backend/control.go index 7bfca26..9db16de 100644 --- a/internal/backend/control.go +++ b/internal/backend/control.go @@ -1305,8 +1305,9 @@ func (s *ManagedSupervisor) startControlAttempt(ctx context.Context, request Req return nil, errors.Join(ctxErr, cleanupErr) } proc, err := s.deps.UV.StartManaged(ctx, []string{"run", "--project", projectDir, "--no-sync", entryArgument}, uv.ManagedOptions{ - RunOptions: uv.RunOptions{Stage: protocol.StageBackendSpawn, WorkingDir: workingDir, ProjectDir: projectDir, ProjectEnvDir: projectEnvDir}, - Identity: identity, + RunOptions: uv.RunOptions{Stage: protocol.StageBackendSpawn, WorkingDir: workingDir, ProjectDir: projectDir, ProjectEnvDir: projectEnvDir}, + Identity: identity, + Infrastructure: s.infrastructure, }, s.streamSink(request, logger, gate)) if err != nil || proc == nil { fault := gate.Fault() diff --git a/internal/backend/development_test.go b/internal/backend/development_test.go index c741485..214ddde 100644 --- a/internal/backend/development_test.go +++ b/internal/backend/development_test.go @@ -12,6 +12,7 @@ import ( "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/config" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/health" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/uv" ) @@ -408,6 +409,41 @@ func TestBackendDevelopment_RejectsRuntimeRootInsideRepoBeforeSideEffects(t *tes } } +// TestBackendDevelopment_PassesInfrastructureAndMirrorPlan 证明增补 1 C11 的四项 +// 在 development 下同样注入:契约表两列都是「必填」,不能只服务 managed。 +// 两个目录必须仍是**受管**目录,而不是开发源码目录里的 .venv。 +func TestBackendDevelopment_PassesInfrastructureAndMirrorPlan(t *testing.T) { + f := newBackendFixture(t) + f.mirrorPolicy = testMirrorPolicy(t, mirror.PolicySpec{}) + repo := newDevelopmentRepo(t) + f.proc.keepAlive = true + mailbox := NewControlMailbox(8) + t.Cleanup(mailbox.Close) + request := developmentRequest(f.request(), repo) + request.Control = mailbox + done := make(chan error, 1) + go func() { done <- f.supervisor().Supervise(t.Context(), request) }() + waitFor(t, f.emitter.running) + if err := mailbox.Submit(t.Context(), protocol.ControlCommand{ + Command: protocol.ControlShutdown, + CommandID: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + }); err != nil { + t.Fatalf("Submit(shutdown) error = %v", err) + } + if err := <-done; err != nil { + t.Fatalf("Supervise() error = %v, want nil", err) + } + infrastructure := f.uv.options.Infrastructure + if got, want := infrastructure.UVCacheDir, f.layout.UVCacheDir(); got != want { + t.Errorf("development UVCacheDir = %q, want the managed cache %q", got, want) + } + if got, want := infrastructure.PythonInstallDir, f.layout.PythonDir(); got != want { + t.Errorf("development PythonInstallDir = %q, want the managed python dir %q", got, want) + } + assertMirrorSourcesMatchPlan(t, f.mirrorPolicy, mirror.KindPackageIndex, infrastructure.PackageIndexSources) + assertMirrorSourcesMatchPlan(t, f.mirrorPolicy, mirror.KindPython, infrastructure.PythonSources) +} + func developmentRequest(request Request, repo string) Request { request.Mode = ModeDevelopment request.DevelopmentRepo = repo diff --git a/internal/backend/e2e_windows_test.go b/internal/backend/e2e_windows_test.go index 3bf0d6b..1be3a5e 100644 --- a/internal/backend/e2e_windows_test.go +++ b/internal/backend/e2e_windows_test.go @@ -21,6 +21,7 @@ import ( "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/config" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/health" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/lock" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/state" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/uv" @@ -438,7 +439,11 @@ func newBackendE2EFixture(t *testing.T, configValue backendE2EConfig) *backendE2 if err := assertE2EPortBindable(); err != nil { t.Fatalf("port 36163 cannot bind before fixture: %v", err) } - supervisor, err := NewProductionManagedSupervisor(t.Context(), layout, io.Discard, time.Now) + mirrorPolicy, err := mirror.NewPolicy(mirror.PolicySpec{}) + if err != nil { + t.Fatalf("mirror.NewPolicy() error = %v", err) + } + supervisor, err := NewProductionManagedSupervisor(t.Context(), layout, io.Discard, time.Now, mirrorPolicy) if err != nil { t.Fatalf("NewProductionManagedSupervisor() error = %v", err) } @@ -1001,7 +1006,11 @@ func TestBackendE2E_RuntimeTerminationLeavesNoDescendants(t *testing.T) { // 并在正常关闭时删除它。 t.Setenv(e2eFakeBackendEnv, filepath.Join(signal.Root, "backend-config.json")) t.Setenv(e2eFakeUVConfigEnv, filepath.Join(signal.Root, "uv-config.json")) - supervisor, err := NewProductionManagedSupervisor(t.Context(), layout, io.Discard, time.Now) + recoveryPolicy, err := mirror.NewPolicy(mirror.PolicySpec{}) + if err != nil { + t.Fatalf("mirror.NewPolicy() error = %v", err) + } + supervisor, err := NewProductionManagedSupervisor(t.Context(), layout, io.Discard, time.Now, recoveryPolicy) if err != nil { t.Fatalf("NewProductionManagedSupervisor(recovery) error = %v", err) } diff --git a/internal/backend/production_other.go b/internal/backend/production_other.go index 7d60c55..3e013d1 100644 --- a/internal/backend/production_other.go +++ b/internal/backend/production_other.go @@ -8,6 +8,7 @@ import ( "time" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/config" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" ) @@ -17,6 +18,7 @@ func NewProductionManagedSupervisor( *config.Layout, io.Writer, func() time.Time, + mirror.Policy, ) (*ManagedSupervisor, error) { return nil, newError(protocol.CodeUnsupportedMode, protocol.StageBackendSpawn, "受管后端监督不支持当前平台", map[string]any{"reason": "platform_unsupported"}, nil) } diff --git a/internal/backend/production_windows.go b/internal/backend/production_windows.go index 67bcb73..6815c5f 100644 --- a/internal/backend/production_windows.go +++ b/internal/backend/production_windows.go @@ -12,6 +12,7 @@ import ( "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/gitrepo" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/health" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/logging" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/state" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/uv" ) @@ -22,6 +23,7 @@ func NewProductionManagedSupervisor( layout *config.Layout, stderr io.Writer, clock func() time.Time, + mirrorPolicy mirror.Policy, ) (*ManagedSupervisor, error) { if ctx == nil || layout == nil || stderr == nil { return nil, errors.New("production backend arguments are invalid") @@ -73,10 +75,11 @@ func NewProductionManagedSupervisor( } return productionLogger{logger: logger}, nil }, - Clock: clock, - UVPath: uvExecutable, - PythonPaths: []string{layout.VenvPythonExecutable(), layout.PythonExecutable()}, - PID: state.NewSystemPIDProbe(), + Clock: clock, + UVPath: uvExecutable, + PythonPaths: []string{layout.VenvPythonExecutable(), layout.PythonExecutable()}, + PID: state.NewSystemPIDProbe(), + MirrorPolicy: mirrorPolicy, } return NewManagedSupervisor(layout, deps) } diff --git a/internal/backend/supervisor.go b/internal/backend/supervisor.go index f08806c..5808484 100644 --- a/internal/backend/supervisor.go +++ b/internal/backend/supervisor.go @@ -3,6 +3,7 @@ package backend import ( "context" "errors" + "fmt" "path/filepath" "strings" "sync" @@ -10,6 +11,7 @@ import ( "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/config" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/health" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/process" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/state" @@ -22,6 +24,10 @@ const cleanupTimeout = 30 * time.Second type ManagedSupervisor struct { layout *config.Layout deps Dependencies + // infrastructure 是按增补 1 C11 下发给后端的受管基础设施。layout 与 + // MirrorPolicy 在 supervisor 生命周期内不变,因此只在构造期解析一次, + // 首次启动与单次自动重启、managed 与 development 都读同一份值。 + infrastructure uv.SupervisionInfrastructure } // NewManagedSupervisor 创建可按请求选择 managed 或 development 的后端监督器。 @@ -54,7 +60,71 @@ func NewManagedSupervisor(layout *config.Layout, deps Dependencies) (*ManagedSup if deps.UVPath == "" || deps.PythonPath == "" { return nil, errors.New("backend process identity paths are incomplete") } - return &ManagedSupervisor{layout: layout, deps: deps}, nil + infrastructure, err := supervisionInfrastructure(layout, deps.MirrorPolicy) + if err != nil { + return nil, err + } + return &ManagedSupervisor{layout: layout, deps: deps, infrastructure: infrastructure}, nil +} + +// supervisionInfrastructure 解析增补 1 C11 下发给后端的受管基础设施。 +// +// 这里是 Runtime 里唯一同时持有 layout 与 mirror.Policy 的位置:目录取自 layout, +// 两个有序源列表由 mirror.BuildPlan 给出——plan 的顺序**就是**尝试顺序 +// (显式首选最前、目录顺序其次、官方源末位),`--mirror-only` 不含官方源, +// `--offline` 得到空列表。本任务因此不需要任何新的 mirror API。 +func supervisionInfrastructure( + layout *config.Layout, + policy mirror.Policy, +) (uv.SupervisionInfrastructure, error) { + catalog, err := mirror.DefaultCatalog() + if err != nil { + return uv.SupervisionInfrastructure{}, fmt.Errorf("build backend mirror catalog: %w", err) + } + packageIndex, err := mirrorSources(catalog, policy, mirror.KindPackageIndex) + if err != nil { + return uv.SupervisionInfrastructure{}, err + } + python, err := mirrorSources(catalog, policy, mirror.KindPython) + if err != nil { + return uv.SupervisionInfrastructure{}, err + } + return uv.SupervisionInfrastructure{ + UVCacheDir: layout.UVCacheDir(), + PythonInstallDir: layout.PythonDir(), + PackageIndexSources: packageIndex, + PythonSources: python, + }, nil +} + +// mirrorSources 返回单个 Kind 的有序源地址。 +// +// 失败语义与 internal/uv 的网络路径一致:ErrPolicyRejected 表示用户显式指定了一个 +// 选不出来的源,必须失败关闭(静默换源等于无视用户意图);其他错误只说明 Policy +// 本身没被配置(例如零值 Policy),退回目录默认顺序。 +func mirrorSources(catalog *mirror.Catalog, policy mirror.Policy, kind mirror.Kind) ([]string, error) { + plan, err := mirror.BuildPlan(catalog, policy, kind) + if err != nil { + if errors.Is(err, mirror.ErrPolicyRejected) { + return nil, newError(protocol.CodeInvalidArgument, protocol.StageBackendSpawn, "镜像源选择无效", map[string]any{ + "sourceKind": kind.String(), + }, err) + } + defaultPolicy, defaultErr := mirror.NewPolicy(mirror.PolicySpec{Preferred: map[mirror.Kind]string{}}) + if defaultErr != nil { + return nil, fmt.Errorf("build default backend mirror policy: %w", defaultErr) + } + plan, defaultErr = mirror.BuildPlan(catalog, defaultPolicy, kind) + if defaultErr != nil { + return nil, fmt.Errorf("build backend mirror plan: %w", errors.Join(err, defaultErr)) + } + } + sources := plan.Sources() + addresses := make([]string, 0, len(sources)) + for _, source := range sources { + addresses = append(addresses, source.BaseURL()) + } + return addresses, nil } // Supervise 启动并长驻监督指定模式的后端,直到调用方取消或 Job 根进程退出。 @@ -211,7 +281,8 @@ func (s *ManagedSupervisor) Supervise(ctx context.Context, request Request) (ret ProjectDir: s.layout.RepoDir(), Line: nil, }, - Identity: &uv.SupervisionIdentity{Version: revision.Version, Commit: revision.Commit}, + Identity: &uv.SupervisionIdentity{Version: revision.Version, Commit: revision.Commit}, + Infrastructure: s.infrastructure, }, sink) if err != nil || proc == nil { if fault := gate.Fault(); fault != nil { diff --git a/internal/backend/supervisor_test.go b/internal/backend/supervisor_test.go index e80f183..db2e081 100644 --- a/internal/backend/supervisor_test.go +++ b/internal/backend/supervisor_test.go @@ -13,6 +13,7 @@ import ( "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/config" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/health" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/logging" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/process" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/state" @@ -605,6 +606,7 @@ type backendFixture struct { pid *fakePID depsHTTP HTTPCloser shutdownTimeout time.Duration + mirrorPolicy mirror.Policy } func newBackendFixture(t *testing.T) *backendFixture { @@ -634,18 +636,19 @@ func newBackendFixture(t *testing.T) *backendFixture { func (f *backendFixture) supervisor() *ManagedSupervisor { f.t.Helper() s, err := NewManagedSupervisor(f.layout, Dependencies{ - Lock: f.lock, - State: f.state, - Repository: f.repository, - Entry: f.entry, - UV: f.uv, - Health: f.health, - Logger: func(context.Context, Request) (Logger, error) { return f.logger, f.loggerErr }, - Clock: func() time.Time { return time.Unix(1, 0).UTC() }, - UVPath: "uv.exe", - PythonPath: "python.exe", - PID: f.pid, - NewTimer: func(time.Duration) Timer { return immediateTimer{} }, + Lock: f.lock, + State: f.state, + Repository: f.repository, + Entry: f.entry, + UV: f.uv, + Health: f.health, + Logger: func(context.Context, Request) (Logger, error) { return f.logger, f.loggerErr }, + Clock: func() time.Time { return time.Unix(1, 0).UTC() }, + UVPath: "uv.exe", + PythonPath: "python.exe", + PID: f.pid, + NewTimer: func(time.Duration) Timer { return immediateTimer{} }, + MirrorPolicy: f.mirrorPolicy, }) if err != nil { f.t.Fatalf("NewManagedSupervisor() error = %v", err) @@ -1232,3 +1235,170 @@ func (immediateTimer) Stop() bool { return true } func (e *fakeCodeError) Error() string { return string(e.code) } func (e *fakeCodeError) Code() protocol.Code { return e.code } + +// TestBackendManaged_PassesInfrastructureAndMirrorPlan 锁定增补 1 C11 的数据流: +// backend 是唯一同时持有 layout 与 mirror.Policy 的地方,因此由它解析出两个 +// 有序源列表和两个受管目录,再交给 StartManaged 注入。 +func TestBackendManaged_PassesInfrastructureAndMirrorPlan(t *testing.T) { + f := newBackendFixture(t) + f.mirrorPolicy = testMirrorPolicy(t, mirror.PolicySpec{}) + options := runManagedSpawnForInfrastructure(t, f) + if got, want := options.Infrastructure.UVCacheDir, f.layout.UVCacheDir(); got != want { + t.Errorf("UVCacheDir = %q, want %q", got, want) + } + if got, want := options.Infrastructure.PythonInstallDir, f.layout.PythonDir(); got != want { + t.Errorf("PythonInstallDir = %q, want %q", got, want) + } + assertMirrorSourcesMatchPlan(t, f.mirrorPolicy, mirror.KindPackageIndex, options.Infrastructure.PackageIndexSources) + assertMirrorSourcesMatchPlan(t, f.mirrorPolicy, mirror.KindPython, options.Infrastructure.PythonSources) + if last := options.Infrastructure.PackageIndexSources[len(options.Infrastructure.PackageIndexSources)-1]; last != "https://pypi.org/simple/" { + t.Errorf("last package index source = %q, want the official source", last) + } +} + +// TestBackendSupervision_MirrorPolicyShapesInjectedSources 覆盖策略矩阵: +// 顺序、--mirror-only 去掉官方源、--offline 空列表,以及零值 Policy 的默认回退。 +func TestBackendSupervision_MirrorPolicyShapesInjectedSources(t *testing.T) { + tests := []struct { + name string + spec *mirror.PolicySpec + verify func(*testing.T, uv.SupervisionInfrastructure) + }{ + { + name: "explicit preference first", + spec: &mirror.PolicySpec{Preferred: map[mirror.Kind]string{mirror.KindPackageIndex: "ustc"}}, + verify: func(t *testing.T, infrastructure uv.SupervisionInfrastructure) { + t.Helper() + if got := infrastructure.PackageIndexSources[0]; got != "https://pypi.mirrors.ustc.edu.cn/simple/" { + t.Errorf("first package index source = %q, want the explicitly preferred source", got) + } + }, + }, + { + name: "mirror only drops the official source", + spec: &mirror.PolicySpec{MirrorOnly: true}, + verify: func(t *testing.T, infrastructure uv.SupervisionInfrastructure) { + t.Helper() + for _, source := range infrastructure.PackageIndexSources { + if source == "https://pypi.org/simple/" { + t.Errorf("package index sources = %#v, want no official source under --mirror-only", infrastructure.PackageIndexSources) + } + } + for _, source := range infrastructure.PythonSources { + if source == "https://github.com/astral-sh/python-build-standalone/releases/download" { + t.Errorf("python sources = %#v, want no official source under --mirror-only", infrastructure.PythonSources) + } + } + }, + }, + { + name: "offline yields empty lists", + spec: &mirror.PolicySpec{Offline: true}, + verify: func(t *testing.T, infrastructure uv.SupervisionInfrastructure) { + t.Helper() + if len(infrastructure.PackageIndexSources) != 0 || len(infrastructure.PythonSources) != 0 { + t.Errorf("offline sources = %#v / %#v, want empty lists", + infrastructure.PackageIndexSources, infrastructure.PythonSources) + } + }, + }, + { + // 零值 Policy 不是「用户传了非法参数」,而是调用方没配置;与 + // internal/uv 其他网络路径一致地退回目录默认顺序,不失败关闭。 + name: "zero value policy falls back to catalog order", + spec: nil, + verify: func(t *testing.T, infrastructure uv.SupervisionInfrastructure) { + t.Helper() + assertMirrorSourcesMatchPlan(t, testMirrorPolicy(t, mirror.PolicySpec{}), + mirror.KindPackageIndex, infrastructure.PackageIndexSources) + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f := newBackendFixture(t) + if test.spec != nil { + f.mirrorPolicy = testMirrorPolicy(t, *test.spec) + } + options := runManagedSpawnForInfrastructure(t, f) + test.verify(t, options.Infrastructure) + }) + } +} + +// TestBackendSupervision_RejectsUnselectableMirrorPreference 证明失败关闭: +// 用户显式指定了一个选不出来的源时映射 INVALID_ARGUMENT,绝不静默换源。 +// 解析发生在构造期,因此在获取任何 Mutex、事务或日志之前就已经拒绝。 +func TestBackendSupervision_RejectsUnselectableMirrorPreference(t *testing.T) { + f := newBackendFixture(t) + f.mirrorPolicy = testMirrorPolicy(t, mirror.PolicySpec{ + Preferred: map[mirror.Kind]string{mirror.KindPackageIndex: "missing"}, + }) + supervisor, err := NewManagedSupervisor(f.layout, Dependencies{ + Lock: f.lock, + State: f.state, + Repository: f.repository, + Entry: f.entry, + UV: f.uv, + Health: f.health, + Logger: func(context.Context, Request) (Logger, error) { return f.logger, nil }, + UVPath: "uv.exe", + PythonPath: "python.exe", + MirrorPolicy: f.mirrorPolicy, + }) + if supervisor != nil { + t.Fatalf("NewManagedSupervisor() = %#v, want nil", supervisor) + } + assertBackendCode(t, err, protocol.CodeInvalidArgument) + if f.uv.startCalls != 0 { + t.Fatalf("StartManaged calls = %d, want 0", f.uv.startCalls) + } +} + +func runManagedSpawnForInfrastructure(t *testing.T, f *backendFixture) uv.ManagedOptions { + t.Helper() + f.proc.keepAlive = true + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { done <- f.supervisor().Supervise(ctx, f.request()) }() + waitFor(t, f.emitter.running) + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("Supervise() error = %v, want context.Canceled", err) + } + if f.uv.startCalls != 1 { + t.Fatalf("StartManaged calls = %d, want 1", f.uv.startCalls) + } + return f.uv.options +} + +func testMirrorPolicy(t *testing.T, spec mirror.PolicySpec) mirror.Policy { + t.Helper() + policy, err := mirror.NewPolicy(spec) + if err != nil { + t.Fatalf("NewPolicy(%#v) error = %v", spec, err) + } + return policy +} + +// assertMirrorSourcesMatchPlan 用 mirror 自己的 BuildPlan 作为期望值, +// 保证「注入顺序 == Runtime 解析后的尝试顺序」这条契约不靠人工抄写维持。 +func assertMirrorSourcesMatchPlan(t *testing.T, policy mirror.Policy, kind mirror.Kind, got []string) { + t.Helper() + catalog, err := mirror.DefaultCatalog() + if err != nil { + t.Fatalf("DefaultCatalog() error = %v", err) + } + plan, err := mirror.BuildPlan(catalog, policy, kind) + if err != nil { + t.Fatalf("BuildPlan(%s) error = %v", kind, err) + } + want := make([]string, 0, len(plan.Sources())) + for _, source := range plan.Sources() { + want = append(want, source.BaseURL()) + } + if !equalStrings(got, want) { + t.Fatalf("%s sources = %#v, want %#v in plan order", kind, got, want) + } +} diff --git a/internal/backend/types.go b/internal/backend/types.go index c75ae49..6a994a7 100644 --- a/internal/backend/types.go +++ b/internal/backend/types.go @@ -5,6 +5,7 @@ import ( "time" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/health" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/process" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/state" @@ -68,6 +69,9 @@ type Dependencies struct { RestartDelay time.Duration Timer func(time.Duration) <-chan time.Time NewTimer func(time.Duration) Timer + // MirrorPolicy 是已解析的全局镜像策略,用于按增补 1 C11 生成下发给后端的 + // 有序源列表。零值表示调用方未配置,按目录默认顺序处理。 + MirrorPolicy mirror.Policy } // Timer 是可停止的重启等待计时器,避免 timer channel 在收口后泄漏。 diff --git a/internal/cli/backend.go b/internal/cli/backend.go index ddac96a..3a6562f 100644 --- a/internal/cli/backend.go +++ b/internal/cli/backend.go @@ -91,6 +91,7 @@ func backendSuperviseCommand(deps *deps) *cobra.Command { deps.global.layout, deps.io.Err, deps.options.clock, + deps.global.mirrorPolicy, ) if err != nil { return sessionSuccess{}, err diff --git a/internal/cli/backend_test.go b/internal/cli/backend_test.go index 59dc244..6919bfa 100644 --- a/internal/cli/backend_test.go +++ b/internal/cli/backend_test.go @@ -13,6 +13,7 @@ import ( "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/backend" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/config" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" ) @@ -40,7 +41,7 @@ func TestBackendSupervise_RequiresExplicitManagedMode(t *testing.T) { context.Background(), args, IO{In: strings.NewReader(""), Out: &stdout, Err: &stderr}, - WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time) (backendService, error) { + WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time, mirror.Policy) (backendService, error) { factoryCalls++ return backendServiceFunc(func(context.Context, backend.Request) error { return nil }), nil }), @@ -90,7 +91,7 @@ func TestBackendSupervise_ShutdownTimeoutArgument(t *testing.T) { context.Background(), args, IO{In: strings.NewReader(""), Out: &stdout, Err: &stderr}, - WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time) (backendService, error) { + WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time, mirror.Policy) (backendService, error) { return backendServiceFunc(func(_ context.Context, request backend.Request) error { captured = request return nil @@ -129,7 +130,7 @@ func TestBackendSupervise_ShutdownTimeoutArgument(t *testing.T) { "backend", "supervise", "--mode", "managed", "--shutdown-timeout", test.value, }, IO{In: strings.NewReader(""), Out: &stdout, Err: &stderr}, - WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time) (backendService, error) { + WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time, mirror.Policy) (backendService, error) { factoryCalls++ return backendServiceFunc(func(context.Context, backend.Request) error { return nil }), nil }), @@ -166,7 +167,7 @@ func TestBackendDevelopment_CLIResolvesExplicitRepoFromCWD(t *testing.T) { []string{"--app-root", t.TempDir(), "--output", "ndjson", "backend", "supervise", "--mode", "development", "--repo", "source"}, IO{In: strings.NewReader(""), Out: &stdout, Err: &stderr}, WithCWD(cwd), - WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time) (backendService, error) { + WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time, mirror.Policy) (backendService, error) { return backendServiceFunc(func(_ context.Context, request backend.Request) error { captured = request return nil @@ -201,7 +202,7 @@ func TestBackend_ControlReaderJoinAndReadFailure(t *testing.T) { context.Background(), []string{"--app-root", t.TempDir(), "--output", "ndjson", "backend", "supervise", "--mode", "managed"}, IO{In: input, Out: &stdout, Err: &stderr}, - WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time) (backendService, error) { + WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time, mirror.Policy) (backendService, error) { return backendServiceFunc(func(ctx context.Context, request backend.Request) error { close(started) _, err := request.Control.Receive(ctx) @@ -238,7 +239,7 @@ func TestBackend_ControlReaderJoinAndReadFailure(t *testing.T) { context.Background(), []string{"--app-root", t.TempDir(), "--output", "ndjson", "backend", "supervise", "--mode", "managed"}, IO{In: input, Out: &stdout, Err: &stderr}, - WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time) (backendService, error) { + WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time, mirror.Policy) (backendService, error) { return backendServiceFunc(func(ctx context.Context, request backend.Request) error { command, err := request.Control.Receive(ctx) if err != nil { @@ -282,7 +283,7 @@ func TestBackend_ControlReaderJoinAndReadFailure(t *testing.T) { context.Background(), []string{"--app-root", t.TempDir(), "--output", "ndjson", "backend", "supervise", "--mode", "managed"}, IO{In: input, Out: &stdout, Err: &stderr}, - WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time) (backendService, error) { + WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time, mirror.Policy) (backendService, error) { return backendServiceFunc(func(ctx context.Context, request backend.Request) error { for range 3 { command, err := request.Control.Receive(ctx) @@ -314,7 +315,7 @@ func TestBackend_ControlReaderJoinAndReadFailure(t *testing.T) { ctx, []string{"--app-root", t.TempDir(), "--output", "ndjson", "backend", "supervise", "--mode", "managed"}, IO{In: strings.NewReader(""), Out: &stdout, Err: &stderr}, - WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time) (backendService, error) { + WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time, mirror.Policy) (backendService, error) { return backendServiceFunc(func(ctx context.Context, _ backend.Request) error { close(started) <-ctx.Done() diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 1d275f4..b32fb2d 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -14,6 +14,7 @@ import ( "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/doctor" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/gitrepo" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/logging" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/state" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/telemetry" @@ -116,8 +117,9 @@ func applyOptions(values ...Option) (options, error) { layout *config.Layout, stderr io.Writer, clock func() time.Time, + mirrorPolicy mirror.Policy, ) (backendService, error) { - return backend.NewProductionManagedSupervisor(ctx, layout, stderr, clock) + return backend.NewProductionManagedSupervisor(ctx, layout, stderr, clock, mirrorPolicy) }, telemetryFactory: telemetry.New, } diff --git a/internal/cli/contract_backend_test.go b/internal/cli/contract_backend_test.go index 54fffe1..fcd44a1 100644 --- a/internal/cli/contract_backend_test.go +++ b/internal/cli/contract_backend_test.go @@ -10,6 +10,7 @@ import ( "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/backend" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/config" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol/contracttest" ) @@ -70,7 +71,7 @@ func backendContractRunner() contracttest.Runner { IO{In: strings.NewReader(""), Out: &stdout, Err: &stderr}, WithCWD(root), WithClock(func() time.Time { return time.Date(2026, 8, 9, 8, 0, 0, 0, time.UTC) }), - WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time) (backendService, error) { + WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time, mirror.Policy) (backendService, error) { return service, nil }), ) diff --git a/internal/cli/options.go b/internal/cli/options.go index d1eca60..288540e 100644 --- a/internal/cli/options.go +++ b/internal/cli/options.go @@ -24,11 +24,14 @@ type backendService interface { Supervise(context.Context, backend.Request) error } +// backendFactory 多接一个 mirror.Policy:backend 需要它按增补 1 C11 解析出 +// 下发给后端的有序镜像源列表,而全局镜像策略只在 CLI 侧被解析。 type backendFactory func( context.Context, *config.Layout, io.Writer, func() time.Time, + mirror.Policy, ) (backendService, error) // WithBackendFactory 注入后端监督器工厂,供命令契约测试隔离真实 Job 与端口。 diff --git a/internal/cli/telemetry_test.go b/internal/cli/telemetry_test.go index 5f95604..572dc02 100644 --- a/internal/cli/telemetry_test.go +++ b/internal/cli/telemetry_test.go @@ -14,6 +14,7 @@ import ( "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/backend" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/config" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/doctor" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/telemetry" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/version" @@ -364,7 +365,7 @@ func TestBackendSupervise_TelemetryClosesOnShutdown(t *testing.T) { code := Execute(context.Background(), []string{"--output", "ndjson", "backend", "supervise", "--mode", "managed"}, IO{In: input, Out: &stdout, Err: io.Discard}, WithCWD(t.TempDir()), WithTelemetryFactory(func(telemetry.Config) telemetry.Recorder { return recorder }), - WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time) (backendService, error) { + WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time, mirror.Policy) (backendService, error) { return backendServiceFunc(func(_ context.Context, request backend.Request) error { command, err := request.Control.Receive(context.Background()) if err != nil { @@ -415,7 +416,7 @@ func TestBackendSupervise_ControlReaderPanicReportsSentry(t *testing.T) { t.Setenv("AUTO_MAS_TELEMETRY", "enabled") code := Execute(context.Background(), []string{"--output", "ndjson", "backend", "supervise", "--mode", "managed"}, IO{In: input, Out: &stdout, Err: &stderr}, WithCWD(t.TempDir()), WithTelemetryFactory(func(telemetry.Config) telemetry.Recorder { return recorder }), - WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time) (backendService, error) { + WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time, mirror.Policy) (backendService, error) { return backendServiceFunc(func(ctx context.Context, request backend.Request) error { _, err := request.Control.Receive(ctx) return err From 020b3b9130809efd75ad754989ff71a030b037da Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 01:00:36 +0200 Subject: [PATCH 28/57] =?UTF-8?q?test:=20=E7=AB=AF=E5=88=B0=E7=AB=AF?= =?UTF-8?q?=E8=AF=81=E6=98=8E=E5=90=8E=E7=AB=AF=E6=94=B6=E5=88=B0=E5=8F=97?= =?UTF-8?q?=E7=AE=A1=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD=E4=B8=8E=E9=95=9C?= =?UTF-8?q?=E5=83=8F=E6=BA=90=20(T13.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/backend/e2e_windows_test.go | 64 ++++++++++++++++++++++++++++ internal/cli/backend_test.go | 51 ++++++++++++++++++++++ testdata/fakebackend/main.go | 34 ++++++++++++++- 3 files changed, 148 insertions(+), 1 deletion(-) diff --git a/internal/backend/e2e_windows_test.go b/internal/backend/e2e_windows_test.go index 1be3a5e..fa7fe0a 100644 --- a/internal/backend/e2e_windows_test.go +++ b/internal/backend/e2e_windows_test.go @@ -43,6 +43,7 @@ type backendE2EConfig struct { ListenAddress string `json:"listenAddress,omitempty"` PIDFile string `json:"pidFile,omitempty"` WorkingDirFile string `json:"workingDirFile,omitempty"` + EnvironmentFile string `json:"environmentFile,omitempty"` GrandchildPIDFile string `json:"grandchildPidFile,omitempty"` SpawnGrandchild bool `json:"spawnGrandchild,omitempty"` GrandchildLifetimeMS int `json:"grandchildLifetimeMs,omitempty"` @@ -242,6 +243,7 @@ type backendE2EFixture struct { configPath string rootPID string workingDir string + environment string grandchildPID string uvExecReady string uvExecRelease string @@ -413,6 +415,7 @@ func newBackendE2EFixture(t *testing.T, configValue backendE2EConfig) *backendE2 configValue.ListenAddress = "127.0.0.1:36163" configValue.PIDFile = filepath.Join(root, "python.pid") configValue.WorkingDirFile = filepath.Join(root, "backend.cwd") + configValue.EnvironmentFile = filepath.Join(root, "backend.env") configValue.GrandchildPIDFile = filepath.Join(root, "grandchild.pid") rootPIDPath := filepath.Join(root, "uv.pid") uvExecReadyPath := "" @@ -472,6 +475,7 @@ func newBackendE2EFixture(t *testing.T, configValue backendE2EConfig) *backendE2 configPath: backendConfigPath, rootPID: rootPIDPath, workingDir: configValue.WorkingDirFile, + environment: configValue.EnvironmentFile, grandchildPID: configValue.GrandchildPIDFile, uvExecReady: uvExecReadyPath, uvExecRelease: uvExecReleasePath, @@ -589,6 +593,7 @@ func TestBackendE2E_LifecycleSpawnReadyShutdown(t *testing.T) { } assertE2EDevelopmentUVEnvironment(t, fixture) assertE2EDevelopmentWorkingDir(t, fixture) + assertE2EBackendInfrastructureEnvironment(t, fixture) // 后代随父进程一起退出,属于优雅路径,不得出现强制回收警告。 for _, warning := range fixture.emitter.warningsSnapshot() { if warning.Code == string(protocol.CodeBackendForceTerminated) { @@ -641,6 +646,65 @@ func assertE2EDevelopmentWorkingDir(t *testing.T, fixture *backendE2EFixture) { } } +// assertE2EBackendInfrastructureEnvironment 证明增补 1 C11 的四个变量确实到达了 +// 真实的后端子进程(fakeuv 之后的那一层),而不只是出现在 Runtime 交给 uv 的 +// StartSpec 里。取值同时与 layout 和 mirror 的 plan 顺序逐项核对。 +func assertE2EBackendInfrastructureEnvironment(t *testing.T, fixture *backendE2EFixture) { + t.Helper() + waitE2EFile(t, fixture.environment) + payload, err := os.ReadFile(fixture.environment) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", fixture.environment, err) + } + received := map[string]string{} + for _, line := range strings.Split(strings.TrimRight(string(payload), "\n"), "\n") { + if line == "" { + continue + } + key, value, found := strings.Cut(line, "=") + if !found { + t.Fatalf("backend environment line %q is malformed", line) + } + received[key] = value + } + policy, err := mirror.NewPolicy(mirror.PolicySpec{}) + if err != nil { + t.Fatalf("mirror.NewPolicy() error = %v", err) + } + want := map[string]string{ + "AUTO_MAS_UV_CACHE_DIR": fixture.layout.UVCacheDir(), + "AUTO_MAS_UV_PYTHON_INSTALL_DIR": fixture.layout.PythonDir(), + "AUTO_MAS_MIRROR_PACKAGE_INDEX": e2EMirrorSourceList(t, policy, mirror.KindPackageIndex), + "AUTO_MAS_MIRROR_PYTHON": e2EMirrorSourceList(t, policy, mirror.KindPython), + } + for key, expected := range want { + got, ok := received[key] + if !ok { + t.Fatalf("backend environment is missing %q; got %#v", key, received) + } + if got != expected { + t.Fatalf("backend environment[%q] = %q, want %q", key, got, expected) + } + } +} + +func e2EMirrorSourceList(t *testing.T, policy mirror.Policy, kind mirror.Kind) string { + t.Helper() + catalog, err := mirror.DefaultCatalog() + if err != nil { + t.Fatalf("DefaultCatalog() error = %v", err) + } + plan, err := mirror.BuildPlan(catalog, policy, kind) + if err != nil { + t.Fatalf("BuildPlan(%s) error = %v", kind, err) + } + addresses := make([]string, 0, len(plan.Sources())) + for _, source := range plan.Sources() { + addresses = append(addresses, source.BaseURL()) + } + return strings.Join(addresses, ";") +} + func assertE2EDevelopmentUVEnvironment(t *testing.T, fixture *backendE2EFixture) { t.Helper() payload, err := os.ReadFile(fixture.uvRecord) diff --git a/internal/cli/backend_test.go b/internal/cli/backend_test.go index 6919bfa..7d52c4f 100644 --- a/internal/cli/backend_test.go +++ b/internal/cli/backend_test.go @@ -185,6 +185,57 @@ func TestBackendDevelopment_CLIResolvesExplicitRepoFromCWD(t *testing.T) { } } +// TestBackendSupervise_PassesMirrorPolicyToFactory 锁定增补 1 C11 的数据流起点: +// 全局镜像策略只在 CLI 侧被解析,backend 需要它才能算出下发给后端的有序源列表。 +func TestBackendSupervise_PassesMirrorPolicyToFactory(t *testing.T) { + tests := []struct { + name string + arguments []string + wantOffline bool + wantOnly bool + wantPreferred string + }{ + {name: "default policy", arguments: nil}, + {name: "offline", arguments: []string{"--offline"}, wantOffline: true}, + {name: "mirror only", arguments: []string{"--mirror-only"}, wantOnly: true}, + { + name: "explicit package index preference", + arguments: []string{"--mirror", "package-index=ustc"}, + wantPreferred: "ustc", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var captured mirror.Policy + var stdout, stderr bytes.Buffer + arguments := append([]string{"--app-root", t.TempDir(), "--output", "ndjson"}, test.arguments...) + arguments = append(arguments, "backend", "supervise", "--mode", "managed") + code := Execute( + context.Background(), + arguments, + IO{In: strings.NewReader(""), Out: &stdout, Err: &stderr}, + WithBackendFactory(func(_ context.Context, _ *config.Layout, _ io.Writer, _ func() time.Time, policy mirror.Policy) (backendService, error) { + captured = policy + return backendServiceFunc(func(context.Context, backend.Request) error { return nil }), nil + }), + ) + if code != protocol.ExitCodeSuccess { + t.Fatalf("Execute() exit code = %d, want 0; stderr=%q", code, stderr.String()) + } + if got := captured.Offline(); got != test.wantOffline { + t.Errorf("policy offline = %t, want %t", got, test.wantOffline) + } + if got := captured.MirrorOnly(); got != test.wantOnly { + t.Errorf("policy mirrorOnly = %t, want %t", got, test.wantOnly) + } + preferred, _ := captured.Preferred(mirror.KindPackageIndex) + if preferred != test.wantPreferred { + t.Errorf("preferred package index = %q, want %q", preferred, test.wantPreferred) + } + }) + } +} + type backendServiceFunc func(context.Context, backend.Request) error func (f backendServiceFunc) Supervise(ctx context.Context, request backend.Request) error { diff --git a/testdata/fakebackend/main.go b/testdata/fakebackend/main.go index f83aa66..87bcb26 100644 --- a/testdata/fakebackend/main.go +++ b/testdata/fakebackend/main.go @@ -36,7 +36,11 @@ type fakeBackendConfig struct { PIDFile string `json:"pidFile"` // WorkingDirFile 让假后端报告自己的 os.Getwd(),供 T13.1 端到端断言 // Runtime 设定的工作目录真的生效;父进程侧的 StartSpec 断言证明不了这件事。 - WorkingDirFile string `json:"workingDirFile"` + WorkingDirFile string `json:"workingDirFile"` + // EnvironmentFile 让假后端把自己进程里读到的受监督环境变量落盘,供 T13.5 + // 端到端断言增补 1 C11 的四个变量确实穿过 uv 到达了真实后端进程;父进程侧 + // 的 StartSpec 断言只能证明 Runtime 传了什么,证明不了后端收到了什么。 + EnvironmentFile string `json:"environmentFile"` GrandchildPIDFile string `json:"grandchildPidFile"` SpawnGrandchild bool `json:"spawnGrandchild"` GrandchildLifetimeMS int `json:"grandchildLifetimeMs"` @@ -214,6 +218,12 @@ func runFakeBackend() int { return 97 } } + if config.EnvironmentFile != "" { + if err := writeSignalFile(config.EnvironmentFile, supervisedEnvironmentReport()); err != nil { + fmt.Fprintln(os.Stderr, err) + return 89 + } + } grandchild, err := startGrandchild(config) if err != nil { @@ -488,6 +498,28 @@ func startGrandchild(config fakeBackendConfig) (*grandchildProcess, error) { return process, nil } +// supervisedEnvironmentReport 逐行输出 `<键>=<值>`,缺席的键整行不出现, +// 因此断言方能区分「注入了空串」和「根本没注入」。 +func supervisedEnvironmentReport() []byte { + var builder bytes.Buffer + for _, key := range []string{ + "AUTO_MAS_UV_CACHE_DIR", + "AUTO_MAS_UV_PYTHON_INSTALL_DIR", + "AUTO_MAS_MIRROR_PACKAGE_INDEX", + "AUTO_MAS_MIRROR_PYTHON", + } { + value, ok := os.LookupEnv(key) + if !ok { + continue + } + builder.WriteString(key) + builder.WriteString("=") + builder.WriteString(value) + builder.WriteString("\n") + } + return builder.Bytes() +} + func writeSignalFile(path string, payload []byte) error { directory := filepath.Dir(path) file, err := os.CreateTemp(directory, ".fakebackend-signal-*") From 12281103b1c8b171b7f3bf4353e9e2354c83580b Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 01:02:17 +0200 Subject: [PATCH 29/57] =?UTF-8?q?fix(cli):=20bootstrap=20=E4=B8=8E=20repai?= =?UTF-8?q?r=20=E5=90=8C=E6=A0=B7=E4=B8=8A=E6=8A=A5=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E7=9A=84=E9=95=9C=E5=83=8F=E6=BA=90=20(T13.4?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cli/bootstrap.go | 7 +++++++ internal/cli/contract_m5_test.go | 19 ++++++++++++++++--- internal/cli/repair.go | 6 ++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/internal/cli/bootstrap.go b/internal/cli/bootstrap.go index 36cd174..ebe0124 100644 --- a/internal/cli/bootstrap.go +++ b/internal/cli/bootstrap.go @@ -353,6 +353,13 @@ func runBootstrap( "pythonVersion": pythonResult.Spec.Version.String(), "lockfileChecked": dependencyResult.LockfileChecked, "synchronized": dependencyResult.Synchronized, + // bootstrap 同样跑 uv sync,因此和 dependencies sync 一样报告本次 + // 实际使用的镜像源(C10 第 7 条);否则首装失败时调用方无从判断 + // 装的是哪个源。 + "sourceKind": dependencyResult.SourceKind, + "source": dependencyResult.Source, + "attemptCount": dependencyResult.AttemptCount, + "lockRewritten": dependencyResult.LockRewritten, }, }, nil } diff --git a/internal/cli/contract_m5_test.go b/internal/cli/contract_m5_test.go index 4a5e776..418e3ec 100644 --- a/internal/cli/contract_m5_test.go +++ b/internal/cli/contract_m5_test.go @@ -129,8 +129,10 @@ func m5ContractInitialState(command string) state.EnvironmentState { } } -// assertM5ContractMirrorDetails 锁定 C10 第 7 条:dependencies sync 成功时 -// result.details 必须报告包索引源与尝试次数,字段名与类型不得漂移。 +// assertM5ContractMirrorDetails 锁定 C10 第 7 条:凡是执行了 uv sync 的命令, +// 成功 result.details 都必须报告包索引源与尝试次数,字段名与类型不得漂移。 +// bootstrap 与 repair 同样跑 SyncDependencies,调用方没有理由在这两条路径上 +// 看不到本次实际使用的镜像源。 func assertM5ContractMirrorDetails( t *testing.T, terminal contracttest.Terminal, @@ -138,7 +140,7 @@ func assertM5ContractMirrorDetails( output string, ) { t.Helper() - if terminal != contracttest.TerminalSuccess || command != "dependencies sync" { + if terminal != contracttest.TerminalSuccess || !m5CommandReportsMirrorSource(command) { return } events := parseNDJSON(t, output) @@ -167,6 +169,17 @@ func assertM5ContractMirrorDetails( t.Fatal("result event is missing") } +// m5CommandReportsMirrorSource 列出会执行 uv sync 的命令。environment repair +// 只修 uv 与 Python、不同步依赖,因此不在其中。 +func m5CommandReportsMirrorSource(command string) bool { + switch command { + case "bootstrap", "repair", "dependencies sync", "dependencies rebuild": + return true + default: + return false + } +} + func assertM5ContractStage(t *testing.T, terminal contracttest.Terminal, want protocol.Stage, output string) { t.Helper() if terminal != contracttest.TerminalFailure { diff --git a/internal/cli/repair.go b/internal/cli/repair.go index f60e781..a131c43 100644 --- a/internal/cli/repair.go +++ b/internal/cli/repair.go @@ -315,6 +315,12 @@ func runRepair( "uvVersion": uv.FixedVersion, "pythonVersion": pythonResult.Spec.Version.String(), "synchronized": dependencyResult.Synchronized, + // repair 的最后一步同样是 uv sync,镜像源事实与 dependencies sync + // 同口径上报(C10 第 7 条)。 + "sourceKind": dependencyResult.SourceKind, + "source": dependencyResult.Source, + "attemptCount": dependencyResult.AttemptCount, + "lockRewritten": dependencyResult.LockRewritten, }, }, nil } From bde5d73bc837e33b2ca741b8d668d246d305cd82 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 01:05:26 +0200 Subject: [PATCH 30/57] =?UTF-8?q?docs:=20=E5=9B=9E=E5=86=99=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E5=AE=9E=E7=8E=B0=E5=81=8F=E5=B7=AE=E4=B8=8E=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E6=A0=87=E8=AF=86=E7=9F=9B=E7=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...73\345\212\241\346\213\206\345\210\206.md" | 6 +++ ...66\346\236\204\350\256\276\350\256\241.md" | 39 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index 60686f0..5c9bfab 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -812,6 +812,10 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 - 依赖:T10.2;可与 M3 的非 filesystem 工作并行 - 内容:按 [`doc/maintenance/现成库替换评估.md`](./maintenance/现成库替换评估.md) 先在 mirror 普通结构比较中试点仅测试依赖 `google/go-cmp`;普通行为测试逐步外部化,按职责拆分巨型测试文件;保留 Win32 参数矩阵和故障注入白盒覆盖;对 StateFiles 私有事务上下文与 Go 1.26 `os.Root` 适用范围做有界评估,未证明等价时不迁移。 - 验收:不删除有效场景;标准门、相关高重复测试与全仓 race detector 全绿;文档明确仍保留的安全内核边界。 +- [ ] **T10.4 错误码到退出码常量的命名对齐**(S) + - 依赖:无;纯可维护性项,**不改变任何对外取值** + - 内容:`internal/protocol/errors.go` 中 `UV_CHECKSUM_MISMATCH` 映射到常量 `ExitCodeGitFailure`(值 `40`)。退出码 `40` 与架构文档一致,问题只在常量名不对口——读代码的人会以为这是 Git 域的错误。排查是否还有别的错误码借用了语义不符的退出码常量,再决定是给 `40` 增加一个语义中性的别名常量,还是逐条改用更贴切的名字。 + - 验收:所有错误码的**退出码数值**与架构文档「错误码全集」逐条一致(有测试即以测试为准);不新增、删除或改名任何错误码;标准验证门全绿。 ### - [ ] M11 跨平台适配(Linux/macOS) @@ -1198,6 +1202,8 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 | 日期 | 变更 | 操作者 | | --- | --- | --- | +| 2026-09-02 | 按 Electron 侧对真实 exe 的接入实测回写 **协议文档偏差**(`doc/架构设计.md`,协议仍为 v1):① stdin 控制命令的 `commandId` 必须是**规范 ULID**(26 位 Crockford base32、首字符 ≤ `7`,`internal/protocol/operation_id.go` 的 `validOperationID`),UUID 因长度不符被判 `invalid_command_id`;② 控制命令 JSON **只允许** `protocol`/`command`/`commandId` 三个键,未知键 `unknown_field`、重复键 `duplicate_field`、缺键 `missing_field`(`decodeControlFields`);③ `--protocol` 不兼容**没有结构化错误**——实测 `--protocol 2` 为 stdout 空、stderr 一行 `auto-mas-runtime: protocol version mismatch`、exit `10`,不产生 `hello`/`result`;④ `result.status` 取值域按命令区分:一次性命令 `succeeded`/`failed`/`cancelled`(跨提交点的失败改用生命周期字面量),`backend supervise` 为 `stopped`/`backend_failed`/`environment_broken`;⑤ `log` 事件的 `source`/`stream` **不定义枚举**(自由字符串,测试中出现过 `stream="unknown"`),调用方只能当标签用;⑥ `result.details` 的 warning 三件套 `warnings`/`warningCount`/`warningsTruncated`,快照上限 `protocol.MaxResultWarningSummaries = 256`、`warningCount` 统计全量;⑦ `hello.capabilities` **随命令变化且常为空**(`version`/`doctor`/`cleanup`/`workspace check` 为 `[]`)。第 ⑦ 条同时暴露一处**文档与实现的真实矛盾**:`backend supervise` 只公告 `["stdin.cancel","state.v1","log.stream"]`(`internal/cli/backend.go`),却经 `NewControlReader` 注册并真实接受 `shutdown`/`status`,而 README 与架构文档都写「实际可用命令以 `hello.capabilities` 为准」。选择**补公告而不是改口**:照文档写的客户端将永远不发 `shutdown`,优雅关闭会退化成 Job 强杀;能力标识属于协议 v1 明确允许追加的集合,补它不升级协议版本。按红线第 2 条先在本次改文档(新增 `stdin.shutdown`、`stdin.status` 两行),代码与契约测试随后单独提交 | Claude | +| 2026-09-02 | 登记 **T10.4**(可维护性,不改对外取值):`UV_CHECKSUM_MISMATCH` 映射到常量名 `ExitCodeGitFailure`。退出码数值 `40` 与架构文档一致,只是常量名不对口,容易让读代码的人误判为 Git 域错误 | Claude | | 2026-09-01 | 完成 **T13.4**(实现收口于 `bba97c9`,设计与计划见 `doc/current/M13/设计-T13.4-依赖同步镜像改写.md`):`uv lock --check` 阶段与 `--locked` 校验口径不变,`uv sync` 改为按 `KindPackageIndex` 目录轮换——每个镜像在受管临时项目目录(`runtime/cache/build/dependencies/`)里用改写后的锁副本执行 `--frozen`,plan 末位的官方源改用 `repo` 原锁与 `--locked`,**回退即 plan 末位**,因此 `--mirror-only` 不回退不需要额外分支(`BuildPlan` 本就不放官方源进 plan,耗尽后映射 `MIRROR_EXHAUSTED`),`--offline` 完全不改写。每源只尝试一次(`OutcomeSwitchSource`):`uv sync` 是重操作、uv 自身已对单个 artifact 重试,同源重试只会让断网时的等待翻倍。临时目录经 `filesystem.PrepareManagedDirectory` 创建、`DeleteDependencySync` 受控删除,成功/失败/取消三条路径都收口(取消走 `context.WithoutCancel` + 15 秒预算),全程不写 `repo/` 内任何文件。改写前缀由目录**显式声明**而非推导(官方源 artifact 在 `files.pythonhosted.org`)。事件形态在零契约新增的前提下落地:每次尝试发一条 `dependencies.sync` progress(`ProgressEvent` 无 `details`,message 仅供展示),机器可读事实进 `result.details` 的 `sourceKind` / `source` / `attemptCount` / `lockRewritten`;`log` 事件按架构定义专指受管进程输出转发(能力标识 `log.stream`),`warning` 需要新错误码而 C10 禁止新增,两者都不占用。测试:`internal/mirror` 前缀不变量、`rewriteLockfile` 五项不变量(含哈希逐字不变与幂等)、`internal/uv` 六项轮换/收口用例、`internal/cli` 组件矩阵七条收场(含 stdin 取消时临时目录收口)与契约字段锁定。审查阶段另删除 `internal/uv/network.go` 里包索引的 `--default-index` 死代码路径。标准验证门与 `go test -race ./... -count=1` 均为退出码 0 | Claude | | 2026-09-01 | T13.4 实施期间按红线第 2 条先改文档,修订 **C10** 两处:其一,显式 `--mirror package-index=` 不再返回 `INVALID_ARGUMENT`,改为把该源排在尝试顺序最前、与自动轮换走同一条锁改写路径——**改写不是覆盖索引**,`uv lock --check` 仍对 `repo/uv.lock` 原锁执行、`uv sync` 消费的是改写后的锁副本而非 `--default-index`,所以「显式指定」与「自动轮换」在实现上是同一件事、只差顺序;定稿时保留拒绝会形成「自动允许、显式拒绝」的不对称,用户想优先用某个已知可达的镜像反而被判参数错误。落地时同步删除 `internal/cli/errors.go` 的 `rejectPackageIndexOverride`(bootstrap 与 repair 的前置拒绝)。其二,改写用的 `simple` / `packages` 两个前缀改为在 `internal/mirror` 目录中**显式声明**,不再由 `baseURL` 去掉结尾 `simple/` 推导——官方源的 artifact 在 `files.pythonhosted.org`,与索引不同 host,推导会得到不存在的 `https://pypi.org/packages/`;三家镜像同 host 只是巧合。同日实测记入 C10:`aliyun` / `tsinghua` 两个前缀均 HTTP 200 且无跳转,`ustc` 的索引 302 到 `mirrors.ustc.edu.cn/pypi/simple`、artifact 302 到清华(可用,与 `tsinghua` 冗余但不冲突),目录中**没有**腾讯云源。`doc/架构设计.md`「项目依赖同步」同步修订,T13.4 条目与验收项同步更新;`--mirror-only`、`--offline`、临时目录生命周期与 `details` 报告口径均不变,协议仍为 v1、不新增字段/stage/state/错误码 | Claude | | 2026-08-31 | 新增 [协议 v1 契约补充 增补 1](./契约补充-v1-增补1.md)(**C6~C11**,协议仍为 v1,不新增或改名任何字段、stage、state 与错误码),并立项里程碑 **M13「dev 基线接入配套」**(T13.1~T13.6)。六条定稿:**C6** managed 下后端子进程 cwd = app-root、入口传绝对路径 `/repo/main.py`(现实现 `internal/uv/managed.go:83` 用 uv 的 project dir,会把用户数据建在 `repo/` 里并被 `workspace sync` 整体替换掉);**C7** 受监督优先级扩展到端口(固定 36163,忽略 `AUTO_MAS_HTTP_PORT` 与 `.env`/`AUTO_MAS_ENV`),并修订 C2 第 1 条(`protocol` 改为后端自报而非回显注入值,`version`/`commit` 仍回显)与 C4 第 1 条(受监督但非管理员时记 warning 并继续运行,不再要求非零退出);**C8** 游戏与模拟器不随后端退出,Runtime 开 `JOB_OBJECT_LIMIT_BREAKAWAY_OK`、AUTO-MAS 只在拉起游戏/模拟器时带 `CREATE_BREAKAWAY_FROM_JOB`(`DETACHED_PROCESS` 不脱离 Job);**C9** `backend supervise` 新增关闭超时选项,默认仍 5 秒,待实测数据再调;**C10** 锁文件在 PyPI 上生成,`dependencies sync` 改为改写锁副本内两处下载地址前缀参与镜像轮换、失败逐个换源、全部失败回退原锁,安全性由锁内 `sha256` 与 uv 的 `--frozen` 校验保证,`dependencies.go:234` 拒绝覆盖包索引的逻辑保留;**C11** MaaFW 运行池只统一基础设施——新增注入 `AUTO_MAS_UV_CACHE_DIR`/`AUTO_MAS_UV_PYTHON_INSTALL_DIR`/`AUTO_MAS_MIRROR_PACKAGE_INDEX`/`AUTO_MAS_MIRROR_PYTHON`,池目录重新分类,「装什么」仍留在后端。按红线第 2 条先改文档:`doc/架构设计.md` 在目录模型、后端启动、项目依赖同步、镜像与网络策略、CLI 设计、插件环境职责边界、健康检查与关闭契约、Lite/Full 边界和数据分类九处补入增补段落,并补全 dev 基线下后端生成目录的分类;`doc/契约补充-v1.md` 顶部与 C2/C4 两条加指向增补的修订标注;`doc/README.md` 权威优先级更新为「增补 1 > 契约补充-v1 > 架构设计 > 任务拆分」。本次不写任何 Go 代码,M13 全部任务保持未开始 | Claude | diff --git "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" index cfbebb8..0ac4a02 100644 --- "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" +++ "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -16,6 +16,14 @@ 后端工作目录与绝对入口路径、Job Object 的 `BREAKAWAY_OK`、关闭超时参数化、 主项目依赖的锁内 URL 改写轮换、MaaFW 运行池的基础设施共享与镜像源下发、dev 基线的目录分类补全。 协议版本保持 v1,不新增或改名任何字段、stage、state 与错误码 +- 修订:2026-09-02 按 Electron 侧对真实 exe 的接入实测,补齐六处**文档未写但实现如此**的协议事实: + stdin 控制命令的 `commandId` 必须是规范 ULID(UUID 被拒)、控制命令只允许三个键、 + `--protocol` 不兼容时无结构化输出(stdout 空 / stderr 一行 / exit 10)、 + `result.status` 的取值域按命令区分、`log` 事件的 `source` 与 `stream` 不定义枚举、 + `result.details` 的 warning 三件套(`warnings` / `warningCount` / `warningsTruncated`,上限 256)。 + 同时按红线第 2 条先行补入 `stdin.shutdown`、`stdin.status` 两个能力标识—— + 实现已接受这两条命令却没有公告,与「实际可用命令以 `hello.capabilities` 为准」直接矛盾。 + 协议仍为 v1:能力标识属于允许追加的集合,不改名、不删除既有标识 - 当前范围:系统边界、职责划分、CLI 协议、纯 Git 更新、uv 环境、健康检查、进程监督、镜像、插件职责边界、目录安全、错误与状态契约、测试矩阵、验收标准和分阶段迁移 ## 背景 @@ -336,6 +344,8 @@ Python 启动阶段产生的 stdout/stderr 由 Runtime 包装为 NDJSON `log` } ``` +`source` 与 `stream` 是**自由字符串,协议 v1 不定义取值枚举**。当前实现中受管后端转发的记录固定为 `source="backend"`,`stream` 取自被读取的管道(`"stdout"` / `"stderr"`),但管道身份不可确定时也可能出现其他值(测试中出现过 `"unknown"`)。调用方只能把这两个字段当作展示与分组用的标签,**不得据此做业务分支**,也不得因为取值陌生就拒绝该事件。 + Runtime 同时把完整日志写入轮转日志文件。这里的完整表示逐流不丢行:非法 UTF-8 字节按 U+FFFD 规范化,日志文件保存规范化后的完整文本;超长行的协议 `log.message` 可以按 M6 冻结上限截断,但日志文件不得截断该行。标准错误保留给 Runtime 自身的诊断信息,Python 的用户可见启动日志通过标准输出中的 `log` 事件传递,以保持机器协议可解析。 @@ -542,9 +552,13 @@ Runtime 提供两种输出模式: | `stdin.cancel` | 本次操作接受 stdin `cancel` 控制命令 | | `state.v1` | 本次操作发射 v1 生命周期 `state` 事件 | | `log.stream` | 本次操作把受管进程的 stdout/stderr 转发为 `log` 事件 | +| `stdin.shutdown` | 本次操作接受 stdin `shutdown` 控制命令 | +| `stdin.status` | 本次操作接受 stdin `status` 控制命令 | 新增能力标识可以追加,既有标识不得改名;删除或改变既有语义必须升级协议版本。调用方对未知能力标识必须忽略而不是拒绝整个协议。 +`capabilities` **随命令变化**,不是固定集合:`backend supervise` 公告全部五项;`bootstrap`、`repair`、`workspace sync`、`dependencies sync` / `rebuild` 等公告 `stdin.cancel`(可写操作再加 `state.v1`);`version`、`doctor`、`cleanup`、`workspace check` 等不接受控制命令、也不发 `state` 的命令公告**空数组 `[]`**。空数组是正常取值,不表示握手异常。 + Runtime 在参数无法解析时可以直接向 stderr 输出诊断并以 `2` 退出,此时不承诺 `hello/result`。参数解析成功后,所有终点都必须遵循 NDJSON 契约。 事件类型固定为: @@ -704,6 +718,25 @@ Electron 根据 `code`、`stage` 和 `retryable` 决定是否提供重试、更 成功结果的 `code` 固定为 `OK`。失败结果必须重复主错误的 `code`、`stage`、`retryable` 和 `remediation`,便于调用方只消费终点事件。`warning` 不会把成功结果改为失败,但必须在 `result.details.warnings` 中汇总。发送 `result` 后不得再发送任何协议事件。 +`result.status` 的取值域**取决于命令**,不是单一枚举: + +- **一次性命令**(`bootstrap`、`repair`、`workspace sync`、`dependencies *`、`doctor`、`cleanup`、`version` 等): + 成功为 `succeeded`,失败为 `failed`,被 stdin `cancel` 终止为 `cancelled`。 + 已经跨过持久化提交点的失败会改用对应的生命周期状态字面量(例如依赖同步失败后的 `environment_broken`、`ready_to_start`),取值一律来自「状态事件全集」,不引入 `state` 之外的新字面量。 +- **`backend supervise`**:正常收场为 `stopped`,后端启动或运行失败为 `backend_failed`,受管环境不可用为 `environment_broken`。 + +调用方判定成败只看 `success` 与 `code`;`status` 用于选择界面呈现,遇到未知字面量按 `success` 降级处理。 + +`result.details` 中与 `warning` 相关的字段固定为三个,只有本次发过 `warning` 时才出现: + +| 字段 | 类型 | 含义 | +| --- | --- | --- | +| `warnings` | array | warning 快照,最多 `256` 条 | +| `warningCount` | number | 本次发射的 warning **总数**,不受 256 上限影响 | +| `warningsTruncated` | bool | `warningCount > 256` 时为 `true` | + +上限对应 `protocol.MaxResultWarningSummaries`。调用方要显示「共 N 条警告」时读 `warningCount`,不能用 `len(warnings)`。 + ### 标准输入控制 耗时操作可以接收取消命令: @@ -721,6 +754,10 @@ Electron 根据 `code`、`stage` 和 `retryable` 决定是否提供重试、更 首个协议版本不提供多请求复用。一个 Runtime 进程只执行一个顶层操作。 +`commandId` 必须是**规范 ULID**:26 个字符的 Crockford base32(字母表 `0123456789ABCDEFGHJKMNPQRSTVWXYZ`,大写),且首字符不大于 `7`(48 位时间戳上限)。文档示例里的 `01J...` 就是这种形态。**UUID 不被接受**——带连字符的 36 字符串长度就不对,会被判为无效控制命令并产生 `INVALID_CONTROL_COMMAND` warning(`details.reason = "invalid_command_id"`)。 + +控制命令的 JSON 对象**只允许** `protocol`、`command`、`commandId` 三个键。出现任何其他键(`details`、`timestamp`、`id` 等)整行判无效(`details.reason = "unknown_field"`),重复键同样判无效(`duplicate_field`);缺键为 `missing_field`。这条严格解析是刻意的:控制通道不做「多余字段忽略」的宽松处理,免得调用方误以为某个自造字段生效了。 + Runtime 接受控制命令后,在下一条相关 `state`、`warning` 或最终 `result` 的 `details.controlCommandId` 中回显 `commandId`。`status` 只返回当前状态快照,不改变状态;重复 `shutdown` 和 `cancel` 必须幂等。无法解析或不适用于当前命令的控制输入产生 `INVALID_CONTROL_COMMAND` warning,但不允许因此遗留正在运行的后端或破坏当前更新事务。 ### 退出码 @@ -740,6 +777,8 @@ Runtime 接受控制命令后,在下一条相关 `state`、`warning` 或最终 | `70` | 操作冲突或目录被锁定 | | `130` | 用户取消 | +参数解析阶段的失败**没有结构化输出**:`--protocol` 与 Runtime 不兼容时(例如 `--protocol 2`),stdout 完全为空、stderr 只有一行 `auto-mas-runtime: protocol version mismatch`、退出码 `10`,不产生 `hello`,也不产生 `result`。`--output` 取值非法等其他解析失败同理,只是退出码为 `2`。调用方必须允许「进程退出但一条 NDJSON 都没有」这种收场,不能无条件等待 `result`。 + ### 错误码全集 以下错误码构成协议版本 1 的稳定全集。新增错误码允许在协议版本不变时追加;删除、改名或改变既有语义必须升级协议版本。 From 1f6b13c414faebbeb92f297323676bb713919be6 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 01:07:09 +0200 Subject: [PATCH 31/57] =?UTF-8?q?feat(protocol):=20backend=20supervise=20?= =?UTF-8?q?=E5=85=AC=E5=91=8A=20stdin.shutdown=20=E4=B8=8E=20stdin.status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cli/backend.go | 10 ++++- internal/cli/contract_backend_test.go | 56 +++++++++++++++++++++++++++ internal/protocol/values.go | 8 ++++ internal/protocol/values_test.go | 4 +- 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/internal/cli/backend.go b/internal/cli/backend.go index 3a6562f..bfdbad8 100644 --- a/internal/cli/backend.go +++ b/internal/cli/backend.go @@ -204,7 +204,15 @@ func runBackendSuperviseSession( emitter, err = output.NewEmitter( runtimeVersion, command, - []string{string(protocol.CapabilityStdinCancel), string(protocol.CapabilityStateV1), string(protocol.CapabilityLogStream)}, + // backend supervise 的 ControlReader 注册了 cancel/shutdown/status 三条命令, + // 公告必须与之一致:调用方按契约只信 hello.capabilities。 + []string{ + string(protocol.CapabilityStdinCancel), + string(protocol.CapabilityStateV1), + string(protocol.CapabilityLogStream), + string(protocol.CapabilityStdinShutdown), + string(protocol.CapabilityStdinStatus), + }, protocol.WithClock(deps.options.clock), ) if err != nil { diff --git a/internal/cli/contract_backend_test.go b/internal/cli/contract_backend_test.go index fcd44a1..b21053f 100644 --- a/internal/cli/contract_backend_test.go +++ b/internal/cli/contract_backend_test.go @@ -19,6 +19,62 @@ func TestBackendSuperviseContract(t *testing.T) { contracttest.Register(t, "backend supervise", backendContractRunner()) } +// TestBackendSupervise_AdvertisesEveryAcceptedControlCommand 钉住「公告集合 == +// 实际接受的控制命令集合」。此前 backend supervise 只公告 cancel/state/log, +// 却真实接受 shutdown 与 status,而 README 与架构文档都写「实际可用命令以 +// hello.capabilities 为准」——照文档写的客户端永远不会发 shutdown,优雅关闭 +// 会退化成 Job 强杀。这条测试让公告与 ControlReader 的注册列表不能再分叉。 +func TestBackendSupervise_AdvertisesEveryAcceptedControlCommand(t *testing.T) { + root := t.TempDir() + var stdout, stderr bytes.Buffer + code := Execute( + context.Background(), + []string{"--app-root", root, "--output", "ndjson", "backend", "supervise", "--mode", "managed"}, + IO{In: strings.NewReader(""), Out: &stdout, Err: &stderr}, + WithCWD(root), + WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time, mirror.Policy) (backendService, error) { + return backendServiceFunc(func(context.Context, backend.Request) error { return nil }), nil + }), + ) + if code != protocol.ExitCodeSuccess { + t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) + } + events := parseNDJSON(t, stdout.String()) + if len(events) == 0 || eventType(events[0]) != string(protocol.TypeHello) { + t.Fatalf("first event = %#v, want hello", events) + } + raw, ok := events[0].object["capabilities"].([]any) + if !ok { + t.Fatalf("hello capabilities = %#v, want array", events[0].object["capabilities"]) + } + announced := make(map[string]bool, len(raw)) + for _, value := range raw { + text, ok := value.(string) + if !ok { + t.Fatalf("capability %#v is not a string", value) + } + if !protocol.IsKnownCapability(protocol.Capability(text)) { + t.Errorf("capability %q is not part of the frozen set", text) + } + announced[text] = true + } + // 与 runBackendSuperviseSession 里 NewControlReader 注册的命令一一对应。 + for command, capability := range map[protocol.ControlKind]protocol.Capability{ + protocol.ControlCancel: protocol.CapabilityStdinCancel, + protocol.ControlShutdown: protocol.CapabilityStdinShutdown, + protocol.ControlStatus: protocol.CapabilityStdinStatus, + } { + if !announced[string(capability)] { + t.Errorf("hello capabilities = %#v, want %q for accepted control command %q", raw, capability, command) + } + } + for _, capability := range []protocol.Capability{protocol.CapabilityStateV1, protocol.CapabilityLogStream} { + if !announced[string(capability)] { + t.Errorf("hello capabilities = %#v, want %q", raw, capability) + } + } +} + func backendContractRunner() contracttest.Runner { return func(t *testing.T, terminal contracttest.Terminal) contracttest.Transcript { t.Helper() diff --git a/internal/protocol/values.go b/internal/protocol/values.go index 5d7aeea..0fd9314 100644 --- a/internal/protocol/values.go +++ b/internal/protocol/values.go @@ -153,12 +153,20 @@ const ( CapabilityStdinCancel Capability = "stdin.cancel" CapabilityStateV1 Capability = "state.v1" CapabilityLogStream Capability = "log.stream" + // CapabilityStdinShutdown 与 CapabilityStdinStatus 是 backend supervise 早已 + // 接受、却一直没有公告的两条 stdin 控制命令。README 与架构文档都规定 + // 「实际可用命令以 hello.capabilities 为准」,不公告等于让守规矩的调用方 + // 永远不发 shutdown,优雅关闭退化成 Job 强杀。 + CapabilityStdinShutdown Capability = "stdin.shutdown" + CapabilityStdinStatus Capability = "stdin.status" ) var knownCapabilities = []Capability{ CapabilityStdinCancel, CapabilityStateV1, CapabilityLogStream, + CapabilityStdinShutdown, + CapabilityStdinStatus, } // AllCapabilities 按文档顺序返回全部稳定能力标识的防御性副本。 diff --git a/internal/protocol/values_test.go b/internal/protocol/values_test.go index cfd9e3a..e6c21e7 100644 --- a/internal/protocol/values_test.go +++ b/internal/protocol/values_test.go @@ -240,8 +240,8 @@ func documentedCapabilities(t *testing.T) []protocol.Capability { for _, match := range matches { capabilities = append(capabilities, protocol.Capability(match[1])) } - if len(capabilities) != 3 { - t.Fatalf("documented capability count = %d, want 3", len(capabilities)) + if len(capabilities) != 5 { + t.Fatalf("documented capability count = %d, want 5", len(capabilities)) } return capabilities } From ba27db3bf7d2b8a5a1294cc3e7833497c413c210 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 01:10:27 +0200 Subject: [PATCH 32/57] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20T13.5=20?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=E9=9D=A2=E5=AE=8C=E6=88=90=E6=83=85=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...44\273\273\345\212\241\346\213\206\345\210\206.md" | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index 5c9bfab..a15bdf8 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -996,10 +996,17 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 - 内容:`uv lock --check` 阶段不动(仍对 `repo/uv.lock` 原锁执行、不带包索引覆盖参数);`uv sync` 阶段改为按 `internal/mirror` 的 `KindPackageIndex` 目录与既有轮换规则逐个尝试——读原锁到内存做两处前缀改写(`https://pypi.org/simple` → `<镜像 base>/simple`,`https://files.pythonhosted.org/packages/` → `<镜像 base>/packages/`),与 `repo/pyproject.toml` 一起放进受管根内的临时项目目录,`UV_PROJECT_ENVIRONMENT` 指向真实受管 venv,执行 `uv sync --frozen --no-default-groups --no-install-workspace`;失败换下一个源;全部失败回退原锁(PyPI)。`--mirror-only` 不做这次回退,`--offline` 完全不改写。临时项目目录由 Runtime 创建并在成功与失败路径上都删除,归「可丢弃缓存」。`dependencies.sync` 事件的 `details` 报告本次实际使用的源 key。 - **2026-09-01 修订**(见 [增补 1 C10](./契约补充-v1-增补1.md#c10主项目依赖的镜像轮换) 顶部的修订提示):`internal/uv/dependencies.go:234-242` 的 `INVALID_ARGUMENT` 拒绝,连同 `internal/cli/errors.go` 的 `rejectPackageIndexOverride`(bootstrap 与 repair 的前置拒绝)一并删除;显式 `--mirror package-index=` 改为把该源排在尝试顺序最前,走同一条改写路径。`internal/mirror/defaults.go` 的 4 个 `KindPackageIndex` 源此前是死代码(唯一消费方拒绝使用),本条正是它的正当用途;每个源的 `simple` / `packages` 改写前缀由目录**显式声明**,不由 `baseURL` 推导(官方源的 artifact 在 `files.pythonhosted.org`,推导会得到不存在的地址)。 - 验收:单元覆盖两处前缀改写与镜像 base 推导(含 `https://mirrors.aliyun.com/pypi/simple/` 这种带路径前缀的 base);组件测试用本地 HTTP 假索引覆盖「首选镜像不可达 → 次选成功」与「全部镜像失败 → 回退原锁」两条路径,以及 `--mirror-only` 不回退、`--offline` 不改写;**`repo/uv.lock` 与 `repo/pyproject.toml` 在全部路径上字节不变**(哈希断言);临时目录在成功、失败与取消三种收场下都不残留;`details` 中的源 key 与实际使用的源一致;显式 `--mirror package-index=` 时该源第一个被尝试;既有 `LOCKFILE_OUTDATED` 测试无回退。 -- [ ] **T13.5 向后端开放受管基础设施与有序镜像源**(M) +- [ ] **T13.5 向后端开放受管基础设施与有序镜像源**(M)🚧 注入面已完成,池目录分类未做 - 依赖:T13.4(同样改 `internal/mirror` 的消费面,分开合以免冲突);契约 [增补 1 C11](./契约补充-v1-增补1.md#c11maafw-运行池的基础设施共享与镜像源下发);成对 TODO-PY-13 - 内容:启动后端时新增注入四个变量——`AUTO_MAS_UV_CACHE_DIR`、`AUTO_MAS_UV_PYTHON_INSTALL_DIR`(受管目录的规范化绝对路径),`AUTO_MAS_MIRROR_PACKAGE_INDEX`、`AUTO_MAS_MIRROR_PYTHON`(`;` 分隔的有序源列表,顺序即 Runtime 解析后的尝试顺序,官方源在末位)。同时把 MaaFW 运行池目录纳入既有分类:venv/解释器/缓存归「可重建」并进入 `repair` / `cleanup`,manifest 与信任基线仍归用户业务数据、绝不删除。Runtime 不读池的依赖声明、不解析池依赖、不维护池的安装清单,不新增池专用命令、stage 或错误码(红线第 4 条)。 - 验收:managed 与 development 两种模式下四个变量都注入且格式正确;列表顺序与 mirror 解析后的尝试顺序逐项一致,`--mirror-only` 不含官方源、`--offline` 为空串;`UV_*` 的注入面与 T12.7 的保留键清理行为**无任何变化**(宿主 `UV_*` 仍不可重新注入);`cleanup` / `repair` 对池目录新分类的行为有测试覆盖,且 manifest 与信任基线在任何自动流程下都不被删除。 + - 证据(注入面,2026-09-02 `020b3b9`):设计与计划见 `doc/current/M13/设计-T13.5-受管基础设施与镜像源注入.md`。数据流为 CLI `--mirror`/`--offline`/`--mirror-only` → `globalOptions.mirrorPolicy` → `backendFactory`(新增 `mirror.Policy` 形参)→ `NewProductionManagedSupervisor` → `Dependencies.MirrorPolicy` → `NewManagedSupervisor` 里 `supervisionInfrastructure()` 解析一次并存到 `ManagedSupervisor.infrastructure` → `uv.ManagedOptions.Infrastructure` → `StartManaged` 的 `supervision` map。layout 与 policy 在 supervisor 生命周期内不变,因此只解析一次,首启、单次自动重启、managed 与 development 读同一份值。 + 有序列表**不新增 mirror API**:直接用 `mirror.BuildPlan(catalog, policy, kind)`,`plan.Sources()` 的顺序就是尝试顺序,`Source.BaseURL()` 是下发值(`PackageIndexRewrite()` 只服务 T13.4 的锁改写,未使用)。失败语义沿用 `internal/uv` 口径:`ErrPolicyRejected` → `INVALID_ARGUMENT` 失败关闭(发生在构造期,早于任何 Mutex/事务/日志),其他错误 → 退回目录默认 policy(零值 `Policy` 即走这条)。 + 四个键并入 `canonicalSupervisionEnvironmentKey` 名单,因此宿主同名变量被清除、`RunOptions.Environment` 覆盖不了、大小写变体也被规范化。两个目录取 `filepath.Clean` 并要求绝对路径,留空时回退本次 uv 调用解析出的 `CacheDir`/`PythonInstallDir`——`AUTO_MAS_UV_CACHE_DIR == UV_CACHE_DIR` 与 `AUTO_MAS_UV_PYTHON_INSTALL_DIR == UV_PYTHON_INSTALL_DIR` 由单测直接锁定,否则 C11「共用一份缓存与解释器」名存实亡。列表用 `;` 连接,空列表注入**空串但键存在**;单个源含 `;` 或为空则 spawn 前失败关闭。 + 测试:`internal/uv` 五条(四键取值与 `;` 保序、offline 空串、受控键清除宿主与选项同名变体、目录回退、非法输入拒绝);`internal/backend` 四条(managed 与 development 各一条、策略矩阵覆盖显式首选/`--mirror-only`/`--offline`/零值 Policy、非法首选 → `INVALID_ARGUMENT` 且零次 spawn),期望值直接由 `BuildPlan` 生成,不靠人工抄写;`internal/cli` 一条锁定全局 policy 交到工厂;E2E `assertE2EBackendInfrastructureEnvironment` 让 `testdata/fakebackend` 把自己进程里读到的四个变量落盘,证明它们穿过 uv 到达了**真实后端进程**,而不只是出现在 Runtime 交给 uv 的 `StartSpec` 里。 + 实测值(development E2E,临时 app-root):`AUTO_MAS_UV_CACHE_DIR=\runtime\cache\uv`、`AUTO_MAS_UV_PYTHON_INSTALL_DIR=\runtime\environment\python`(development 下同样是**受管**目录,不是 `--repo` 里的 `.venv`)、`AUTO_MAS_MIRROR_PACKAGE_INDEX` 为 aliyun/tsinghua/ustc/pypi 四个 simple 索引 URL 以 `;` 相连,`AUTO_MAS_MIRROR_PYTHON` 为 gh-proxy/github 两个 URL,两条列表均官方源末位。 + gofmt/vet/build/`go test ./... -count=1`/`git diff --check` 全绿(各 exit 0);`go test -race ./... -count=1` exit 0(GCC 目录已前置到 PATH)。 + - **未完成**:本任务条目「内容」后半段的 MaaFW 运行池目录重新分类(`config/maafw_runtime_pool/`、`config/maafw_agent_venvs/` 里的 venv/解释器/缓存归「可重建」并纳入 `repair`/`cleanup`)**没有做**,对应验收项「`cleanup`/`repair` 对池目录新分类的行为有测试覆盖」同样未覆盖。理由:`config/` 现为 `ProtectedRootDirs` 里的受保护根,在它内部开一个可删除子树属于删除安全边界的实质放宽,应独立设计与审查;且这两个目录名来自尚未合入的 MaaFW 内置层分支(`work/maafw-embedded-20260830`),在它落地前按名字删 `config/` 下的东西正是红线第 6 条禁止的猜测。该半段与注入面无代码耦合,可独立成任务后续实施。 - [ ] **T13.6 (可选加速)Full 预置 uv 缓存与 cleanup 分类**(S)⏸ 优先级低于 T13.4 - 依赖:T13.4;AUTO-MAS 发布 CI 侧按发布版本预生成缓存 - 内容:让 `cleanup` 区分「随包预置」与「运行期产生」的 uv 缓存,或提供重新播种入口——现在 `internal/cleanup/cleanup.go:280` 无条件删除 `runtime/cache/uv` 且没有补种路径,清理一次就得重新联网。这是纯加速项:受限网络地区的首装可达性已由 T13.4 解决,Lite 与 Full 在这件事上没有区别。 @@ -1202,6 +1209,8 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 | 日期 | 变更 | 操作者 | | --- | --- | --- | +| 2026-09-02 | **T13.5 注入面完成**(`020b3b9`,任务整体仍为 🚧):`backend supervise` 在 managed 与 development 两种模式下注入 `AUTO_MAS_UV_CACHE_DIR`、`AUTO_MAS_UV_PYTHON_INSTALL_DIR`、`AUTO_MAS_MIRROR_PACKAGE_INDEX`、`AUTO_MAS_MIRROR_PYTHON`(增补 1 C11)。plan 由 `internal/backend` 在构造期解析一次(唯一同时持有 layout 与 `mirror.Policy` 的位置),经 `uv.ManagedOptions.Infrastructure` 交给 `StartManaged`;四个键并入受控监督集合,宿主同名变量被清除且不可被 `RunOptions.Environment` 覆盖。不新增 mirror API、不新增协议字段/stage/state/错误码,`UV_*` 注入面与 T12.7 保留键清理行为未变。池目录重新分类未做,理由见 T13.5 条目 | Claude | +| 2026-09-02 | 补 **T13.4** 的 `details` 口径(`1228110`):`bootstrap` 与 `repair` 同样执行 `uv sync`,却只有 `dependencies sync`/`rebuild` 上报 `sourceKind`/`source`/`attemptCount`/`lockRewritten`,首装或修复失败时调用方无从判断用的是哪个镜像源。四个字段补齐到这两条路径,M5 契约测试的断言门槛同步从「仅 dependencies sync」放宽到全部执行 uv sync 的命令(`environment repair` 只修 uv 与 Python、不同步依赖,不在其中)| Claude | | 2026-09-02 | 按 Electron 侧对真实 exe 的接入实测回写 **协议文档偏差**(`doc/架构设计.md`,协议仍为 v1):① stdin 控制命令的 `commandId` 必须是**规范 ULID**(26 位 Crockford base32、首字符 ≤ `7`,`internal/protocol/operation_id.go` 的 `validOperationID`),UUID 因长度不符被判 `invalid_command_id`;② 控制命令 JSON **只允许** `protocol`/`command`/`commandId` 三个键,未知键 `unknown_field`、重复键 `duplicate_field`、缺键 `missing_field`(`decodeControlFields`);③ `--protocol` 不兼容**没有结构化错误**——实测 `--protocol 2` 为 stdout 空、stderr 一行 `auto-mas-runtime: protocol version mismatch`、exit `10`,不产生 `hello`/`result`;④ `result.status` 取值域按命令区分:一次性命令 `succeeded`/`failed`/`cancelled`(跨提交点的失败改用生命周期字面量),`backend supervise` 为 `stopped`/`backend_failed`/`environment_broken`;⑤ `log` 事件的 `source`/`stream` **不定义枚举**(自由字符串,测试中出现过 `stream="unknown"`),调用方只能当标签用;⑥ `result.details` 的 warning 三件套 `warnings`/`warningCount`/`warningsTruncated`,快照上限 `protocol.MaxResultWarningSummaries = 256`、`warningCount` 统计全量;⑦ `hello.capabilities` **随命令变化且常为空**(`version`/`doctor`/`cleanup`/`workspace check` 为 `[]`)。第 ⑦ 条同时暴露一处**文档与实现的真实矛盾**:`backend supervise` 只公告 `["stdin.cancel","state.v1","log.stream"]`(`internal/cli/backend.go`),却经 `NewControlReader` 注册并真实接受 `shutdown`/`status`,而 README 与架构文档都写「实际可用命令以 `hello.capabilities` 为准」。选择**补公告而不是改口**:照文档写的客户端将永远不发 `shutdown`,优雅关闭会退化成 Job 强杀;能力标识属于协议 v1 明确允许追加的集合,补它不升级协议版本。按红线第 2 条先在本次改文档(新增 `stdin.shutdown`、`stdin.status` 两行),代码与契约测试随后单独提交 | Claude | | 2026-09-02 | 登记 **T10.4**(可维护性,不改对外取值):`UV_CHECKSUM_MISMATCH` 映射到常量名 `ExitCodeGitFailure`。退出码数值 `40` 与架构文档一致,只是常量名不对口,容易让读代码的人误判为 Git 域错误 | Claude | | 2026-09-01 | 完成 **T13.4**(实现收口于 `bba97c9`,设计与计划见 `doc/current/M13/设计-T13.4-依赖同步镜像改写.md`):`uv lock --check` 阶段与 `--locked` 校验口径不变,`uv sync` 改为按 `KindPackageIndex` 目录轮换——每个镜像在受管临时项目目录(`runtime/cache/build/dependencies/`)里用改写后的锁副本执行 `--frozen`,plan 末位的官方源改用 `repo` 原锁与 `--locked`,**回退即 plan 末位**,因此 `--mirror-only` 不回退不需要额外分支(`BuildPlan` 本就不放官方源进 plan,耗尽后映射 `MIRROR_EXHAUSTED`),`--offline` 完全不改写。每源只尝试一次(`OutcomeSwitchSource`):`uv sync` 是重操作、uv 自身已对单个 artifact 重试,同源重试只会让断网时的等待翻倍。临时目录经 `filesystem.PrepareManagedDirectory` 创建、`DeleteDependencySync` 受控删除,成功/失败/取消三条路径都收口(取消走 `context.WithoutCancel` + 15 秒预算),全程不写 `repo/` 内任何文件。改写前缀由目录**显式声明**而非推导(官方源 artifact 在 `files.pythonhosted.org`)。事件形态在零契约新增的前提下落地:每次尝试发一条 `dependencies.sync` progress(`ProgressEvent` 无 `details`,message 仅供展示),机器可读事实进 `result.details` 的 `sourceKind` / `source` / `attemptCount` / `lockRewritten`;`log` 事件按架构定义专指受管进程输出转发(能力标识 `log.stream`),`warning` 需要新错误码而 C10 禁止新增,两者都不占用。测试:`internal/mirror` 前缀不变量、`rewriteLockfile` 五项不变量(含哈希逐字不变与幂等)、`internal/uv` 六项轮换/收口用例、`internal/cli` 组件矩阵七条收场(含 stdin 取消时临时目录收口)与契约字段锁定。审查阶段另删除 `internal/uv/network.go` 里包索引的 `--default-index` 死代码路径。标准验证门与 `go test -race ./... -count=1` 均为退出码 0 | Claude | From bcadcb32839e6983a27d35c4fe326e0dbd6c7472 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 01:26:09 +0200 Subject: [PATCH 33/57] =?UTF-8?q?docs:=20AGENTS.md=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E8=A1=A8=E5=9B=9E=E5=86=99=20T13.4=20=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E4=B8=8E=20T13.5=20=E6=B3=A8=E5=85=A5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index df0eb43..a5fae8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ Vue/Electron/Python 的改写、CI/CD 发布流程本身。 --- -## 2. 当前状态(截至 2026-08-31) +## 2. 当前状态(截至 2026-09-02) | 里程碑 | 状态 | | --- | --- | @@ -47,7 +47,7 @@ Vue/Electron/Python 的改写、CI/CD 发布流程本身。 | M10 工程可维护性收敛 | T10.1 文档信息架构已完成;后续阶段见维护设计 | | M11 跨平台适配(Linux/macOS) | 规划中(决策 D7,2026-08-04 立项);仅 T11.1 设计任务可执行 | | M12 遥测与错误观测(Sentry-only) | T12.1、T12.2、T12.4、T12.5、T12.7 已完成;T12.6 本地发布配置已提交,待 Repository secret 与授权后的远端验收;T12.3/Umami 已取消 | -| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;T13.1(后端工作目录)、T13.2(Job 允许显式脱离)、T13.3(关闭超时参数化,含优雅关闭误报修复)**已完成**;T13.4~T13.6 未开始 | +| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;T13.1(后端工作目录)、T13.2(Job 允许显式脱离)、T13.3(关闭超时参数化,含优雅关闭误报修复)、T13.4(锁副本改写参与包索引镜像轮换,显式 package-index 排最前)**已完成**;T13.5 注入面已完成(`AUTO_MAS_UV_CACHE_DIR` / `AUTO_MAS_UV_PYTHON_INSTALL_DIR` / `AUTO_MAS_MIRROR_PACKAGE_INDEX` / `AUTO_MAS_MIRROR_PYTHON`),池目录重新分类未做,任务整体 🚧;T13.6 未开始 | 代码现状: From 9d9bb2b7cb42bd2d440195270a8e7dbff273e0df Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 22:33:34 +0200 Subject: [PATCH 34/57] =?UTF-8?q?docs:=20T13.5=20=E6=B1=A0=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E9=87=8D=E6=96=B0=E5=88=86=E7=B1=BB=E6=8C=89=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E5=85=B3=E9=97=AD=EF=BC=8CT9.1=20development=20?= =?UTF-8?q?=E8=81=94=E8=B0=83=E8=AE=B0=E4=B8=BA=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T13.5 的后半段「MaaFW 运行池目录重新分类」不再实施:注入面落地后,池里真正 可重建的 uv 缓存与受管解释器已物理落在 Runtime 自己的 runtime/cache/uv 与 runtime/environment/python(真机 manifest.json 的 installerMetadata 证实),本就在 cleanup/repair 的既有分类内;config/maafw_runtime_pool/runtimes// 只剩 venv 与 manifest,让 Runtime 认布局、删 venv 留 manifest 等于维护池清单,触红线第 4、6 条。 分工定为 Runtime 只处理自己的目录,池 venv 因基解释器缺失失效后由后端自行判定重建。 按红线第 2 条先改文档:增补 1 C11 结论第 4 条加修订标注并保留原文,架构设计三处、 任务拆分(T13.5 ✅、0.3 顺序说明、TODO-PY-13 对应要求、变更记录)与 AGENTS.md 状态表同步。T13.6 保持 ⏸。 T9.1 按 2026-09-02 真机联调记为 ✅:ba27db3 构建对 AUTO-MAS 集成树 integ/runtime-20260901 的真后端跑通启动→就绪→优雅关闭(ready 3.21s、shutdown 后 收口 0.35s、exit 0、无 warning),附带修复 e5ef0ab 交叉引用 T13.3。T9.2~T9.4 不动。 Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 4 ++-- ...3\273\345\212\241\346\213\206\345\210\206.md" | 16 +++++++++++----- ...\345\205\205-v1-\345\242\236\350\241\2451.md" | 10 ++++++++-- ...6\266\346\236\204\350\256\276\350\256\241.md" | 7 +++++-- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a5fae8b..9522666 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,11 +43,11 @@ Vue/Electron/Python 的改写、CI/CD 发布流程本身。 | M5 uv、Python 与依赖 | T5.1~T5.8 **已完成**(复审修复收口于 `8deb9d7`,含官方 uv 资产、完整组件矩阵与 race 验证) | | M6 后端监督 | T6.1~T6.7 **已完成**(真实 Windows Job/health/control/restart/development/E2E 与对抗复审收口于 `ea886f8`) | | M7 GitHub CI/CD 发布 | T7.1~T7.4 **已完成**(beta.2 Release run `31407585577`、三 job 全绿、独立资产/NDJSON 验收通过);T7.5 按 D3 延后 | -| M9 联调与首版验收 | 未开始 | +| M9 联调与首版验收 | T9.1 development 真后端联调 **已完成**(2026-09-02,`ba27db3` 构建对 AUTO-MAS 集成树 `integ/runtime-20260901` 跑通启动→就绪→优雅关闭);T9.2~T9.4 进行中,尚未回写 | | M10 工程可维护性收敛 | T10.1 文档信息架构已完成;后续阶段见维护设计 | | M11 跨平台适配(Linux/macOS) | 规划中(决策 D7,2026-08-04 立项);仅 T11.1 设计任务可执行 | | M12 遥测与错误观测(Sentry-only) | T12.1、T12.2、T12.4、T12.5、T12.7 已完成;T12.6 本地发布配置已提交,待 Repository secret 与授权后的远端验收;T12.3/Umami 已取消 | -| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;T13.1(后端工作目录)、T13.2(Job 允许显式脱离)、T13.3(关闭超时参数化,含优雅关闭误报修复)、T13.4(锁副本改写参与包索引镜像轮换,显式 package-index 排最前)**已完成**;T13.5 注入面已完成(`AUTO_MAS_UV_CACHE_DIR` / `AUTO_MAS_UV_PYTHON_INSTALL_DIR` / `AUTO_MAS_MIRROR_PACKAGE_INDEX` / `AUTO_MAS_MIRROR_PYTHON`),池目录重新分类未做,任务整体 🚧;T13.6 未开始 | +| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;T13.1(后端工作目录)、T13.2(Job 允许显式脱离)、T13.3(关闭超时参数化,含优雅关闭误报修复)、T13.4(锁副本改写参与包索引镜像轮换,显式 package-index 排最前)**已完成**;T13.5 **已完成**(注入面 `020b3b9`:`AUTO_MAS_UV_CACHE_DIR` / `AUTO_MAS_UV_PYTHON_INSTALL_DIR` / `AUTO_MAS_MIRROR_PACKAGE_INDEX` / `AUTO_MAS_MIRROR_PYTHON`;池目录重新分类按设计关闭——Runtime 的 repair/cleanup 只处理自己的目录,池 venv 失效后由后端判定重建);T13.6 ⏸ 未开始 | 代码现状: diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index a15bdf8..9ef92ca 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -27,7 +27,7 @@ ### 0.3 执行顺序 - 按各任务标注的「依赖」执行;无依赖关系的任务可并行; -- 里程碑整体顺序建议 M0 → M1 → M2 → M3 →(M4 ∥ M5)→ M6 → M7 → M9(M8 编号已随决策 D6 移除,保留空缺);M10 为跨阶段维护;M11 为规划中的跨平台扩展,M12 按 D11 收敛为 Sentry-only 并依 T12.1、T12.2、T12.4~T12.7 实施;M13 为 D12 与契约补充增补 1 的配套实施,T13.1/T13.2/T13.3 可并行,T13.4 → T13.5 → T13.6 有先后,整体建议排在 M9 联调之前; +- 里程碑整体顺序建议 M0 → M1 → M2 → M3 →(M4 ∥ M5)→ M6 → M7 → M9(M8 编号已随决策 D6 移除,保留空缺);M10 为跨阶段维护;M11 为规划中的跨平台扩展,M12 按 D11 收敛为 Sentry-only 并依 T12.1、T12.2、T12.4~T12.7 实施;M13 为 D12 与契约补充增补 1 的配套实施,T13.1/T13.2/T13.3 可并行,T13.4 → T13.5 → T13.6 有先后,整体建议排在 M9 联调之前(2026-09-02 状态:T13.1~T13.5 已完成,其中 T13.5 的「池目录重新分类」半段按设计关闭;T13.6 仍 ⏸;T9.1 development 联调已于同日在 T13.1~T13.5 之上完成); - AUTO-MAS 侧 TODO 由那边的仓库执行,本仓库任务不阻塞在其上,除非「依赖」中显式标注。 ### 0.4 规模标记 @@ -774,10 +774,11 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 ### - [ ] M9 联调与首版验收 -- [ ] **T9.1 development 模式真后端联调**(M) +- [x] **T9.1 development 模式真后端联调**(M)✅ 2026-09-02(Runtime `ba27db3` 构建;附带修复 `e5ef0ab`) - 依赖:M6;AUTO-MAS 侧 TODO-PY-1/2/3 完成后效果最佳,未完成也可先跑并记录差距 - 内容:`backend supervise --mode development --repo ""`(dev_v2 检出)跑真后端;验证 health 轮询、close 链路、日志转发、Job 树清理;发现契约偏差回写第 5 章 TODO 或 T1.0 契约文档。 - 验收:真后端完整「启动 → 就绪 → 优雅关闭」一轮通过;问题清单归档。 + - 证据(2026-09-02):Runtime 可执行文件由 `integ/t13-20260901@ba27db3` 构建(`auto-mas-runtime-final.exe`),对 AUTO-MAS 集成树 `integ/runtime-20260901` 的真后端执行 `backend supervise --mode development --repo <集成树>`。结果:ready 3.21 秒;health 返回 `protocol: 1 / version: v5.5.0-beta.3 / commit: ""`(development 下 commit 为空符合 C2/C7 修订);stdin `shutdown` 被接受,后端退出后 Runtime 收口 0.35 秒、退出码 0;`result.details` 无 warning;`hello.capabilities` 含 `stdin.shutdown` 与 `stdin.status`。联调中发现并修掉的 Runtime 缺陷:优雅关闭后误报 `BACKEND_FORCE_TERMINATED`,根因是 `internal/process/job_windows.go` `snapshot()` 的 TOCTOU,修复 `e5ef0ab`(红绿灯与对照组见 T13.3「附带修复」,此处不重复)。更早一轮(2026-09-01)对同一后端的 M-0 测量:核心 API 首次 200 约 1.1 秒、ready 约 2.0 秒、受监督 close→退出 0.4 秒、无残留。契约偏差回写已由 2026-08-31 的增补 1(C6~C11)与 9 月 1 日/2 日的协议偏差回写提交完成,本任务不再新增 TODO。 - [ ] **T9.2 managed 模式全链路联调**(L) - 依赖:T9.1;TODO-PY-4/6(repo 布局与锁定环境)合入某个真实 `release/*` 分支后才能完整执行 - 内容:全新临时根目录:`bootstrap --version <真实发布版本>` → `backend supervise --mode managed`;覆盖升级(旧 → 新)与显式降级(新 → 旧)各一轮。 @@ -996,7 +997,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 - 内容:`uv lock --check` 阶段不动(仍对 `repo/uv.lock` 原锁执行、不带包索引覆盖参数);`uv sync` 阶段改为按 `internal/mirror` 的 `KindPackageIndex` 目录与既有轮换规则逐个尝试——读原锁到内存做两处前缀改写(`https://pypi.org/simple` → `<镜像 base>/simple`,`https://files.pythonhosted.org/packages/` → `<镜像 base>/packages/`),与 `repo/pyproject.toml` 一起放进受管根内的临时项目目录,`UV_PROJECT_ENVIRONMENT` 指向真实受管 venv,执行 `uv sync --frozen --no-default-groups --no-install-workspace`;失败换下一个源;全部失败回退原锁(PyPI)。`--mirror-only` 不做这次回退,`--offline` 完全不改写。临时项目目录由 Runtime 创建并在成功与失败路径上都删除,归「可丢弃缓存」。`dependencies.sync` 事件的 `details` 报告本次实际使用的源 key。 - **2026-09-01 修订**(见 [增补 1 C10](./契约补充-v1-增补1.md#c10主项目依赖的镜像轮换) 顶部的修订提示):`internal/uv/dependencies.go:234-242` 的 `INVALID_ARGUMENT` 拒绝,连同 `internal/cli/errors.go` 的 `rejectPackageIndexOverride`(bootstrap 与 repair 的前置拒绝)一并删除;显式 `--mirror package-index=` 改为把该源排在尝试顺序最前,走同一条改写路径。`internal/mirror/defaults.go` 的 4 个 `KindPackageIndex` 源此前是死代码(唯一消费方拒绝使用),本条正是它的正当用途;每个源的 `simple` / `packages` 改写前缀由目录**显式声明**,不由 `baseURL` 推导(官方源的 artifact 在 `files.pythonhosted.org`,推导会得到不存在的地址)。 - 验收:单元覆盖两处前缀改写与镜像 base 推导(含 `https://mirrors.aliyun.com/pypi/simple/` 这种带路径前缀的 base);组件测试用本地 HTTP 假索引覆盖「首选镜像不可达 → 次选成功」与「全部镜像失败 → 回退原锁」两条路径,以及 `--mirror-only` 不回退、`--offline` 不改写;**`repo/uv.lock` 与 `repo/pyproject.toml` 在全部路径上字节不变**(哈希断言);临时目录在成功、失败与取消三种收场下都不残留;`details` 中的源 key 与实际使用的源一致;显式 `--mirror package-index=` 时该源第一个被尝试;既有 `LOCKFILE_OUTDATED` 测试无回退。 -- [ ] **T13.5 向后端开放受管基础设施与有序镜像源**(M)🚧 注入面已完成,池目录分类未做 +- [x] **T13.5 向后端开放受管基础设施与有序镜像源**(M)✅ 2026-09-02 `020b3b9`(注入面;「池目录重新分类」半段按设计关闭,见条目末尾) - 依赖:T13.4(同样改 `internal/mirror` 的消费面,分开合以免冲突);契约 [增补 1 C11](./契约补充-v1-增补1.md#c11maafw-运行池的基础设施共享与镜像源下发);成对 TODO-PY-13 - 内容:启动后端时新增注入四个变量——`AUTO_MAS_UV_CACHE_DIR`、`AUTO_MAS_UV_PYTHON_INSTALL_DIR`(受管目录的规范化绝对路径),`AUTO_MAS_MIRROR_PACKAGE_INDEX`、`AUTO_MAS_MIRROR_PYTHON`(`;` 分隔的有序源列表,顺序即 Runtime 解析后的尝试顺序,官方源在末位)。同时把 MaaFW 运行池目录纳入既有分类:venv/解释器/缓存归「可重建」并进入 `repair` / `cleanup`,manifest 与信任基线仍归用户业务数据、绝不删除。Runtime 不读池的依赖声明、不解析池依赖、不维护池的安装清单,不新增池专用命令、stage 或错误码(红线第 4 条)。 - 验收:managed 与 development 两种模式下四个变量都注入且格式正确;列表顺序与 mirror 解析后的尝试顺序逐项一致,`--mirror-only` 不含官方源、`--offline` 为空串;`UV_*` 的注入面与 T12.7 的保留键清理行为**无任何变化**(宿主 `UV_*` 仍不可重新注入);`cleanup` / `repair` 对池目录新分类的行为有测试覆盖,且 manifest 与信任基线在任何自动流程下都不被删除。 @@ -1006,7 +1007,10 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 测试:`internal/uv` 五条(四键取值与 `;` 保序、offline 空串、受控键清除宿主与选项同名变体、目录回退、非法输入拒绝);`internal/backend` 四条(managed 与 development 各一条、策略矩阵覆盖显式首选/`--mirror-only`/`--offline`/零值 Policy、非法首选 → `INVALID_ARGUMENT` 且零次 spawn),期望值直接由 `BuildPlan` 生成,不靠人工抄写;`internal/cli` 一条锁定全局 policy 交到工厂;E2E `assertE2EBackendInfrastructureEnvironment` 让 `testdata/fakebackend` 把自己进程里读到的四个变量落盘,证明它们穿过 uv 到达了**真实后端进程**,而不只是出现在 Runtime 交给 uv 的 `StartSpec` 里。 实测值(development E2E,临时 app-root):`AUTO_MAS_UV_CACHE_DIR=\runtime\cache\uv`、`AUTO_MAS_UV_PYTHON_INSTALL_DIR=\runtime\environment\python`(development 下同样是**受管**目录,不是 `--repo` 里的 `.venv`)、`AUTO_MAS_MIRROR_PACKAGE_INDEX` 为 aliyun/tsinghua/ustc/pypi 四个 simple 索引 URL 以 `;` 相连,`AUTO_MAS_MIRROR_PYTHON` 为 gh-proxy/github 两个 URL,两条列表均官方源末位。 gofmt/vet/build/`go test ./... -count=1`/`git diff --check` 全绿(各 exit 0);`go test -race ./... -count=1` exit 0(GCC 目录已前置到 PATH)。 - - **未完成**:本任务条目「内容」后半段的 MaaFW 运行池目录重新分类(`config/maafw_runtime_pool/`、`config/maafw_agent_venvs/` 里的 venv/解释器/缓存归「可重建」并纳入 `repair`/`cleanup`)**没有做**,对应验收项「`cleanup`/`repair` 对池目录新分类的行为有测试覆盖」同样未覆盖。理由:`config/` 现为 `ProtectedRootDirs` 里的受保护根,在它内部开一个可删除子树属于删除安全边界的实质放宽,应独立设计与审查;且这两个目录名来自尚未合入的 MaaFW 内置层分支(`work/maafw-embedded-20260830`),在它落地前按名字删 `config/` 下的东西正是红线第 6 条禁止的猜测。该半段与注入面无代码耦合,可独立成任务后续实施。 + - **池目录重新分类:按设计关闭(2026-09-02)**。「内容」后半段的 MaaFW 运行池目录重新分类(`config/maafw_runtime_pool/`、`config/maafw_agent_venvs/` 里的 venv/解释器/缓存归「可重建」并纳入 `repair`/`cleanup`)**不再实施**,对应验收项「`cleanup`/`repair` 对池目录新分类的行为有测试覆盖」随之撤销;[增补 1 C11](./契约补充-v1-增补1.md#c11maafw-运行池的基础设施共享与镜像源下发) 结论第 4 条与 `架构设计.md` 同日修订。依据有三: + ① 注入面落地后,池里真正可重建的两样东西——uv 缓存与受管解释器——已经**物理上**住在 Runtime 自己的目录里(`AUTO_MAS_UV_CACHE_DIR=/runtime/cache/uv`、`AUTO_MAS_UV_PYTHON_INSTALL_DIR=/runtime/environment/python`),本来就在 `cleanup`/`repair` 的既有分类内。真机证据(2026-09-02,AUTO-MAS 集成树 `integ/runtime-20260901`,受监督后端上 `POST /api/scripts/maafw/agent-env/prepare` 安装 M9A 项目):池的 `manifest.json` 记 `installerMetadata.cache = {scope: "pool", shared: true, path: "/runtime/cache/uv"}`、`installerMetadata.installer.executable = "/runtime/tools/uv/0.12.3/uv.exe"`、`installerMetadata.index = {attempt: 1, source: "https://mirrors.aliyun.com/pypi/simple/"}`;`config/maafw_runtime_pool/` 下不再出现 `cache/`,`python/` 只是空目录。 + ② 留在 `config/maafw_runtime_pool/runtimes//` 里的只剩 venv 与 `manifest.json`。让 Runtime 识别这个布局、删 venv 留 manifest,等于让 Runtime 维护池的安装清单——正是红线第 4 条(不读池依赖、不维护池清单、不新增池专用命令/stage/错误码)与第 6 条(不按名字猜删 `config/` 下的东西)禁止的;`config/` 也仍是 `ProtectedRootDirs` 里的受保护根。 + ③ 分工定为:**Runtime 的 `cleanup`/`repair` 只处理自己的目录**;它删掉受管解释器或缓存后,池里依赖它们的 venv 因基解释器缺失而失效,由后端(AUTO-MAS 侧运行池的基解释器校验)自行判定并重建。「装什么」与「重建谁」都留在后端;AUTO-MAS 侧是否真的自愈由主线程另做真机验证,Runtime 侧不因此新增任何行为。 - [ ] **T13.6 (可选加速)Full 预置 uv 缓存与 cleanup 分类**(S)⏸ 优先级低于 T13.4 - 依赖:T13.4;AUTO-MAS 发布 CI 侧按发布版本预生成缓存 - 内容:让 `cleanup` 区分「随包预置」与「运行期产生」的 uv 缓存,或提供重新播种入口——现在 `internal/cleanup/cleanup.go:280` 无条件删除 `runtime/cache/uv` 且没有补种路径,清理一次就得重新联网。这是纯加速项:受限网络地区的首装可达性已由 T13.4 解决,Lite 与 Full 在这件事上没有区别。 @@ -1118,7 +1122,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 - 依赖:[增补 1 C11](./契约补充-v1-增补1.md#c11maafw-运行池的基础设施共享与镜像源下发)、内置层已合 - 位置:`.../automas_maafw_runtime_pool/installer.py`、`pool.py`、`cache.py` - 现状(契约 M-5):MaaFW 在 `config/maafw_runtime_pool/` 下自己维护解释器池和 uv 缓存,接入后会与 Runtime 的受管 Python 并存——两份 Python、两份 uv 缓存、两套互不相通的镜像开关;池目录在 `config/` 下按 Runtime 的分类属于「用户业务数据,自动更新绝不能删除」,可里面其实是可重建的 venv、解释器和缓存,坏掉之后没有任何受管的修复入口。 - - 要求(契约 T-1~T-4 的 AUTO-MAS 侧):池的 `--cache-dir` 改指 `AUTO_MAS_UV_CACHE_DIR`、`--install-dir` 改指 `AUTO_MAS_UV_PYTHON_INSTALL_DIR`;镜像改吃 Runtime 下发的 `AUTO_MAS_MIRROR_PACKAGE_INDEX` / `AUTO_MAS_MIRROR_PYTHON` 有序列表并按序重试(现在只有单值 `AUTO_MAS_UV_INDEX_URL` / `AUTO_MAS_UV_PYTHON_INSTALL_MIRROR`,没有轮换);池目录按新分类拆开,venv/解释器/缓存纳入 `repair` / `cleanup`,manifest 与信任基线仍归用户数据。 + - 要求(契约 T-1~T-4 的 AUTO-MAS 侧):池的 `--cache-dir` 改指 `AUTO_MAS_UV_CACHE_DIR`、`--install-dir` 改指 `AUTO_MAS_UV_PYTHON_INSTALL_DIR`;镜像改吃 Runtime 下发的 `AUTO_MAS_MIRROR_PACKAGE_INDEX` / `AUTO_MAS_MIRROR_PYTHON` 有序列表并按序重试(现在只有单值 `AUTO_MAS_UV_INDEX_URL` / `AUTO_MAS_UV_PYTHON_INSTALL_MIRROR`,没有轮换);~~池目录按新分类拆开,venv/解释器/缓存纳入 `repair` / `cleanup`,manifest 与信任基线仍归用户数据~~(2026-09-02 随 T13.5 按设计关闭:缓存与解释器改用 Runtime 受管目录后已迁出 `config/`,Runtime 不动池目录;池只需在基解释器缺失时自行判定并重建 venv,见 C11 结论第 4 条修订)。 - **必须保留的两条**:身份探针里真的 `import ctypes`(代码注释记着真机漏过一次,探测全绿、worker 才在 `maa/library.py` 第 1 行炸掉);`UV_LINK_MODE=hardlink` 要求缓存与环境同卷,合并目录时要一起验。 - 已经不用担心的:Runtime 注入的 `UV_CACHE_DIR` / `UV_PYTHON_INSTALL_DIR` 不会劫持池——池对两者都传显式命令行 flag,命令行覆盖环境变量。待验证:Runtime 注入的 `UV_MANAGED_PYTHON=1` 会不会影响池的「项目自带解释器」那条路——池的 `_clean_process_environment` 只剔除 `PYTHONHOME` / `PYTHONUSERBASE` / `PYTHONPATH` / `PIP_*`,`UV_*` 不在剔除名单里。 @@ -1209,6 +1213,8 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 | 日期 | 变更 | 操作者 | | --- | --- | --- | +| 2026-09-02 | **T13.5 收口为 ✅**:「池目录重新分类」半段**按设计关闭**,不再实施,对应验收项撤销。注入面落地后,池真正可重建的 uv 缓存与受管解释器已物理落在 `/runtime/cache/uv` 与 `/runtime/environment/python`(真机 `manifest.json` 的 `installerMetadata.cache.path` / `installer.executable` 证实,`config/maafw_runtime_pool/` 下已无 `cache/`),本就在 `cleanup`/`repair` 的既有分类内;`config/maafw_runtime_pool/runtimes//` 只剩 venv 与 manifest,让 Runtime 识别该布局并删 venv 留 manifest 等于维护池清单,触红线第 4、6 条。分工改为 Runtime 只处理自己的目录,池 venv 因基解释器缺失失效后由后端自行判定重建。同步修订增补 1 C11 结论第 4 条、`架构设计.md` 三处(文档状态修订项、插件环境职责边界增补段、dev 基线目录分类表)、5.1 TODO-PY-13 的对应要求与 AGENTS.md 状态表;T13.6 保持 ⏸ | Claude | +| 2026-09-02 | 完成 **T9.1** development 真后端联调:`integ/t13-20260901@ba27db3` 构建的 Runtime 对 AUTO-MAS 集成树 `integ/runtime-20260901` 的真后端 `backend supervise --mode development` 完整跑通「启动 → 就绪 → 优雅关闭」:ready 3.21 秒,health `protocol: 1 / version: v5.5.0-beta.3 / commit: ""`,stdin `shutdown` 被接受、后端退出后 Runtime 收口 0.35 秒、exit 0,`result.details` 无 warning,`hello.capabilities` 含 `stdin.shutdown` 与 `stdin.status`。过程中修掉优雅关闭误报 `BACKEND_FORCE_TERMINATED`(`e5ef0ab`,见 T13.3);契约偏差已由增补 1(C6~C11)与 9 月 1/2 日的协议偏差回写收口,不再新增 TODO。T9.2~T9.4 不动 | Claude | | 2026-09-02 | **T13.5 注入面完成**(`020b3b9`,任务整体仍为 🚧):`backend supervise` 在 managed 与 development 两种模式下注入 `AUTO_MAS_UV_CACHE_DIR`、`AUTO_MAS_UV_PYTHON_INSTALL_DIR`、`AUTO_MAS_MIRROR_PACKAGE_INDEX`、`AUTO_MAS_MIRROR_PYTHON`(增补 1 C11)。plan 由 `internal/backend` 在构造期解析一次(唯一同时持有 layout 与 `mirror.Policy` 的位置),经 `uv.ManagedOptions.Infrastructure` 交给 `StartManaged`;四个键并入受控监督集合,宿主同名变量被清除且不可被 `RunOptions.Environment` 覆盖。不新增 mirror API、不新增协议字段/stage/state/错误码,`UV_*` 注入面与 T12.7 保留键清理行为未变。池目录重新分类未做,理由见 T13.5 条目 | Claude | | 2026-09-02 | 补 **T13.4** 的 `details` 口径(`1228110`):`bootstrap` 与 `repair` 同样执行 `uv sync`,却只有 `dependencies sync`/`rebuild` 上报 `sourceKind`/`source`/`attemptCount`/`lockRewritten`,首装或修复失败时调用方无从判断用的是哪个镜像源。四个字段补齐到这两条路径,M5 契约测试的断言门槛同步从「仅 dependencies sync」放宽到全部执行 uv sync 的命令(`environment repair` 只修 uv 与 Python、不同步依赖,不在其中)| Claude | | 2026-09-02 | 按 Electron 侧对真实 exe 的接入实测回写 **协议文档偏差**(`doc/架构设计.md`,协议仍为 v1):① stdin 控制命令的 `commandId` 必须是**规范 ULID**(26 位 Crockford base32、首字符 ≤ `7`,`internal/protocol/operation_id.go` 的 `validOperationID`),UUID 因长度不符被判 `invalid_command_id`;② 控制命令 JSON **只允许** `protocol`/`command`/`commandId` 三个键,未知键 `unknown_field`、重复键 `duplicate_field`、缺键 `missing_field`(`decodeControlFields`);③ `--protocol` 不兼容**没有结构化错误**——实测 `--protocol 2` 为 stdout 空、stderr 一行 `auto-mas-runtime: protocol version mismatch`、exit `10`,不产生 `hello`/`result`;④ `result.status` 取值域按命令区分:一次性命令 `succeeded`/`failed`/`cancelled`(跨提交点的失败改用生命周期字面量),`backend supervise` 为 `stopped`/`backend_failed`/`environment_broken`;⑤ `log` 事件的 `source`/`stream` **不定义枚举**(自由字符串,测试中出现过 `stream="unknown"`),调用方只能当标签用;⑥ `result.details` 的 warning 三件套 `warnings`/`warningCount`/`warningsTruncated`,快照上限 `protocol.MaxResultWarningSummaries = 256`、`warningCount` 统计全量;⑦ `hello.capabilities` **随命令变化且常为空**(`version`/`doctor`/`cleanup`/`workspace check` 为 `[]`)。第 ⑦ 条同时暴露一处**文档与实现的真实矛盾**:`backend supervise` 只公告 `["stdin.cancel","state.v1","log.stream"]`(`internal/cli/backend.go`),却经 `NewControlReader` 注册并真实接受 `shutdown`/`status`,而 README 与架构文档都写「实际可用命令以 `hello.capabilities` 为准」。选择**补公告而不是改口**:照文档写的客户端将永远不发 `shutdown`,优雅关闭会退化成 Job 强杀;能力标识属于协议 v1 明确允许追加的集合,补它不升级协议版本。按红线第 2 条先在本次改文档(新增 `stdin.shutdown`、`stdin.status` 两行),代码与契约测试随后单独提交 | Claude | diff --git "a/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" "b/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" index 7cd6432..06efeb7 100644 --- "a/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" +++ "b/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" @@ -9,6 +9,7 @@ - 架构基线:[架构设计](./架构设计.md) - 实现基线:Runtime `40ef464`;AUTO-MAS `dev@3c422093` 与 MaaFW 内置层 `work/maafw-embedded-20260830@1561bc55`(决策 D12) - 修订:2026-09-01 修订 C10 对显式 `--mirror package-index=` 的处理,并把镜像改写前缀由推导改为显式声明(T13.4 实施期间,见 C10) +- 修订:2026-09-02 修订 C11 结论第 4 条:「池目录的重新分类」按设计关闭,改为「Runtime 的 `repair` / `cleanup` 只处理自己的目录;池 venv 因基解释器缺失失效后由后端自行判定重建」(T13.5 收口时,见 C11) 本文档是对 [契约补充-v1](./契约补充-v1.md) 的**增量修订**,定稿六项此前未冻结或与实现矛盾的对外契约,并修订 C2、C4 各一条。 `契约补充-v1.md` 开头已规定「对外字段、字面量或环境变量发生变化时必须升级或增量修订共同契约和测试」,本文档走的正是增量修订这条路: @@ -249,6 +250,10 @@ Runtime `T13.4`;AUTO-MAS `TODO-PY-6`(锁文件在 PyPI 上生成、入库与 ## C11:MaaFW 运行池的基础设施共享与镜像源下发 +> **2026-09-02 修订(T13.5 收口时):** 一处。结论第 4 条「池目录的重新分类」按设计关闭,改为 +> 「Runtime 的 `repair` / `cleanup` 只处理自己的目录;池 venv 因基解释器缺失失效后由后端自行判定重建」 +> (原文与理由见结论第 4 条)。其余条款不变。 + ### 结论 **整体接管不可行,只统一基础设施;「装什么」仍然留在后端。** @@ -258,7 +263,8 @@ Runtime 向后端开放: 1. **共享 uv 缓存目录**——经 `AUTO_MAS_UV_CACHE_DIR` 注入,主项目与 MaaFW 运行池共用一份 wheel 缓存; 2. **共享受管 Python 安装目录**——经 `AUTO_MAS_UV_PYTHON_INSTALL_DIR` 注入,两边的 `uv python install` 共用一份解释器; 3. **解析后的有序镜像源列表**——经 `AUTO_MAS_MIRROR_PACKAGE_INDEX` 与 `AUTO_MAS_MIRROR_PYTHON` 注入,后端**自行按序重试**; -4. **池目录的重新分类**——venv、解释器、缓存归「可重建」,纳入 `repair` / `cleanup`;manifest 与信任基线归「用户业务数据」,自动流程绝不删除。 +4. ~~**池目录的重新分类**——venv、解释器、缓存归「可重建」,纳入 `repair` / `cleanup`;manifest 与信任基线归「用户业务数据」,自动流程绝不删除。~~ + **2026-09-02 修订(T13.5 收口时):本条按设计关闭,改为分工规则**——Runtime 的 `repair` / `cleanup` **只处理自己的目录**(`runtime/cache/uv`、`runtime/environment/python` 等既有分类);池目录留在 `config/` 下、仍随 `config/` 归用户业务数据,Runtime 不识别其布局、不删其中任何条目。Runtime 删除受管解释器或缓存后,池中依赖它们的 venv 因基解释器缺失而失效,由后端(运行池的基解释器校验)自行判定并重建。理由:前三条落地后,池里真正可重建的 uv 缓存与受管解释器已物理落在 Runtime 自己的目录(2026-09-02 真机:池 `manifest.json` 的 `installerMetadata.cache.path` 为 `/runtime/cache/uv`、`installer.executable` 为 `/runtime/tools/uv/0.12.3/uv.exe`,`config/maafw_runtime_pool/` 下已无 `cache/`),`config/maafw_runtime_pool/runtimes//` 只剩 venv 与 `manifest.json`;让 Runtime 认这个布局、删 venv 留 manifest 等于维护池的安装清单,与下一段的四个「不」及红线第 4、6 条冲突,`config/` 也仍是受保护根。 Runtime **不**读取 MaaFW 项目的依赖声明,**不**解析池依赖,**不**维护池的安装清单,**不**决定装哪个 Python 版本或哪些包,也**不**新增池专用命令、stage 或错误码。 @@ -281,7 +287,7 @@ Runtime **不**读取 MaaFW 项目的依赖声明,**不**解析池依赖,** - 两份 Python:Runtime 的 `runtime/environment/python` 与池的 `/python`; - 两份 uv 缓存:`runtime/cache/uv` 与 `/cache/uv`,wheel 不复用。合并时注意 `UV_LINK_MODE=hardlink` 要求缓存与环境**同卷**; - 两套镜像开关互不相通:Runtime 有 `KindUV` 5 源、`KindPython` 2 源、`KindPackageIndex` 4 源的多路轮换;池只有单值环境变量(`AUTO_MAS_UV_INDEX_URL` / `AUTO_MAS_UV_PYTHON_INSTALL_MIRROR`),没有轮换; -- 数据分类错位:池在 `config/` 下,按 Runtime 的分类属于「用户业务数据,自动更新绝不能删除」,可里面其实是可重建的 venv、解释器和缓存,坏掉之后没有任何受管的修复入口。 +- 数据分类错位:池在 `config/` 下,按 Runtime 的分类属于「用户业务数据,自动更新绝不能删除」,可里面其实是可重建的 venv、解释器和缓存,坏掉之后没有任何受管的修复入口。(2026-09-02:这一项由前两条消除——解释器与缓存随注入面迁出 `config/`,剩下的 venv 由后端在基解释器缺失时自行重建;见结论第 4 条修订。) ### 与 C10 的关系 diff --git "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" index 0ac4a02..a8f958c 100644 --- "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" +++ "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -24,6 +24,9 @@ 同时按红线第 2 条先行补入 `stdin.shutdown`、`stdin.status` 两个能力标识—— 实现已接受这两条命令却没有公告,与「实际可用命令以 `hello.capabilities` 为准」直接矛盾。 协议仍为 v1:能力标识属于允许追加的集合,不改名、不删除既有标识 +- 修订:2026-09-02 按 T13.5 收口修订 MaaFW 运行池目录的处置:原拟的「池目录重新分类」按设计关闭, + Runtime 的 repair/cleanup 只处理自己的目录,池 venv 因基解释器缺失失效后由后端自行判定重建 + (增补 1 C11 结论第 4 条同日修订;「插件环境职责边界」与「文件与删除安全边界」两处同步) - 当前范围:系统边界、职责划分、CLI 协议、纯 Git 更新、uv 环境、健康检查、进程监督、镜像、插件职责边界、目录安全、错误与状态契约、测试矩阵、验收标准和分阶段迁移 ## 背景 @@ -1553,7 +1556,7 @@ AUTO-MAS-Runtime Runtime 管理 uv 工具本身,不等于管理插件依赖。Runtime 不读取插件安装清单,不执行插件依赖同步,不提供任何插件同步或重建命令,也不产生插件专用状态或错误码。 -**2026-08-31 增补([增补 1 C11](./契约补充-v1-增补1.md#c11maafw-运行池的基础设施共享与镜像源下发)):** 同一条边界适用于 AUTO-MAS 后端的 MaaFW 运行池——它在环境数量、Python 版本、依赖声明和解析时机四个维度上与主项目模型正相反(N 个环境、多个 minor、运行时在线解析、`uv pip install` 自由解析),整体交给 Runtime 等于把它改造成通用包管理器,与本节边界和红线第 4 条直接冲突。因此**只统一基础设施**:共享 uv 缓存与受管 Python 目录(经 `AUTO_MAS_UV_CACHE_DIR`、`AUTO_MAS_UV_PYTHON_INSTALL_DIR` 注入)、下发有序镜像源(经 `AUTO_MAS_MIRROR_PACKAGE_INDEX`、`AUTO_MAS_MIRROR_PYTHON` 注入),并把池目录重新分类——venv、解释器、缓存归「可重建」并纳入 `repair` / `cleanup`,manifest 与信任基线仍归用户业务数据。变量的取值格式与命名规则见增补 1「新增注入环境变量」。 +**2026-08-31 增补([增补 1 C11](./契约补充-v1-增补1.md#c11maafw-运行池的基础设施共享与镜像源下发)):** 同一条边界适用于 AUTO-MAS 后端的 MaaFW 运行池——它在环境数量、Python 版本、依赖声明和解析时机四个维度上与主项目模型正相反(N 个环境、多个 minor、运行时在线解析、`uv pip install` 自由解析),整体交给 Runtime 等于把它改造成通用包管理器,与本节边界和红线第 4 条直接冲突。因此**只统一基础设施**:共享 uv 缓存与受管 Python 目录(经 `AUTO_MAS_UV_CACHE_DIR`、`AUTO_MAS_UV_PYTHON_INSTALL_DIR` 注入)、下发有序镜像源(经 `AUTO_MAS_MIRROR_PACKAGE_INDEX`、`AUTO_MAS_MIRROR_PYTHON` 注入)。**2026-09-02 修订(T13.5 收口):** 原拟的「池目录重新分类——venv、解释器、缓存归『可重建』并纳入 `repair` / `cleanup`」按设计关闭。前两项落地后,池真正可重建的缓存与解释器已物理落在 Runtime 自己的 `runtime/cache/uv` 与 `runtime/environment/python`(真机 `manifest.json` 的 `installerMetadata` 证实),本就在既有分类内;池目录里只剩 venv 与 manifest,Runtime 识别该布局并删 venv 留 manifest 等于维护池的安装清单,触本节边界与红线第 4、6 条。定稿分工:Runtime 的 `repair` / `cleanup` **只处理自己的目录**,池目录随 `config/` 归用户业务数据;Runtime 删除受管解释器或缓存后,依赖它们的池 venv 由后端的基解释器校验自行判定并重建。变量的取值格式与命名规则见增补 1「新增注入环境变量」。 插件依赖失败不进入 `environment_broken`,因为主项目 venv 仍可能完全有效。Python 后端负责生成插件域错误并决定是否继续初始化;Runtime 只根据后端进程退出和 `/api/core/health` 的通用结果输出后端启动错误,同时保留完整 stdout/stderr。需要安装、修复或删除插件时,Electron/Vue 通过 Python 插件 API 操作,不通过 Runtime CLI。 @@ -1901,7 +1904,7 @@ app/core/plugins/_generated/ | 条目 | 内容 | 分类与 Runtime 权限 | | --- | --- | --- | -| `config/maafw_runtime_pool/`、`config/maafw_agent_venvs/` | MaaFW 的解释器池、uv 缓存与隔离 venv | 当前随 `config/` 归用户业务数据、绝不删除;按[增补 1 C11](./契约补充-v1-增补1.md#c11maafw-运行池的基础设施共享与镜像源下发)重新拆分后,其中的 venv/解释器/缓存改归「可重建主环境」并纳入 repair/cleanup,manifest 与信任基线仍归用户业务数据 | +| `config/maafw_runtime_pool/`、`config/maafw_agent_venvs/` | MaaFW 的隔离 venv 与安装 manifest(接入 [增补 1 C11](./契约补充-v1-增补1.md#c11maafw-运行池的基础设施共享与镜像源下发) 后解释器与 uv 缓存改用 Runtime 注入的受管目录,不再落在此处) | 随 `config/` 归用户业务数据、绝不删除。**2026-09-02 修订(T13.5 收口):** 原拟按 C11 拆出 venv/解释器/缓存归「可重建主环境」并纳入 repair/cleanup 的方案按设计关闭;Runtime 不识别池布局、不删其中任何条目,池 venv 因 Runtime 删除受管解释器或缓存而失效时由后端自行判定重建 | | `data/maafw_project_store/` 等 MaaFW 项目数据 | 项目仓库、运行记录、受管下载与更新缓存 | 随 `data/` 归用户业务数据,绝不删除 | | `runtime/`(后端侧) | MaaFW 的 `maafw_runner_jobs`、HSR 的 `hsr` | **名字与 Runtime 的 `/runtime/` 冲突**:cwd 按[增补 1 C6](./契约补充-v1-增补1.md#c6后端进程工作目录与入口路径)改到 app-root 之后两者同名同层。处置是后端把这两处挪进 `data/`,`runtime/` 整个归 Runtime(`任务拆分.md` TODO-PY-10) | | `environment/` | 便携 python/git/hpatchz | 接入后不再产生;hpatchz 缓存需另找落点 | From e77464765254e2f5d90d6588a93c9a65624d4662 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 22:55:33 +0200 Subject: [PATCH 35/57] =?UTF-8?q?docs:=20=E5=AE=9A=E7=A8=BF=E5=A2=9E?= =?UTF-8?q?=E8=A1=A5=201=20C12/C13=20=E5=B9=B6=E7=AB=8B=E9=A1=B9=20T13.7?= =?UTF-8?q?=20=E5=8F=97=E7=9B=91=E7=9D=A3=E7=AB=AF=E5=8F=A3=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E4=B8=8E=20T13.8=20=E5=AE=BF=E4=B8=BB=E6=96=AD?= =?UTF-8?q?=E5=BC=80=E5=8D=B3=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机联调暴露两个首版前必须修掉的问题,按红线第 2 条先改文档: - C12:backend supervise --port(1024~65535,缺省 managed 36163 / development 36164), 注入 AUTO_MAS_SUPERVISED_PORT 并并入受控监督键集合,健康/关闭地址与 baseUrl 由它派生; 同步修订 C1、C7 结论第 1 条、架构设计五处、TODO-PY-8 与 D-open-2 - C13:backend supervise 的 stdin EOF 或读取出错视为隐式 shutdown,stdout 已断也须能退出, 其他命令不变 - 任务拆分新增 T13.7 / T13.8 条目与变更记录;doc/current/M13 各补一份设计与计划; AGENTS.md 状态表同步 Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 4 +- ...4\261-Runtime-\346\263\250\345\205\245.md" | 111 +++++++++++++++++ ...00\345\215\263\345\205\263\351\227\255.md" | 117 ++++++++++++++++++ doc/current/README.md | 5 +- ...73\345\212\241\346\213\206\345\210\206.md" | 15 ++- ...5\205\205-v1-\345\242\236\350\241\2451.md" | 94 ++++++++++++-- ...347\272\246\350\241\245\345\205\205-v1.md" | 14 ++- ...66\346\236\204\350\256\276\350\256\241.md" | 35 +++++- 8 files changed, 373 insertions(+), 22 deletions(-) create mode 100644 "doc/current/M13/\350\256\276\350\256\241-T13.7-\345\217\227\347\233\221\347\235\243\347\253\257\345\217\243\347\224\261-Runtime-\346\263\250\345\205\245.md" create mode 100644 "doc/current/M13/\350\256\276\350\256\241-T13.8-\345\256\277\344\270\273\346\226\255\345\274\200\345\215\263\345\205\263\351\227\255.md" diff --git a/AGENTS.md b/AGENTS.md index 9522666..66e96b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Vue/Electron/Python 的改写、CI/CD 发布流程本身。 | M10 工程可维护性收敛 | T10.1 文档信息架构已完成;后续阶段见维护设计 | | M11 跨平台适配(Linux/macOS) | 规划中(决策 D7,2026-08-04 立项);仅 T11.1 设计任务可执行 | | M12 遥测与错误观测(Sentry-only) | T12.1、T12.2、T12.4、T12.5、T12.7 已完成;T12.6 本地发布配置已提交,待 Repository secret 与授权后的远端验收;T12.3/Umami 已取消 | -| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;T13.1(后端工作目录)、T13.2(Job 允许显式脱离)、T13.3(关闭超时参数化,含优雅关闭误报修复)、T13.4(锁副本改写参与包索引镜像轮换,显式 package-index 排最前)**已完成**;T13.5 **已完成**(注入面 `020b3b9`:`AUTO_MAS_UV_CACHE_DIR` / `AUTO_MAS_UV_PYTHON_INSTALL_DIR` / `AUTO_MAS_MIRROR_PACKAGE_INDEX` / `AUTO_MAS_MIRROR_PYTHON`;池目录重新分类按设计关闭——Runtime 的 repair/cleanup 只处理自己的目录,池 venv 失效后由后端判定重建);T13.6 ⏸ 未开始 | +| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;T13.1(后端工作目录)、T13.2(Job 允许显式脱离)、T13.3(关闭超时参数化,含优雅关闭误报修复)、T13.4(锁副本改写参与包索引镜像轮换,显式 package-index 排最前)**已完成**;T13.5 **已完成**(注入面 `020b3b9`:`AUTO_MAS_UV_CACHE_DIR` / `AUTO_MAS_UV_PYTHON_INSTALL_DIR` / `AUTO_MAS_MIRROR_PACKAGE_INDEX` / `AUTO_MAS_MIRROR_PYTHON`;池目录重新分类按设计关闭——Runtime 的 repair/cleanup 只处理自己的目录,池 venv 失效后由后端判定重建);T13.6 ⏸ 未开始;2026-09-02 真机联调后按增补 1 **C12 / C13** 立项 T13.7(`backend supervise --port`,缺省 managed 36163 / development 36164,注入 `AUTO_MAS_SUPERVISED_PORT`,健康/关闭地址与 `baseUrl` 由它派生,E2E 改用空闲端口)与 T13.8(`backend supervise` 的 stdin EOF 视为隐式 shutdown,宿主崩溃不再留孤儿)🚧 进行中 | 代码现状: @@ -77,7 +77,7 @@ Git:远端 `origin` = `git@github.com:AUTO-MAS-Project/AUTO-MAS-Runtime.git` | --- | --- | --- | | [doc/README.md](doc/README.md) | 文档导航、分层与生命周期规则 | 查找任何项目文档时 | | [doc/架构设计.md](doc/架构设计.md) | 冻结的系统架构:边界、CLI 命令树、NDJSON 协议、错误码/退出码/stage/state 全集、Git 更新流程、uv 策略、目录安全、测试矩阵、验收标准 | 任何涉及对外契约的改动 | -| [doc/契约补充-v1.md](doc/契约补充-v1.md) | 协议 v1 的 5 项定稿细节(C1~C5:固定端口 36163、身份注入环境变量、`failed` 字面量、`AUTO_MAS_SUPERVISED=1`、development 检查边界) | 涉及后端启动/健康检查/环境变量 | +| [doc/契约补充-v1.md](doc/契约补充-v1.md) | 协议 v1 的 5 项定稿细节(C1~C5:后端端口(已由增补 1 C12 改为 Runtime 注入)、身份注入环境变量、`failed` 字面量、`AUTO_MAS_SUPERVISED=1`、development 检查边界) | 涉及后端启动/健康检查/环境变量 | | [doc/契约补充-v1-增补1.md](doc/契约补充-v1-增补1.md) | 对 v1 的增量修订(C6~C11:后端工作目录、受监督优先级扩展到端口、Job 逃逸、关闭预算参数化、依赖镜像改写轮换、运行池基础设施共享),并修订 C2 第 1 条与 C4 第 1 条 | 同上;**与 `契约补充-v1.md` 冲突时以本文件为准** | | [doc/任务拆分.md](doc/任务拆分.md) | 逐任务清单、依赖、验收项、决策记录 D1~D12、待决项 D-open-*、AUTO-MAS 侧 TODO、变更记录 | **每次开工前**确认自己在做哪个任务 | | [doc/代码审查清单.md](doc/代码审查清单.md) | 自动化门禁覆盖不到的架构边界检查 | 提交前自查、审查他人代码 | diff --git "a/doc/current/M13/\350\256\276\350\256\241-T13.7-\345\217\227\347\233\221\347\235\243\347\253\257\345\217\243\347\224\261-Runtime-\346\263\250\345\205\245.md" "b/doc/current/M13/\350\256\276\350\256\241-T13.7-\345\217\227\347\233\221\347\235\243\347\253\257\345\217\243\347\224\261-Runtime-\346\263\250\345\205\245.md" new file mode 100644 index 0000000..c07515f --- /dev/null +++ "b/doc/current/M13/\350\256\276\350\256\241-T13.7-\345\217\227\347\233\221\347\235\243\347\253\257\345\217\243\347\224\261-Runtime-\346\263\250\345\205\245.md" @@ -0,0 +1,111 @@ +# 设计与计划 T13.7 受监督端口由 Runtime 注入 + +- 契约:[增补 1 C12](../../契约补充-v1-增补1.md#c12受监督端口由-runtime-注入)(同日修订 C1 与 C7 结论第 1 条) +- 任务:[任务拆分 T13.7](../../任务拆分.md) +- 成对:AUTO-MAS `TODO-PY-8`(后端改读 `AUTO_MAS_SUPERVISED_PORT`,缺失回退 36163) +- 状态:设计 + 计划 + +## 目标 + +`backend supervise` 新增 `--port `(整数,`1024`~`65535`),缺省按模式:managed `36163`、 +development `36164`。Runtime 把端口作为 `AUTO_MAS_SUPERVISED_PORT` 注入后端,并让健康检查地址、 +关闭地址与 `backend.run` / `running` 事件里的 `baseUrl` 全部由它派生。协议不新增字段。 + +## 边界(不负责) + +- 不做端口探测、不自动换端口:端口是调用方与后端的地址约定,自动挑选会掩盖「另一份实例已在跑」 + 这个真实冲突;端口被占用时由健康检查按既有口径超时失败; +- 不改 `hello.capabilities`、不新增 stage / state / 错误码; +- 不改后端侧(AUTO-MAS 由另一位代理按 TODO-PY-8 同步实现); +- 就绪侧预算、关闭预算与 `AUTO_MAS_SUPERVISED=1` 等既有注入键一个字节都不动。 + +## API 形态与数据流 + +```text +CLI --port(string,自行 strconv.Atoi,与 --shutdown-timeout 同一理由) + └─ 缺省按 --mode:managed → 36163,development → 36164 + └─ backend.Request.Port(int) + ├─ health.Expectation.Port → Checker 请求 http://127.0.0.1:/api/core/health + ├─ 关闭:portHTTPCloser{port} → POST http://127.0.0.1:/api/core/close + ├─ running 事件 details.baseUrl = http://127.0.0.1: + └─ uv.ManagedOptions.Port → 子进程环境 AUTO_MAS_SUPERVISED_PORT= +``` + +- **单一真值来源**:`internal/health` 导出 `DefaultPort`、`BaseURL(port)`、`HealthURLForPort(port)`; + `HealthURL` 保留为缺省端口的派生值,兼容既有断言。backend 的 `baseUrl` 与关闭地址都用 + `health.BaseURL(port)` 拼出,不再各自持有字面量; +- **谁解析缺省**:CLI 在校验 `--mode` 之后立即解析 `--port`,缺省值随模式给出,交给 + `Request.Port`;`internal/backend` 再做一次同样的解析(`Request.Port` 为零按模式取缺省), + 让直接构造 `Request` 的调用方(测试、未来其他入口)也拿到确定的端口,并把最终值写进 + `uv.ManagedOptions.Port`; +- **受控注入**:`autoMASSupervisedPort` 加入 `canonicalSupervisionEnvironmentKey` 名单, + 与 T13.5 四个键同一条纪律;`ManagedOptions.Port` 为零时不注入(`internal/uv` 是通用启动器, + 不替调用方决定端口),backend 保证任何模式都传非零值,并由单测锁定; +- **单次重启复用**:端口只从 `Request` 读,`startControlAttempt` 的两代进程天然同值。 + +## 失败语义 + +`--port` 非整数、`< 1024`、`> 65535`(含 `0`、负数、小数、空串)一律 `INVALID_ARGUMENT` +(退出码 2、不可重试,`details.field = "port"`),在 `backendFactory` 之前拒绝; +`internal/uv` 对非零但越界的 `Port` 同样在 spawn 之前失败关闭。 + +## E2E 与夹具 + +- `testdata/fakebackend`:`listenAddress` 为空时读 `AUTO_MAS_SUPERVISED_PORT` 决定监听地址, + 缺失或非法时仍回退 `127.0.0.1:36163`。E2E 夹具**不再设置** `listenAddress`,因此健康检查能 + 通过就直接证明「后端确实是从环境变量读到端口的」; +- E2E 夹具用 `net.Listen("tcp4", "127.0.0.1:0")` 探出空闲端口后关闭,经 `Request.Port` 传入; + 端口相关辅助函数(`assertE2EPortClosed` 等)改为带端口参数;`RuntimeTermination` 用例的 + 跨进程 signal 增加 `port` 字段。本机 36163 被用户正式版占用,这正是 E2E 必须改的原因; +- 串行化 E2E 的命名 Mutex 改名为与端口无关的名字。 + +## Task 拆分 + +### Task 1:`internal/uv` 注入受控端口 + +- 文件:`internal/uv/runner.go`、`internal/uv/managed.go`、`internal/uv/managed_test.go` +- 红灯:`TestManaged_InjectsSupervisedPort`(键存在、值为十进制、宿主与选项同名变体被清除)、 + `TestManaged_RejectsInvalidSupervisedPort`(`1023` / `65536` / 负数在 spawn 前失败) +- 验证:`go test ./internal/uv -run '^TestManaged_' -count=1` + +### Task 2:`internal/health` 按端口派生地址 + +- 文件:`internal/health/checker.go`、`internal/health/health_test.go` +- 红灯:`TestHealth_RequestURLFollowsExpectationPort`(`Expectation.Port` 改变请求 URL, + 零值回退缺省)、`TestHealth_RejectsOutOfRangePort` +- 验证:`go test ./internal/health -run '^TestHealth_' -count=1` + +### Task 3:`internal/backend` 端口解析、派生与注入 + +- 文件:`internal/backend/types.go`、`supervisor.go`、`control.go`、`supervisor_test.go`、 + `control_test.go`、`development_test.go` +- 红灯:`TestBackend_PortDefaultsByModeAndDerivesAddresses`(managed 缺省 36163 / + development 缺省 36164 / 显式值;`baseUrl`、`health.Expectation.Port`、`ManagedOptions.Port` + 三者一致)、`TestBackend_RestartReusesSupervisedPort`、`TestBackend_CloseRequestTargetsSupervisedPort` + (用 `httptest` 在 `127.0.0.1:0` 上证明关闭请求打到派生端口)、`TestBackend_RejectsOutOfRangePort` +- 验证:`go test ./internal/backend -run 'Port' -count=1` + +### Task 4:CLI `--port` + +- 文件:`internal/cli/backend.go`、`internal/cli/backend_test.go` +- 红灯:`TestBackendSupervise_PortArgument`(表驱动:managed / development 缺省、`1024` / `65535`、 + 中间值;`1023` / `65536` / `0` / `-1` / `abc` / `1.5` / 空串拒绝且 factory 零调用) +- 验证:`go test ./internal/cli -run '^TestBackendSupervise_PortArgument$' -count=1 -v` + +### Task 5:假后端读端口与 E2E 空闲端口 + +- 文件:`testdata/fakebackend/main.go`、`main_test.go`、`internal/backend/e2e_windows_test.go` +- 红灯:`TestFakeBackend_ListensOnSupervisedPortEnv`;E2E 夹具去掉 `listenAddress` 后 + `TestBackendE2E_LifecycleSpawnReadyShutdown` 在假后端未读环境变量时超时 +- 验证:`go test ./testdata/fakebackend -count=1`、`go test ./internal/backend -run '^TestBackendE2E_' -count=1` + +## 验收对照 + +| 任务拆分验收项 | 覆盖方式 | +| --- | --- | +| `--port` 缺省 / 边界 / 越界 / 非整数 | Task 4 | +| 注入且受控 | Task 1 | +| 健康 URL 随端口变化 | Task 2 | +| `baseUrl` 与关闭地址派生、重启复用 | Task 3 | +| 假后端从环境变量读端口、E2E 不依赖 36163 | Task 5 | +| `grep 36163` 只剩缺省常量与文档 | 收尾时执行并记入证据 | diff --git "a/doc/current/M13/\350\256\276\350\256\241-T13.8-\345\256\277\344\270\273\346\226\255\345\274\200\345\215\263\345\205\263\351\227\255.md" "b/doc/current/M13/\350\256\276\350\256\241-T13.8-\345\256\277\344\270\273\346\226\255\345\274\200\345\215\263\345\205\263\351\227\255.md" new file mode 100644 index 0000000..e092a12 --- /dev/null +++ "b/doc/current/M13/\350\256\276\350\256\241-T13.8-\345\256\277\344\270\273\346\226\255\345\274\200\345\215\263\345\205\263\351\227\255.md" @@ -0,0 +1,117 @@ +# 设计与计划 T13.8 宿主断开即关闭 + +- 契约:[增补 1 C13](../../契约补充-v1-增补1.md#c13宿主断开即关闭) +- 任务:[任务拆分 T13.8](../../任务拆分.md) +- 成对:无(Electron 只需在监督期间保持 stdin 打开) +- 状态:设计 + 计划 + +## 目标 + +`backend supervise` 在 `hello` 发出之后,stdin 到达 EOF 或读取出错,视为**隐式 `shutdown`**: +走与 stdin `shutdown` 控制命令完全相同的优雅关闭路径与结局,退出码与 `result` 不变(只是没有 +`controlCommandId`)。写 stdout 已失败时仍能收口退出。其他命令的 stdin EOF 行为不变。 + +## 现状与根因 + +- `internal/protocol/control.go:196-198`:`ControlReader.Run` 读到 EOF 只 `return nil`, + 与被 `StopAccepting` 停止的返回值无法区分; +- `internal/cli/backend.go:262-267`:reader goroutine 结束后只把非取消错误记进 + `control.SetReaderError`,EOF 什么也不做;监督循环继续等 `ctx.Done()` 或后端退出; +- 于是宿主崩溃后 Runtime 与后端一直活着,占着端口与 `backend` Mutex。 + +## 设计 + +### 谁识别 EOF + +`protocol.ControlReader` 新增只读方法 `InputClosed() bool`:`Run` 因输入到达 EOF(含最后一行 +不带换行的情况)而返回时置位;因 `StopAccepting` 或 ctx 取消而返回时不置位。这是协议层 +唯一的改动,不改变 `Run` 的返回值,因此 workspace / bootstrap 等共用 reader 的命令零影响。 + +### 谁把 EOF 变成 shutdown + +`internal/cli/backend.go` 的 reader goroutine 在 `Run` 返回后: + +1. `readerContext` 已取消(操作已收口)→ 什么都不做(现状); +2. 返回错误且不是取消 → **也**按隐式 shutdown 处理,并把错误写 stderr 诊断(C13:读取出错等价于 + 宿主断开;INTERNAL_ERROR 的硬失败路径会让后端被 Job 硬杀,而隐式 shutdown 走优雅关闭, + 对用户只会更好); +3. 返回 nil 且 `reader.InputClosed()` → 隐式 shutdown。 + +隐式 shutdown 就是向同一个 `ControlMailbox` 提交一条 +`ControlCommand{Protocol: 1, Command: shutdown, CommandID: ""}`: +`Submit` 会像显式 shutdown 一样置 first-wins latch 并停止接受后续命令;`ErrControlStopped` / +`ErrControlMailboxClosed` / ctx 错误说明已经有终止命令或操作已结束,直接忽略——幂等由 mailbox +既有语义给出,不需要新状态。监督循环对空 `CommandID` 的处理已经存在: +`finishControlShutdown` 只在 `commandID != ""` 时写 `controlCommandId`。 + +`hello` 在 reader 启动之前发出,「hello 之后」的前提天然成立。 + +### stdout 已断 + +写 stdout 失败时 `ProcessOutput` 返回 `ErrOutputWriteFailed`,监督循环按既有 +`OUTPUT_WRITE_FAILED` 路径 `cleanupProcess`(关 Job)并收口 Mutex 与事务,CLI 的 +`emitFailure` 写不出事件时只写 stderr 诊断——这条链路今天已经不 panic、不阻塞(Windows 上对已 +关闭管道的写立即返回错误),本任务只用 E2E 把它锁住。已知限制:stdout 已断时后端会被 Job 硬杀 +而不是 HTTP 优雅关闭,因为 `stopping_backend` 事件写不出去就走失败路径;改成「写失败也继续 +HTTP close」属于监督循环的错误语义变更,不在本任务范围,登记为后续项。 + +### 一次性命令 + +它们的 `runSession` / workspace 控制路径不读 `InputClosed()`,行为一个字节都不变; +既有 `TestControlReader_FramingAndAcceptedCancel` 的「empty EOF」用例与 workspace 控制测试继续锁定。 + +## E2E(真实 exe 黑盒) + +现有 `internal/backend` E2E 直接调用 `ManagedSupervisor` 并自己喂 mailbox,绕过了 CLI 的 +reader goroutine——而修复点正是那里。因此本任务的 E2E 用 `go build` 出真实的 +`auto-mas-runtime.exe`(与 fakeuv / fakebackend 同一种夹具构建方式),以子进程方式驱动: + +- `TestBackendE2E_StdinEOFShutsDownGracefully`:`--output ndjson backend supervise --mode development + --repo --port <空闲端口>`,从 stdout 读 NDJSON 到 `running`,关闭 stdin 写端;断言退出码 0、 + 事件序列含 `stopping_backend` → `stopped`、`result.status = "stopped"` 且无 `controlCommandId`、 + 无 `BACKEND_FORCE_TERMINATED`(假后端收到 close 自行退出)、后端 PID 已退出、端口关闭、 + `backend` Mutex 可重取、事务文件不存在; +- `TestBackendE2E_StdinEOFWithBrokenStdoutStillExits`:同上,但 stdout 是测试自建的 `os.Pipe`, + 看到 `running` 后先关读端再关 stdin;断言 Runtime 在有限时间内退出(退出码不作要求)、 + 后端 PID 已退出、端口 / Mutex / 事务无残留。 + +## Task 拆分 + +### Task 1:`ControlReader.InputClosed` + +- 文件:`internal/protocol/control.go`、`internal/protocol/control_test.go` +- 红灯:`TestControlReader_InputClosedDistinguishesEOFFromStop`(EOF → true;`StopAccepting` + 后返回 → false;ctx 取消 → false;最后一行无换行的 EOF → true) +- 验证:`go test ./internal/protocol -run '^TestControlReader_InputClosed' -count=1` + +### Task 2:CLI 把 EOF 变成隐式 shutdown + +- 文件:`internal/cli/backend.go`、`internal/cli/backend_test.go` +- 红灯:`TestBackendSupervise_StdinEOFSubmitsImplicitShutdown`(假 service 从 mailbox 收到 + `shutdown` 且 `CommandID == ""`,`result.status = stopped`、无 `controlCommandId`、退出码 0)、 + `TestBackendSupervise_StdinReadErrorSubmitsImplicitShutdown`(读错误同样得到 shutdown, + stderr 有诊断)、`TestBackendSupervise_ExplicitShutdownThenEOFIsIdempotent`(service 只收到一条 + 终止命令,且 `controlCommandId` 是显式那条的) +- 既有 `TestBackend_ControlReaderJoinAndReadFailure/reader failure joins within bound` 的期望 + 从 INTERNAL_ERROR 改为隐式 shutdown(契约变更,随本任务同步) +- 验证:`go test ./internal/cli -run 'StdinEOF|StdinReadError|ThenEOF|ControlReaderJoin' -count=1` + +### Task 3:真实 exe E2E + +- 文件:`internal/backend/e2e_stdin_windows_test.go`(复用既有夹具) +- 红灯:在 Task 2 之前跑 `TestBackendE2E_StdinEOFShutsDownGracefully` 应超时(Runtime 不退出) +- 验证:`go test ./internal/backend -run '^TestBackendE2E_StdinEOF' -count=1` + +### 收尾 + +标准验证门 + `go test ./internal/protocol -count=100` + `go test -race ./... -count=1`,回写任务拆分。 + +## 验收对照 + +| 任务拆分验收项 | 覆盖方式 | +| --- | --- | +| EOF 后优雅关闭、事件与 result、退出码 0、无残留 | Task 3 第一条 | +| stdout 已断也能退出 | Task 3 第二条 | +| 已接受 shutdown 后再 EOF 不二次关闭 | Task 2 幂等用例 | +| 一次性命令 EOF 行为不变 | 既有 protocol / workspace 测试无回退 | +| race | 收尾 | diff --git a/doc/current/README.md b/doc/current/README.md index 925a5cd..6898bcd 100644 --- a/doc/current/README.md +++ b/doc/current/README.md @@ -18,7 +18,10 @@ M7 GitHub CI/CD 发布均已完成;设计和审查记录分别归档到 - `M13/` 三份设计([T13.1 后端工作目录](./M13/设计-T13.1-后端工作目录与绝对入口路径.md)、 [T13.2 Job 允许显式脱离](./M13/设计-T13.2-Job-Object-允许显式脱离.md)、 [T13.3 关闭超时参数化](./M13/设计-T13.3-关闭超时参数化.md)):设计与计划合并为一份, - 三项均已完成,待 T13.4~T13.6 收口后一并归档。 + 三项均已完成,待 T13.4~T13.6 收口后一并归档; +- `M13/` 另有 [T13.7 受监督端口由 Runtime 注入](./M13/设计-T13.7-受监督端口由-Runtime-注入.md) + 与 [T13.8 宿主断开即关闭](./M13/设计-T13.8-宿主断开即关闭.md)(2026-09-02 真机联调后立项, + 设计与计划合并为一份)。 任务完成后的处理规则: diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index 9ef92ca..c712774 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -27,7 +27,7 @@ ### 0.3 执行顺序 - 按各任务标注的「依赖」执行;无依赖关系的任务可并行; -- 里程碑整体顺序建议 M0 → M1 → M2 → M3 →(M4 ∥ M5)→ M6 → M7 → M9(M8 编号已随决策 D6 移除,保留空缺);M10 为跨阶段维护;M11 为规划中的跨平台扩展,M12 按 D11 收敛为 Sentry-only 并依 T12.1、T12.2、T12.4~T12.7 实施;M13 为 D12 与契约补充增补 1 的配套实施,T13.1/T13.2/T13.3 可并行,T13.4 → T13.5 → T13.6 有先后,整体建议排在 M9 联调之前(2026-09-02 状态:T13.1~T13.5 已完成,其中 T13.5 的「池目录重新分类」半段按设计关闭;T13.6 仍 ⏸;T9.1 development 联调已于同日在 T13.1~T13.5 之上完成); +- 里程碑整体顺序建议 M0 → M1 → M2 → M3 →(M4 ∥ M5)→ M6 → M7 → M9(M8 编号已随决策 D6 移除,保留空缺);M10 为跨阶段维护;M11 为规划中的跨平台扩展,M12 按 D11 收敛为 Sentry-only 并依 T12.1、T12.2、T12.4~T12.7 实施;M13 为 D12 与契约补充增补 1 的配套实施,T13.1/T13.2/T13.3 可并行,T13.4 → T13.5 → T13.6 有先后,整体建议排在 M9 联调之前(2026-09-02 状态:T13.1~T13.5 已完成,其中 T13.5 的「池目录重新分类」半段按设计关闭;T13.6 仍 ⏸;T9.1 development 联调已于同日在 T13.1~T13.5 之上完成;同日按联调暴露的问题立项 T13.7(受监督端口注入)与 T13.8(宿主断开即关闭),两者互不依赖、均在 M9 之前); - AUTO-MAS 侧 TODO 由那边的仓库执行,本仓库任务不阻塞在其上,除非「依赖」中显式标注。 ### 0.4 规模标记 @@ -1015,6 +1015,14 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 - 依赖:T13.4;AUTO-MAS 发布 CI 侧按发布版本预生成缓存 - 内容:让 `cleanup` 区分「随包预置」与「运行期产生」的 uv 缓存,或提供重新播种入口——现在 `internal/cleanup/cleanup.go:280` 无条件删除 `runtime/cache/uv` 且没有补种路径,清理一次就得重新联网。这是纯加速项:受限网络地区的首装可达性已由 T13.4 解决,Lite 与 Full 在这件事上没有区别。 - 验收:删除预置缓存后仍能通过 T13.4 的轮换正常装上;预置缓存存在时不被无条件删除;不改变 T13.4 的任何路径与错误映射。 +- [ ] **T13.7 受监督端口由 Runtime 注入**(M)🚧 2026-09-02 立项 + - 依赖:M6;契约 [增补 1 C12](./契约补充-v1-增补1.md#c12受监督端口由-runtime-注入)(同日修订 C1 与 C7 结论第 1 条);成对 TODO-PY-8(后端改读 `AUTO_MAS_SUPERVISED_PORT`,缺失回退 36163) + - 内容:`backend supervise` 新增可选参数 `--port `(整数,`1024`~`65535`),缺省按模式 managed `36163` / development `36164`;非整数或越界映射 `INVALID_ARGUMENT`(`details.field = "port"`)并在 `backendFactory` 之前拒绝。Runtime 启动后端时注入 `AUTO_MAS_SUPERVISED_PORT=`,该键并入受控监督环境键集合(宿主同名变量被清除、`RunOptions.Environment` 覆盖不了、大小写变体规范化——照 T13.5 四个键的做法)。`internal/health/checker.go` 的 `HealthURL`、`internal/backend/control.go` 的 `backendCloseURL` 与 `baseUrl`、`internal/backend/supervisor.go` 的 `baseUrl` 全部改为由该端口派生;协议**不新增字段**。`testdata/fakebackend` 改为读 `AUTO_MAS_SUPERVISED_PORT` 决定监听地址(缺省仍 36163);E2E 改用 `net.Listen(":0")` 探出的空闲端口,不再依赖 36163 空闲(本机 36163 被用户正式版占用)。 + - 验收:表驱动覆盖 `--port` 缺省(managed 36163 / development 36164)、边界 `1024` / `65535`、越界(`1023` / `65536` / `0` / 负数)与非整数(拒绝时 factory 零调用、`details.field = "port"`);`internal/uv` 单测证明 `AUTO_MAS_SUPERVISED_PORT` 注入且受控(宿主与选项同名变体被清除);`internal/health` 单测证明请求 URL 随端口变化;`internal/backend` 单测证明 managed 与 development 的 `baseUrl` 与关闭地址随端口派生且单次重启复用同一端口;E2E 在空闲端口上跑通并由假后端证明它是从环境变量读到端口的;`grep 36163` 只剩缺省常量与文档。 +- [ ] **T13.8 宿主断开即关闭**(M)🚧 2026-09-02 立项 + - 依赖:M6;契约 [增补 1 C13](./契约补充-v1-增补1.md#c13宿主断开即关闭);AUTO-MAS 侧无改动 + - 内容:仅 `backend supervise`:`hello` 发出之后 stdin 到达 EOF 或读取出错视为**隐式 `shutdown`**,走与 stdin `shutdown` 控制命令完全相同的优雅关闭路径与结局(`stopping_backend` → `POST /api/core/close` → 关闭预算 → Job 兜底 → `stopped`;`result.status = "stopped"`、退出码 0;无 `controlCommandId`)。现状 `internal/protocol/control.go:196-198` 读到 EOF 只 `return nil`、`internal/cli/backend.go:262-267` 只记日志,Electron 崩溃后 Runtime 与后端继续占着端口和 `backend` Mutex,下次启动得到 `BACKEND_ALREADY_RUNNING`。写 stdout 若已失败(宿主管道断了)要能容错——不 panic、不阻塞,仍完成进程树回收与 Mutex / 事务收口后退出。其他命令(bootstrap / dependencies / repair / workspace 等)的 stdin EOF 行为**不变**。 + - 验收:E2E(真实 exe 黑盒)——启动 `backend supervise` 到 `running`,关闭 stdin,断言后端被优雅关闭(假后端收到 close 自行退出、无 `BACKEND_FORCE_TERMINATED`)、事件序列 `stopping_backend` → `stopped`、`result.status = "stopped"` 且无 `controlCommandId`、退出码 0、端口 / Mutex / 事务无残留;补一条「stdout 已断也能退出」(stdout 接到已关闭的管道后再关 stdin,Runtime 有限时间内退出、进程树与 Mutex 无残留);已接受 shutdown 后再 EOF 不产生第二次关闭;一次性命令的 EOF 行为有既有测试锁定无回退;涉及并发,`go test -race` 通过。 --- @@ -1086,7 +1094,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 - 注意:`doc/架构设计.md`「插件环境职责边界」描述的是**能力边界**(Runtime 不读插件声明、不解析插件依赖、不维护插件安装清单),该边界与本条是否适用无关,不随之作废;它同时是 TODO-PY-13 里「Runtime 只提供基础设施」的依据。 - [ ] **TODO-PY-8 受监督标记的优先级** - 位置:`app/api/core.py:75-84,116-135`、`main.py:43,114,142`、`.env.example` - - 内容:`AUTO_MAS_SUPERVISED=1` 的优先级高于 `AUTO_MAS_DEV` 与 `AUTO_MAS_ENV=development`:close 必须真实设置 `Config.server.should_exit`;端口固定回到 36163,忽略 `AUTO_MAS_HTTP_PORT` 与任何开发环境判据(见 [增补 1 C7](./契约补充-v1-增补1.md#c7受监督标记对端口与开发环境标记的优先级))。只有「未受监督且显式开了开发标记」的裸进程保留现有的忽略行为。 + - 内容:`AUTO_MAS_SUPERVISED=1` 的优先级高于 `AUTO_MAS_DEV` 与 `AUTO_MAS_ENV=development`:close 必须真实设置 `Config.server.should_exit`;~~端口固定回到 36163~~ **(2026-09-02 按 [增补 1 C12](./契约补充-v1-增补1.md#c12受监督端口由-runtime-注入) 修订)端口只认 Runtime 注入的 `AUTO_MAS_SUPERVISED_PORT`,缺失时回退 36163**,忽略 `AUTO_MAS_HTTP_PORT` 与任何开发环境判据(见 [增补 1 C7](./契约补充-v1-增补1.md#c7受监督标记对端口与开发环境标记的优先级))。只有「未受监督且显式开了开发标记」的裸进程保留现有的忽略行为。 - 背景:dev 已合入的 PR #451 让开发环境走 36164,判据是仓库根的 `.env` 或 `AUTO_MAS_ENV`,与契约 C1 直接冲突;同一个标记还让 `/api/core/close` 只做轻量清理、不设 `should_exit`。 - **这条要连着 `.env.example` 的说明一起改**,否则开发者照着文档建了 `.env`,第一次用 development 模式联调就会莫名其妙失败。 @@ -1192,7 +1200,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 | 编号 | 事项 | 当前状态 | 落点 | | --- | --- | --- | --- | | ~~D-open-1~~ | ~~版本索引托管位置~~ | 已随决策 D6 移除 | — | -| ~~D-open-2~~ | ~~后端端口契约(固定 36163 或 Runtime 注入)~~ | 已定稿:v1 固定 `36163`,见 `doc/契约补充-v1.md` C1 | T1.0 / TODO-PY-1 | +| ~~D-open-2~~ | ~~后端端口契约(固定 36163 或 Runtime 注入)~~ | 已定稿:v1 固定 `36163`,见 `doc/契约补充-v1.md` C1;**2026-09-02 由增补 1 C12 改为 Runtime 注入**(`--port`,缺省 managed 36163 / development 36164) | T1.0 / TODO-PY-1;T13.7 / TODO-PY-8 | | ~~D-open-3~~ | ~~`backgroundStatus` 失败字面量(`failed` vs `error`)~~ | 已定稿:固定 `failed`,见 `doc/契约补充-v1.md` C3 | T1.0 / TODO-PY-1 | | D-open-4 | `/api/update/check`(MirrorChyan 查询)是否保留给前端 | 产品决策 | TODO-PY-5 | | D-open-5 | GitHub 组织/仓库名(默认 `AUTO-MAS-Project/AUTO-MAS-Runtime`) | 待用户确认 | T0.3 | @@ -1213,6 +1221,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 | 日期 | 变更 | 操作者 | | --- | --- | --- | +| 2026-09-02 | 真机联调暴露两个首版前必须修掉的问题,按红线第 2 条先改文档:新增 [增补 1 **C12**「受监督端口由 Runtime 注入」](./契约补充-v1-增补1.md#c12受监督端口由-runtime-注入)(`backend supervise --port `,`1024`~`65535`,缺省 managed 36163 / development 36164;注入 `AUTO_MAS_SUPERVISED_PORT` 并并入受控监督键集合;健康 / 关闭地址与 `baseUrl` 由它派生;后端受监督时只认该变量、缺失回退 36163;协议不新增字段)与 [**C13**「宿主断开即关闭」](./契约补充-v1-增补1.md#c13宿主断开即关闭)(仅 `backend supervise`:`hello` 之后 stdin EOF 或读取出错视为隐式 `shutdown`,同一条优雅关闭路径与结局;stdout 已断也须能退出;其他命令不变);同步修订 C7 结论第 1 条、`契约补充-v1.md` C1、`架构设计.md` 五处(后端启动流程、后端关闭流程、CLI 设计、标准输入控制、后端健康检查与关闭契约)与 TODO-PY-8、D-open-2;M13 下立项 **T13.7**、**T13.8**,设计与计划见 `doc/current/M13/设计-T13.7-*.md`、`设计-T13.8-*.md`。背景:C7 把受监督端口钉死 36163,与 AUTO-MAS dev 已合入的 PR #451 开发版(36164)/ 正式版(36163)并存约定冲突,`backend supervise --mode development` 必撞正在运行的正式版,Runtime 自己的 E2E 在该机器上也必然失败;stdin EOF 只 `return nil` 让宿主崩溃后 Runtime 与后端继续占着端口与 `backend` Mutex,下次启动得到不可重试的 `BACKEND_ALREADY_RUNNING` | Claude | | 2026-09-02 | **T13.5 收口为 ✅**:「池目录重新分类」半段**按设计关闭**,不再实施,对应验收项撤销。注入面落地后,池真正可重建的 uv 缓存与受管解释器已物理落在 `/runtime/cache/uv` 与 `/runtime/environment/python`(真机 `manifest.json` 的 `installerMetadata.cache.path` / `installer.executable` 证实,`config/maafw_runtime_pool/` 下已无 `cache/`),本就在 `cleanup`/`repair` 的既有分类内;`config/maafw_runtime_pool/runtimes//` 只剩 venv 与 manifest,让 Runtime 识别该布局并删 venv 留 manifest 等于维护池清单,触红线第 4、6 条。分工改为 Runtime 只处理自己的目录,池 venv 因基解释器缺失失效后由后端自行判定重建。同步修订增补 1 C11 结论第 4 条、`架构设计.md` 三处(文档状态修订项、插件环境职责边界增补段、dev 基线目录分类表)、5.1 TODO-PY-13 的对应要求与 AGENTS.md 状态表;T13.6 保持 ⏸ | Claude | | 2026-09-02 | 完成 **T9.1** development 真后端联调:`integ/t13-20260901@ba27db3` 构建的 Runtime 对 AUTO-MAS 集成树 `integ/runtime-20260901` 的真后端 `backend supervise --mode development` 完整跑通「启动 → 就绪 → 优雅关闭」:ready 3.21 秒,health `protocol: 1 / version: v5.5.0-beta.3 / commit: ""`,stdin `shutdown` 被接受、后端退出后 Runtime 收口 0.35 秒、exit 0,`result.details` 无 warning,`hello.capabilities` 含 `stdin.shutdown` 与 `stdin.status`。过程中修掉优雅关闭误报 `BACKEND_FORCE_TERMINATED`(`e5ef0ab`,见 T13.3);契约偏差已由增补 1(C6~C11)与 9 月 1/2 日的协议偏差回写收口,不再新增 TODO。T9.2~T9.4 不动 | Claude | | 2026-09-02 | **T13.5 注入面完成**(`020b3b9`,任务整体仍为 🚧):`backend supervise` 在 managed 与 development 两种模式下注入 `AUTO_MAS_UV_CACHE_DIR`、`AUTO_MAS_UV_PYTHON_INSTALL_DIR`、`AUTO_MAS_MIRROR_PACKAGE_INDEX`、`AUTO_MAS_MIRROR_PYTHON`(增补 1 C11)。plan 由 `internal/backend` 在构造期解析一次(唯一同时持有 layout 与 `mirror.Policy` 的位置),经 `uv.ManagedOptions.Infrastructure` 交给 `StartManaged`;四个键并入受控监督集合,宿主同名变量被清除且不可被 `RunOptions.Environment` 覆盖。不新增 mirror API、不新增协议字段/stage/state/错误码,`UV_*` 注入面与 T12.7 保留键清理行为未变。池目录重新分类未做,理由见 T13.5 条目 | Claude | diff --git "a/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" "b/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" index 06efeb7..526cc4f 100644 --- "a/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" +++ "b/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" @@ -10,10 +10,13 @@ - 实现基线:Runtime `40ef464`;AUTO-MAS `dev@3c422093` 与 MaaFW 内置层 `work/maafw-embedded-20260830@1561bc55`(决策 D12) - 修订:2026-09-01 修订 C10 对显式 `--mirror package-index=` 的处理,并把镜像改写前缀由推导改为显式声明(T13.4 实施期间,见 C10) - 修订:2026-09-02 修订 C11 结论第 4 条:「池目录的重新分类」按设计关闭,改为「Runtime 的 `repair` / `cleanup` 只处理自己的目录;池 venv 因基解释器缺失失效后由后端自行判定重建」(T13.5 收口时,见 C11) +- 修订:2026-09-02 新增 **C12「受监督端口由 Runtime 注入」** 与 **C13「宿主断开即关闭」**,并修订 C7 结论第 1 条与 C1(T13.7 / T13.8,真机联调暴露;见 C12、C13) -本文档是对 [契约补充-v1](./契约补充-v1.md) 的**增量修订**,定稿六项此前未冻结或与实现矛盾的对外契约,并修订 C2、C4 各一条。 +本文档是对 [契约补充-v1](./契约补充-v1.md) 的**增量修订**,定稿六项此前未冻结或与实现矛盾的对外契约,并修订 C2、C4 各一条; +2026-09-02 追加 C12、C13 两项。 `契约补充-v1.md` 开头已规定「对外字段、字面量或环境变量发生变化时必须升级或增量修订共同契约和测试」,本文档走的正是增量修订这条路: -六项都不新增、删除或改名协议字段,也不改变任何 `stage` / `state` / 错误码字面量,因此协议版本保持 `1`。 +八项都不新增、删除或改名协议字段,也不改变任何 `stage` / `state` / 错误码字面量,因此协议版本保持 `1` +(C12 新增一个注入环境变量与一个 CLI 参数,属于允许追加的集合;`baseUrl` 字段本身不变,只是取值不再恒定)。 优先级:**本文档 > `契约补充-v1.md` > `架构设计.md` > `任务拆分.md`**。本文档与 `契约补充-v1.md` 冲突时以本文档为准;未被本文档触及的条目一律沿用原文。 @@ -24,11 +27,13 @@ | 编号 | 事项 | v1 结论 | | --- | --- | --- | | C6 | 后端进程工作目录 | managed 下子进程 cwd = app-root,入口传绝对路径 `/repo/main.py`;development 不变 | -| C7 | 受监督标记的优先级 | 扩展到端口:受监督时固定 36163,忽略一切开发环境标记;并修订 C2 第 1 条与 C4 第 1 条 | +| C7 | 受监督标记的优先级 | 扩展到端口:受监督时端口由 Runtime 注入(原「固定 36163」已由 C12 修订),忽略一切开发环境标记;并修订 C2 第 1 条与 C4 第 1 条 | | C8 | Job Object 逃逸 | 游戏与模拟器**不随后端退出**;Runtime 开 `BREAKAWAY_OK`,AUTO-MAS 只在拉起游戏/模拟器时带 `CREATE_BREAKAWAY_FROM_JOB` | | C9 | 关闭预算 | `backend supervise` 新增关闭超时选项,默认值保持 5 秒 | | C10 | 主项目依赖的镜像 | 锁文件在 PyPI 上生成;`dependencies sync` 改写锁内下载地址前缀参与镜像轮换,**不覆盖包索引**;显式首选源只改变尝试顺序(2026-09-01 修订) | | C11 | MaaFW 运行池的归属 | 只统一基础设施:Runtime 开放共享 uv 缓存与受管 Python 目录、下发有序镜像源;「装什么」仍留在后端 | +| C12 | 受监督端口 | `backend supervise --port `(1024~65535),缺省 managed 36163 / development 36164;Runtime 注入 `AUTO_MAS_SUPERVISED_PORT`,健康/关闭地址与 `baseUrl` 由它派生;后端受监督时只认该变量、缺失回退 36163(2026-09-02 新增) | +| C13 | 宿主断开即关闭 | 仅 `backend supervise`:`hello` 之后 stdin EOF 或读取出错视为隐式 `shutdown`,走同一条优雅关闭路径;stdout 已断也必须能退出;其他命令不变(2026-09-02 新增) | --- @@ -68,15 +73,21 @@ Runtime `T13.1`(`internal/uv/managed.go`、`internal/backend/supervisor.go`、 ## C7:受监督标记对端口与开发环境标记的优先级 +> **2026-09-02 修订(T13.7,见 [C12](#c12受监督端口由-runtime-注入)):** 结论第 1 条中「固定监听 `36163`」改为 +> 「端口由 Runtime 决定并经 `AUTO_MAS_SUPERVISED_PORT` 注入;managed 缺省 `36163`、development 缺省 `36164`, +> 缺失时回退 `36163`」。「忽略 `AUTO_MAS_HTTP_PORT` 与一切开发环境判据」的部分不变。下文「Runtime 侧无需改代码」 +> 的表述随之作废——Runtime 侧由 T13.7 实现注入与地址派生。 + ### 结论 `AUTO_MAS_SUPERVISED=1` 的优先级高于**一切**开发环境标记,不限于关闭行为: -1. **端口**:受监督时后端固定监听 `36163`,忽略 `AUTO_MAS_HTTP_PORT` 以及任何开发环境判据(仓库根的 `.env`、`AUTO_MAS_ENV=development`)。C1 的固定端口在受监督路径上无例外。 +1. **端口**:~~受监督时后端固定监听 `36163`~~ **(2026-09-02 修订)受监督时后端只认 Runtime 注入的 `AUTO_MAS_SUPERVISED_PORT`,缺失时回退 `36163`**;忽略 `AUTO_MAS_HTTP_PORT` 以及任何开发环境判据(仓库根的 `.env`、`AUTO_MAS_ENV=development`)。端口的来源与缺省值见 C12。 2. **关闭**:沿用 C4 第 2 条,受监督 development 收到 `POST /api/core/close` 必须真实设置 `Config.server.should_exit`。 3. 只有「未受监督且显式启用开发标记」的裸进程保留现有的端口与忽略关闭行为。 -Runtime 侧无需改代码——它本就只连 `127.0.0.1:36163`、只注入 `AUTO_MAS_SUPERVISED=1`,本条是把既有行为写成契约。AUTO-MAS 侧按 `TODO-PY-8` 实现。 +~~Runtime 侧无需改代码——它本就只连 `127.0.0.1:36163`、只注入 `AUTO_MAS_SUPERVISED=1`,本条是把既有行为写成契约。~~ +(2026-09-02:Runtime 侧按 C12 / T13.7 注入端口并派生地址。)AUTO-MAS 侧按 `TODO-PY-8` 实现。 ### 依据 @@ -107,7 +118,7 @@ C2 第 3 条的 Runtime 侧比较逻辑**不变**(三项仍精确比较,任 ### 落点 -Runtime:无代码改动,仅契约登记与测试断言口径(`T13.1` 的 E2E 顺带覆盖);AUTO-MAS `TODO-PY-1`、`TODO-PY-2`、`TODO-PY-8`。 +Runtime:原定无代码改动,仅契约登记与测试断言口径(`T13.1` 的 E2E 顺带覆盖);**2026-09-02 起端口部分由 `T13.7` 实现(见 C12)**;AUTO-MAS `TODO-PY-1`、`TODO-PY-2`、`TODO-PY-8`。 --- @@ -299,9 +310,72 @@ Runtime `T13.5`;AUTO-MAS `TODO-PY-13`。`架构设计.md`「插件环境职责 --- +## C12:受监督端口由 Runtime 注入 + +> 2026-09-02 定稿(T13.7)。修订 C1 与 C7 结论第 1 条中「固定 36163」的部分;协议字段不变。 + +### 结论 + +1. `backend supervise` 新增可选参数 `--port `,取值为 `1024`~`65535` 的整数;缺省按模式:`managed` → `36163`,`development` → `36164`。非整数或越界按参数错误映射 `INVALID_ARGUMENT`(退出码 2、不可重试,`details.field = "port"`),在建立任何后端资源之前拒绝。 +2. Runtime 启动后端时注入 `AUTO_MAS_SUPERVISED_PORT=`(十进制、无前导零)。该键并入受控监督环境键集合:宿主同名变量被清除、`RunOptions.Environment` 覆盖不了、大小写变体被规范化——与 C2 的五个键、C11 的四个键同一条纪律。 +3. 健康检查地址 `http://127.0.0.1:/api/core/health`、关闭地址 `http://127.0.0.1:/api/core/close` 与 `backend.run` / `running` 事件里的 `details.baseUrl = http://127.0.0.1:` 全部由同一个端口派生;仍用 `127.0.0.1`,`baseUrl` 仍无结尾斜杠。**协议不新增字段**:`baseUrl` 一直是调用方必须消费的值,只是它不再恒等于 36163。 +4. 后端侧(AUTO-MAS):受监督时只认 `AUTO_MAS_SUPERVISED_PORT`,缺失时回退 `36163`;仍忽略 `.env`、`AUTO_MAS_HTTP_PORT` 与任何开发环境判据。未受监督的裸进程行为不变。 +5. 同一个 `backend supervise` 进程生命周期内端口不变,单次自动重启复用同一端口。 + +### 依据 + +- AUTO-MAS `dev` 已通过 PR #451 让开发版(后端 36164、独立 userData)与正式版(36163)同机并存,用户日常正式版一直在跑;C7 把受监督端口钉死 36163,`backend supervise --mode development` 必然撞上正在运行的正式版,破坏 dev 已有的并存约定; +- Runtime 自己的 E2E 也钉死 36163(`internal/backend/e2e_windows_test.go` 的端口 Mutex、`assertE2EPortBindable`、`triggerE2EHealthRequest`;`testdata/fakebackend` 默认监听同一端口),在正式版运行的机器上必然失败; +- 2026-09-02 真机联调暴露;Runtime 侧硬编码位置:`internal/health/checker.go` 的 `HealthURL`、`internal/backend/control.go` 的 `backendCloseURL` 与 `baseUrl`、`internal/backend/supervisor.go:380` 的 `baseUrl`。 + +### 为什么是参数 + 注入,而不是只改缺省 + +只把缺省改成 36164 仍是一个写死的常量,两个开发版仍会互撞;参数化才让并存关系由调用方(Electron / 用户)决定。Runtime **不做**端口探测或自动选择:端口是调用方与后端之间的地址约定,自动换端口会掩盖真正的冲突(另一份实例已在跑),与 `BACKEND_ALREADY_RUNNING` 的失败关闭语义相悖;端口被占用时由健康检查按既有口径超时失败。 + +### 与 C1、C7 的关系 + +- C1「不提供端口参数或端口环境变量」自本条起**作废**;`127.0.0.1`、无尾斜杠与地址表形态不变,只是端口从常量变为变量; +- C7 结论第 1 条改为「受监督时端口由 Runtime 决定并注入;managed 缺省 36163、development 缺省 36164」,「忽略 `AUTO_MAS_HTTP_PORT` 与开发环境判据」的部分不变; +- 「Runtime 侧无需改代码」(C7 原文)随之作废。 + +### 落点 + +Runtime `T13.7`(`internal/cli/backend.go`、`internal/backend`、`internal/health`、`internal/uv`、`testdata/fakebackend` 与 Windows E2E);AUTO-MAS `TODO-PY-8`(改读 `AUTO_MAS_SUPERVISED_PORT`,缺失回退 36163)。 + +--- + +## C13:宿主断开即关闭 + +> 2026-09-02 定稿(T13.8)。只约束 `backend supervise`;协议字段、错误码、退出码不变。 + +### 结论 + +1. `backend supervise` 在 `hello` 发出之后,stdin 到达 EOF 或读取出错,视为**隐式 `shutdown`**:走与 stdin `shutdown` 控制命令完全相同的优雅关闭路径与结局——`stopping_backend` → `POST /api/core/close` → 等待关闭预算(C9)→ 超时才收 Job → `stopped`;`result.status = "stopped"`、退出码 `0`。与显式 shutdown 的唯一差别是没有 `commandId`,因此 `details.controlCommandId` 不出现。 +2. 隐式与显式 shutdown 幂等:已接受过 `shutdown` / `cancel` 之后再遇 EOF 不产生第二次关闭;EOF 之后不再有任何输入可读。 +3. EOF 发生在后端就绪之前(预检、启动、健康检查期间)同样触发隐式 shutdown,语义与在这些阶段收到显式 shutdown 完全一致。 +4. **stdout 容错**:宿主崩溃时 stdout 管道通常与 stdin 同时失效。Runtime 对写 stdout 失败必须容错——不 panic、不阻塞,仍完成进程树回收、Mutex 与事务收口后退出。此时事件与 `result` 可能写不出去,退出码按既有 `OUTPUT_WRITE_FAILED` 分类;调用方本就已经不在,无人消费。 +5. **其他命令不变**:`bootstrap`、`dependencies`、`repair`、`workspace` 等一次性命令的 stdin EOF 行为一个字节都不动——它们可能在没有 stdin 的环境下运行(`< NUL`、CI、计划任务)。 +6. 调用方义务:宿主在整个监督期间必须保持 stdin 打开;把 `backend supervise` 的 stdin 接到 `NUL` 或已关闭的管道等于立刻请求关闭。 + +### 依据 + +- `internal/protocol/control.go:196-198` 读到 EOF 只 `return nil`;`internal/cli/backend.go:262-267` 只记日志。Electron 被杀或崩溃后,Runtime 与后端继续占着端口和 `backend` Mutex,下次启动得到 `BACKEND_ALREADY_RUNNING`(`retryable=false`),用户只能手杀 `auto-mas-runtime.exe`; +- 架构设计「后端关闭流程」的前提是 Electron 会发 `shutdown`,而崩溃的宿主发不出任何命令;Job Object 只把**后端**的生命周期绑到 Runtime,没有把 **Runtime** 的生命周期绑到宿主,本条补上最后一环; +- 2026-09-02 真机联调暴露。 + +### 为什么用 stdin EOF,而不是 Job Object 或父进程句柄 + +把 Runtime 放进 Electron 的 Job 需要改 Electron 侧,且 Electron 自身不一定在 Job 内;轮询父 PID 有 PID 复用风险。stdin 是宿主本来就独占持有、随宿主消亡而必然 EOF 的句柄,而且它已经是唯一的控制通道,不需要任何新机制。 + +### 落点 + +Runtime `T13.8`(`internal/protocol/control.go`、`internal/cli/backend.go` 与 Windows E2E);AUTO-MAS 侧无改动(Electron 只需按既有约定在监督期间保持 stdin 打开)。 + +--- + ## 新增注入环境变量 -以下四个变量由 Runtime 在启动后端时注入,与 C2 的五个变量同属受监督进程的环境契约。**变量名与取值格式属于已冻结的对外契约**,改动须先改文档(红线第 2 条)。 +以下变量由 Runtime 在启动后端时注入,与 C2 的五个变量同属受监督进程的环境契约。**变量名与取值格式属于已冻结的对外契约**,改动须先改文档(红线第 2 条)。 | 环境变量 | managed 模式 | development 模式 | 取值 | | --- | --- | --- | --- | @@ -309,6 +383,7 @@ Runtime `T13.5`;AUTO-MAS `TODO-PY-13`。`架构设计.md`「插件环境职责 | `AUTO_MAS_UV_PYTHON_INSTALL_DIR` | 必填 | 必填 | 受管 Python 安装目录的规范化绝对路径 | | `AUTO_MAS_MIRROR_PACKAGE_INDEX` | 必填 | 必填 | Python 包索引的有序源列表 | | `AUTO_MAS_MIRROR_PYTHON` | 必填 | 必填 | Python 分发源的有序源列表 | +| `AUTO_MAS_SUPERVISED_PORT` | 必填 | 必填 | 受监督后端监听端口,十进制整数(C12,2026-09-02 新增);缺省 managed `36163` / development `36164` | 命名与格式规则: @@ -316,7 +391,8 @@ Runtime `T13.5`;AUTO-MAS `TODO-PY-13`。`架构设计.md`「插件环境职责 2. 镜像类变量用 `AUTO_MAS_MIRROR_`,`` 取 `internal/mirror` 已冻结的 `Kind` 字面量并把连字符换成下划线、转大写:`package-index` → `PACKAGE_INDEX`,`python` → `PYTHON`。**`KindUV` 与 `KindGit` 不注入**——后端没有下载 uv 或 Git 的职责; 3. 列表以 `;` 分隔,按 Runtime 解析后的**尝试顺序**排列,官方源在末位(`--mirror-only` 时不含官方源,`--offline` 时注入空串)。每项是绝对 HTTPS URL,其中不得出现未编码的 `;`; 4. 变量名**刻意不叫** `AUTO_MAS_UV_INDEX_URLS`:AUTO-MAS 侧已有单值的 `AUTO_MAS_UV_INDEX_URL`,只差一个字母的两个变量是排障陷阱。同理,Runtime 不通过直接注入 `UV_INDEX` 等 `UV_*` 变量下发镜像——运行池的 `_clean_process_environment` 不剔除 `UV_*`,那样做等于劫持池未显式覆盖的 uv 行为; -5. 后端读到列表后**自行按序重试**。Runtime 不代替后端执行池的安装,也不感知重试结果。 +5. 后端读到列表后**自行按序重试**。Runtime 不代替后端执行池的安装,也不感知重试结果; +6. `AUTO_MAS_SUPERVISED_PORT` 沿用 `AUTO_MAS_SUPERVISED` 家族命名,表示「受监督时由 Runtime 决定的端口」;取值只允许十进制整数、无前导零、范围 `1024`~`65535`,后端解析失败时按缺失处理并回退 `36163`。 ## 跨仓库落实点 @@ -328,5 +404,7 @@ Runtime `T13.5`;AUTO-MAS `TODO-PY-13`。`架构设计.md`「插件环境职责 | C9 | T13.3 | TODO-PY-12(提供实测数据) | | C10 | T13.4、T13.6(可选加速) | TODO-PY-6 | | C11 | T13.5 | TODO-PY-13 | +| C12 | T13.7 | TODO-PY-8(改读 `AUTO_MAS_SUPERVISED_PORT`) | +| C13 | T13.8 | 无(Electron 保持 stdin 打开即可) | 验收:三关联调(development 一轮 → managed 全链路升降级各一轮 → MaaFW 真跑)见 `doc/任务拆分.md` M13 各任务的「验收」条目;C8 与 C9 的实际后果尚未实机验证,第三关就是为了验它们。 diff --git "a/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1.md" "b/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1.md" index 982faa6..a4f5d56 100644 --- "a/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1.md" +++ "b/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1.md" @@ -7,7 +7,7 @@ - 适用协议:Runtime protocol v1 - 架构基线:[架构设计](./架构设计.md) - 实现基线:AUTO-MAS `origin/dev_v2@3a4e2a65` -- **增量修订:[增补 1](./契约补充-v1-增补1.md)(2026-08-31,定稿 C6~C11,并修订本文档 C2 第 1 条与 C4 第 1 条)——本文档与增补冲突时以增补为准** +- **增量修订:[增补 1](./契约补充-v1-增补1.md)(2026-08-31,定稿 C6~C11,并修订本文档 C2 第 1 条与 C4 第 1 条;2026-09-02 追加 C12、C13,并修订本文档 C1)——本文档与增补冲突时以增补为准** 本文档定稿任务 T1.0 的五个原待决项。若本文档与架构设计的概括性描述存在歧义,以本文档中更具体的 v1 规则为准;对外字段、字面量或环境变量发生变化时必须升级或增量修订共同契约和测试。 @@ -17,7 +17,7 @@ | 编号 | 事项 | v1 结论 | | --- | --- | --- | -| C1 | 后端端口 | 固定 `36163`,不提供端口参数或端口环境变量 | +| C1 | 后端端口 | ~~固定 `36163`,不提供端口参数或端口环境变量~~ **已由 [增补 1 C12](./契约补充-v1-增补1.md#c12受监督端口由-runtime-注入) 修订:`backend supervise --port`(缺省 managed 36163 / development 36164),Runtime 注入 `AUTO_MAS_SUPERVISED_PORT`** | | C2 | 身份注入 | 使用 `AUTO_MAS_RUNTIME_PROTOCOL`、`AUTO_MAS_EXPECTED_VERSION`、`AUTO_MAS_EXPECTED_COMMIT`,后端回显 Runtime 注入值 | | C3 | 后台失败字面量 | 固定为 `failed` | | C4 | 受监督标记 | 固定为 `AUTO_MAS_SUPERVISED=1` | @@ -25,9 +25,13 @@ ## C1:固定后端端口与地址 -协议 v1 固定使用 TCP 端口 `36163`。Runtime CLI 不提供 `--port`,也不注入端口环境变量。 +> **本条已由 [增补 1 C12](./契约补充-v1-增补1.md#c12受监督端口由-runtime-注入) 修订(2026-09-02):** 端口不再固定。 +> `backend supervise` 提供 `--port `(`1024`~`65535`,缺省 managed `36163`、development `36164`), +> Runtime 注入 `AUTO_MAS_SUPERVISED_PORT=`,下表各地址中的 `36163` 读作 ``。`127.0.0.1`、无尾斜杠与地址形态不变。 -Runtime 使用以下规范地址: +~~协议 v1 固定使用 TCP 端口 `36163`。Runtime CLI 不提供 `--port`,也不注入端口环境变量。~~ + +Runtime 使用以下规范地址(缺省 managed 端口示例): | 用途 | 地址 | | --- | --- | @@ -36,7 +40,7 @@ Runtime 使用以下规范地址: | `ready` / `state:running` 的 `details.baseUrl` | `http://127.0.0.1:36163` | | 由调用方派生的 WebSocket 根地址 | `ws://127.0.0.1:36163` | -`baseUrl` 不带结尾斜杠。机器协议使用 `127.0.0.1`,不使用可能受 hosts、代理或 IPv6 解析影响的 `localhost`。未来若需要动态端口,必须作为协议升级单独设计。 +`baseUrl` 不带结尾斜杠。机器协议使用 `127.0.0.1`,不使用可能受 hosts、代理或 IPv6 解析影响的 `localhost`。~~未来若需要动态端口,必须作为协议升级单独设计。~~(2026-09-02:端口参数化已由增补 1 C12 在 v1 内以增量修订落地——`baseUrl` 字段不变、只是取值不再恒定,调用方本就必须消费该字段。) ## C2:身份注入与 health 回显 diff --git "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" index a8f958c..4ef6fe7 100644 --- "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" +++ "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -27,6 +27,12 @@ - 修订:2026-09-02 按 T13.5 收口修订 MaaFW 运行池目录的处置:原拟的「池目录重新分类」按设计关闭, Runtime 的 repair/cleanup 只处理自己的目录,池 venv 因基解释器缺失失效后由后端自行判定重建 (增补 1 C11 结论第 4 条同日修订;「插件环境职责边界」与「文件与删除安全边界」两处同步) +- 修订:2026-09-02 按真机联调暴露的两个首版阻塞项定稿 [增补 1 C12、C13](./契约补充-v1-增补1.md): + 受监督端口不再固定 36163,改由 `backend supervise --port` 决定并经 `AUTO_MAS_SUPERVISED_PORT` 注入 + (managed 缺省 36163、development 缺省 36164,健康/关闭地址与 `baseUrl` 由它派生); + `backend supervise` 的 stdin EOF 视为隐式 `shutdown`,宿主崩溃不再留下孤儿 Runtime 与后端。 + 回写「后端启动流程」「后端关闭流程」「CLI 设计」「标准输入控制」「后端健康检查与关闭契约」五处; + 协议仍为 v1,不新增或改名任何字段、stage、state 与错误码 - 当前范围:系统边界、职责划分、CLI 协议、纯 Git 更新、uv 环境、健康检查、进程监督、镜像、插件职责边界、目录安全、错误与状态契约、测试矩阵、验收标准和分阶段迁移 ## 背景 @@ -284,6 +290,11 @@ Python 后代 PID 只用于 Runtime 本地诊断。调用方不得直接终止 stdin 完成。Runtime 另外从 Job Object 的成员、父子关系和解释器身份证明 uv 与实际 Python 后端仍在同一受管树内,但不新增 `uvPid` 等 v1 字段。 +**2026-09-02 增补([增补 1 C12](./契约补充-v1-增补1.md#c12受监督端口由-runtime-注入)):** 上例中的 +`baseUrl` 端口不再恒为 36163——它由 `backend supervise --port` 决定(managed 缺省 `36163`、development +缺省 `36164`),并作为 `AUTO_MAS_SUPERVISED_PORT` 注入后端。`baseUrl` 字段本身不变,调用方本就必须消费该值 +而不是自行拼接固定端口。 + ## 后端关闭流程 Electron 不再通过进程名查找或全局执行 `taskkill python.exe`。 @@ -304,6 +315,11 @@ Runtime 随后: 6. 清理 PID、锁和临时状态; 7. 以确定的退出码退出。 +**2026-09-02 增补([增补 1 C13](./契约补充-v1-增补1.md#c13宿主断开即关闭)):** 宿主崩溃或被杀时发不出 +`shutdown`。`backend supervise` 在 `hello` 之后遇到 stdin EOF 或读取出错,视为**隐式 `shutdown`**,走上述 +同一条路径与结局(`result.status = "stopped"`、退出码 0,只是没有 `controlCommandId`)。stdout 已随宿主断开时 +Runtime 仍须完成第 4、6 步并退出,不 panic、不阻塞。其他一次性命令的 stdin EOF 行为不变。 + ## 后端启动失败通知与日志 后端尚未启动成功时,HTTP 服务可能不存在,因此启动失败不能依赖 Python HTTP API 通知前端。失败信息通过控制面传递: @@ -476,7 +492,11 @@ auto-mas-runtime `backend supervise` 除 `--mode` / `--repo` 外,另按 [增补 1 C9](./契约补充-v1-增补1.md#c9关闭预算参数化) 提供关闭超时选项:正整数秒,合法范围 -`1`~`120`,默认 `5`,越界按参数错误映射 `INVALID_ARGUMENT`。 +`1`~`120`,默认 `5`,越界按参数错误映射 `INVALID_ARGUMENT`;并按 +[增补 1 C12](./契约补充-v1-增补1.md#c12受监督端口由-runtime-注入) 提供 `--port `:整数,合法范围 +`1024`~`65535`,缺省按模式 managed `36163` / development `36164`,非整数或越界同样映射 +`INVALID_ARGUMENT`(`details.field = "port"`)。该端口经 `AUTO_MAS_SUPERVISED_PORT` 注入后端, +健康检查、关闭地址与 `baseUrl` 全部由它派生;Runtime 不探测、不自动更换端口。 `bootstrap --version` 是准备阶段的编排命令,固定按以下顺序执行: @@ -763,6 +783,8 @@ Electron 根据 `code`、`stage` 和 `retryable` 决定是否提供重试、更 Runtime 接受控制命令后,在下一条相关 `state`、`warning` 或最终 `result` 的 `details.controlCommandId` 中回显 `commandId`。`status` 只返回当前状态快照,不改变状态;重复 `shutdown` 和 `cancel` 必须幂等。无法解析或不适用于当前命令的控制输入产生 `INVALID_CONTROL_COMMAND` warning,但不允许因此遗留正在运行的后端或破坏当前更新事务。 +**stdin EOF(2026-09-02,[增补 1 C13](./契约补充-v1-增补1.md#c13宿主断开即关闭)):** 只对 `backend supervise`,`hello` 之后 stdin 到达 EOF 或读取出错等价于收到一条没有 `commandId` 的 `shutdown`——同一条优雅关闭路径、同样的 `result.status = "stopped"` 与退出码 0,只是不回显 `controlCommandId`;已接受过 `shutdown` / `cancel` 后再遇 EOF 不产生第二次关闭。因此宿主必须在整个监督期间保持 stdin 打开,把它接到 `NUL` 等于立刻请求关闭。其他命令的 stdin EOF 仍只是「不再有控制命令」,操作照常完成。 + ### 退出码 退出码只提供粗粒度分类,精确错误信息由 `error.code` 表达。 @@ -1547,6 +1569,7 @@ Runtime 对自己负责的网络操作只根据退出码和自身网络状态机 AUTO-MAS-Runtime ├── 管理 uv.exe、Python、主项目 venv ├── uv sync --locked + ├── AUTO_MAS_SUPERVISED_PORT=(增补 1 C12) └── AUTO_MAS_UV_EXE= │ ▼ @@ -1569,7 +1592,8 @@ GET /api/core/health POST /api/core/close ``` -协议 v1 固定连接 `127.0.0.1:36163`,并在就绪事件中返回无结尾斜杠的 `baseUrl=http://127.0.0.1:36163`;v1 不提供动态端口参数。具体地址规则见 [协议 v1 契约补充](./契约补充-v1.md#c1固定后端端口与地址)。 +~~协议 v1 固定连接 `127.0.0.1:36163`,并在就绪事件中返回无结尾斜杠的 `baseUrl=http://127.0.0.1:36163`;v1 不提供动态端口参数。~~ +**2026-09-02 修订([增补 1 C12](./契约补充-v1-增补1.md#c12受监督端口由-runtime-注入)):** Runtime 连接 `127.0.0.1:`,`` 由 `backend supervise --port` 决定(managed 缺省 `36163`、development 缺省 `36164`)并经 `AUTO_MAS_SUPERVISED_PORT` 注入后端;健康检查 `http://127.0.0.1:/api/core/health`、关闭 `http://127.0.0.1:/api/core/close` 与就绪事件里无结尾斜杠的 `baseUrl=http://127.0.0.1:` 由同一个端口派生。仍用 `127.0.0.1`;其余地址规则见 [协议 v1 契约补充](./契约补充-v1.md#c1固定后端端口与地址)。 managed 模式创建后端进程前必须读取 `environment.json` 并核对 active repo: 状态必须为 `ready_to_start`,且 `lastSuccessful.version/commit` 必须分别精确等于仓库版本 @@ -1617,10 +1641,15 @@ Runtime 通过 `AUTO_MAS_RUNTIME_PROTOCOL`、`AUTO_MAS_EXPECTED_VERSION` 和 `AU **2026-08-31 增补:** -- **受监督优先级扩展到端口**([增补 1 C7](./契约补充-v1-增补1.md#c7受监督标记对端口与开发环境标记的优先级)):受监督时后端固定监听 `36163`,忽略 `AUTO_MAS_HTTP_PORT` 与一切开发环境标记(仓库根 `.env`、`AUTO_MAS_ENV=development`)。C1 的固定端口在受监督路径上无例外,Runtime 侧无需改动。 +- **受监督优先级扩展到端口**([增补 1 C7](./契约补充-v1-增补1.md#c7受监督标记对端口与开发环境标记的优先级)):受监督时后端 ~~固定监听 `36163`~~ **只认 Runtime 注入的 `AUTO_MAS_SUPERVISED_PORT`、缺失回退 `36163`(2026-09-02 由 C12 修订)**,忽略 `AUTO_MAS_HTTP_PORT` 与一切开发环境标记(仓库根 `.env`、`AUTO_MAS_ENV=development`)。~~C1 的固定端口在受监督路径上无例外,Runtime 侧无需改动。~~ - **`protocol` 字段自报**(增补 1 C7 对 C2 第 1 条的修订):上文列出的第 5 项「协议版本兼容」核对的是后端**自身支持**的协议版本,不是注入值的回显;`version` 与 `commit` 仍为回显。第 6、7 项不变。 - **关闭超时可配**([增补 1 C9](./契约补充-v1-增补1.md#c9关闭预算参数化)):上表的时间参数中,关闭超时由 `backend supervise` 的选项提供,默认仍为 5 秒;就绪侧的四个参数本次不参数化,保持编译期常量。默认值待 AUTO-MAS 侧实测「MaaFW 任务运行中收到 close」的耗时后再决定是否调整。 +**2026-09-02 增补:** + +- **受监督端口由 Runtime 注入**([增补 1 C12](./契约补充-v1-增补1.md#c12受监督端口由-runtime-注入)):`backend supervise --port `(`1024`~`65535`,缺省 managed `36163` / development `36164`)决定端口,Runtime 注入 `AUTO_MAS_SUPERVISED_PORT=`(并入受控监督环境键集合),本节两个接口的地址与 `baseUrl` 由它派生;同一监督进程生命周期内端口不变,单次自动重启复用。后端受监督时只认该变量,缺失回退 36163。 +- **宿主断开即关闭**([增补 1 C13](./契约补充-v1-增补1.md#c13宿主断开即关闭)):`hello` 之后 stdin EOF 或读取出错等价于一条没有 `commandId` 的 `shutdown`,走上一段完全相同的 `POST /api/core/close` → 关闭预算 → Job 兜底路径;stdout 已断开时仍须完成进程树回收与 Mutex / 事务收口后退出。仅 `backend supervise` 适用。 + 这两个接口是 Runtime 与 Python 后端的共同开发契约。任一侧修改字段、语义或协议版本时必须同步更新另一侧及对应测试。后端 schema 变更后通过 OpenAPI 生成器更新前端客户端,不能手工修改生成文件。 ## 后端异常重启 From 5c21fb4e04bf4074d576cae70237d0379e3cd8af Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 22:56:48 +0200 Subject: [PATCH 36/57] =?UTF-8?q?feat(uv):=20=E5=8F=97=E7=AE=A1=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E6=B3=A8=E5=85=A5=20AUTO=5FMAS=5FSUPERVISED=5FPORT=20?= =?UTF-8?q?(T13.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ManagedOptions.Port 非零时以十进制注入 AUTO_MAS_SUPERVISED_PORT,并入受控监督键集合, 宿主与 RunOptions.Environment 的同名变体被清除;越界端口在 spawn 前失败关闭。 Co-Authored-By: Claude Fable 5.1 --- internal/uv/managed.go | 25 +++++++++++++ internal/uv/managed_test.go | 75 +++++++++++++++++++++++++++++++++++++ internal/uv/runner.go | 19 +++++++--- 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/internal/uv/managed.go b/internal/uv/managed.go index c147953..6b1f981 100644 --- a/internal/uv/managed.go +++ b/internal/uv/managed.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "path/filepath" + "strconv" "strings" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/process" @@ -35,6 +36,10 @@ type ManagedOptions struct { RunOptions Identity *SupervisionIdentity Infrastructure SupervisionInfrastructure + // Port 是按增补 1 C12 注入的受监督后端端口(AUTO_MAS_SUPERVISED_PORT)。 + // 为零时不注入:本包是通用启动器,不替调用方决定端口;backend 保证任何模式 + // 都传非零值。非零但越界在 spawn 之前失败关闭。 + Port int } // StartManaged 复用 UVRunner 的路径与环境策略启动长驻 uv,且不提供普通 exec 降级。 @@ -97,6 +102,18 @@ func (r *UVRunner) StartManaged( for key, value := range infrastructure { supervision[key] = value } + if options.Port != 0 { + if err := validateSupervisedPort(options.Port); err != nil { + return nil, newError( + protocol.CodeUVExecFailed, + options.Stage, + "uv 执行失败", + map[string]any{}, + err, + ) + } + supervision[autoMASSupervisedPort] = strconv.Itoa(options.Port) + } if err := validateRunnerPaths(resolved); err != nil { return nil, newError( protocol.CodeUVExecFailed, @@ -191,6 +208,14 @@ func joinSupervisionMirrorSources(sources []string) (string, error) { return strings.Join(sources, mirrorSourceSeparator), nil } +// validateSupervisedPort 校验增补 1 C12 的端口范围;十进制无前导零由 strconv.Itoa 保证。 +func validateSupervisedPort(port int) error { + if port < minSupervisedPort || port > maxSupervisedPort { + return fmt.Errorf("supervised port %d is out of range %d-%d", port, minSupervisedPort, maxSupervisedPort) + } + return nil +} + func validateSupervisionIdentity(identity SupervisionIdentity) error { if !validSupervisionVersion(identity.Version) { return errors.New("managed supervision version is invalid") diff --git a/internal/uv/managed_test.go b/internal/uv/managed_test.go index f5ba829..6df3188 100644 --- a/internal/uv/managed_test.go +++ b/internal/uv/managed_test.go @@ -5,6 +5,7 @@ import ( "errors" "path/filepath" "slices" + "strconv" "strings" "sync" "testing" @@ -621,3 +622,77 @@ func waitManagedProcess(t *testing.T, managed *process.ManagedProcess) { t.Fatalf("Close() error = %v", err) } } + +// TestManaged_InjectsSupervisedPort 锁定增补 1 C12:端口以十进制注入 +// AUTO_MAS_SUPERVISED_PORT,且该键属于受控监督集合——宿主与 RunOptions.Environment +// 里的同名项(含大小写变体)都被清除并被受控值覆盖。 +func TestManaged_InjectsSupervisedPort(t *testing.T) { + runner := newTestRunner(t) + t.Setenv(autoMASSupervisedPort, "1111") + recordPath := filepath.Join(t.TempDir(), "managed-port-record.txt") + managed, err := runner.StartManaged(t.Context(), []string{ + "-test.run=^TestFakeUVProcess$", + }, ManagedOptions{ + RunOptions: RunOptions{ + Stage: protocol.StageBackendSpawn, + Environment: map[string]string{ + "FAKE_UV_RECORD": recordPath, + strings.ToLower(autoMASSupervisedPort): "2222", + }, + }, + Port: 36164, + }, nil) + if err != nil { + t.Fatalf("StartManaged() error = %v", err) + } + waitManagedProcess(t, managed) + record := readTestRecord(t, recordPath) + if got := record[autoMASSupervisedPort]; got != "36164" { + t.Errorf("environment[%q] = %q, want %q", autoMASSupervisedPort, got, "36164") + } + for key := range record { + if strings.EqualFold(key, autoMASSupervisedPort) && key != autoMASSupervisedPort { + t.Errorf("environment contains case variant %q of the supervised port key", key) + } + } +} + +// TestManaged_OmitsSupervisedPortWhenUnset 证明零值不注入:internal/uv 是通用启动器, +// 不替调用方决定端口;backend 保证任何模式都传非零值,由它自己的单测锁定。 +func TestManaged_OmitsSupervisedPortWhenUnset(t *testing.T) { + runner := newTestRunner(t) + t.Setenv(autoMASSupervisedPort, "1111") + recordPath := filepath.Join(t.TempDir(), "managed-no-port-record.txt") + managed, err := runner.StartManaged(t.Context(), []string{ + "-test.run=^TestFakeUVProcess$", + }, ManagedOptions{ + RunOptions: RunOptions{ + Stage: protocol.StageBackendSpawn, + Environment: map[string]string{"FAKE_UV_RECORD": recordPath}, + }, + }, nil) + if err != nil { + t.Fatalf("StartManaged() error = %v", err) + } + waitManagedProcess(t, managed) + record := readTestRecord(t, recordPath) + if value, ok := record[autoMASSupervisedPort]; ok { + t.Errorf("environment[%q] = %q, want the key absent (host value must not leak either)", autoMASSupervisedPort, value) + } +} + +// TestManaged_RejectsInvalidSupervisedPort 覆盖失败关闭:越界端口在 spawn 之前被拒绝。 +func TestManaged_RejectsInvalidSupervisedPort(t *testing.T) { + for _, port := range []int{1023, 65536, -1} { + t.Run(strconv.Itoa(port), func(t *testing.T) { + runner := newTestRunner(t) + managed, err := runner.StartManaged(t.Context(), []string{"run"}, ManagedOptions{ + RunOptions: RunOptions{Stage: protocol.StageBackendSpawn}, + Port: port, + }, nil) + if managed != nil || err == nil { + t.Fatalf("StartManaged() = %#v, %v, want validation error", managed, err) + } + }) + } +} diff --git a/internal/uv/runner.go b/internal/uv/runner.go index f69b54f..7791b70 100644 --- a/internal/uv/runner.go +++ b/internal/uv/runner.go @@ -41,12 +41,18 @@ const ( autoMASMirrorPython = "AUTO_MAS_MIRROR_PYTHON" // mirrorSourceSeparator 是有序源列表的分隔符;单个源里不得出现它。 mirrorSourceSeparator = ";" - autoMASTelemetry = "AUTO_MAS_TELEMETRY" - autoMASSentryDSN = "AUTO_MAS_SENTRY_DSN" - autoMASSentryEnv = "AUTO_MAS_SENTRY_ENVIRONMENT" - autoMASSentryRelease = "AUTO_MAS_SENTRY_RELEASE" - maxUVOutputLineBytes = 1 << 20 - maxUVOutputBytes = 4 << 20 + // autoMASSupervisedPort 按增补 1 C12 注入受监督后端的监听端口;它同样是受控键, + // 宿主同名变量不得穿透——否则正式版与开发版并存时后端会听错端口。 + autoMASSupervisedPort = "AUTO_MAS_SUPERVISED_PORT" + // 受监督端口的合法范围(增补 1 C12):避开特权端口,不超过 TCP 上限。 + minSupervisedPort = 1024 + maxSupervisedPort = 65535 + autoMASTelemetry = "AUTO_MAS_TELEMETRY" + autoMASSentryDSN = "AUTO_MAS_SENTRY_DSN" + autoMASSentryEnv = "AUTO_MAS_SENTRY_ENVIRONMENT" + autoMASSentryRelease = "AUTO_MAS_SENTRY_RELEASE" + maxUVOutputLineBytes = 1 << 20 + maxUVOutputBytes = 4 << 20 // uvCaptureTruncatedNotice 追加在诊断快照末尾,说明输出被截断而非 uv 失败。 uvCaptureTruncatedNotice = "\n[uv 输出已截断:超过诊断保留上限]\n" maxUVStreamBytes = 16 << 20 @@ -553,6 +559,7 @@ func canonicalSupervisionEnvironmentKey(key string) (string, bool) { autoMASUVPythonInstallDir, autoMASMirrorPackageIndex, autoMASMirrorPython, + autoMASSupervisedPort, } { if strings.EqualFold(key, managed) { return managed, true From 3d1ff06cb0319e751824da1a4f431dbc8a04ea8e Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 22:58:16 +0200 Subject: [PATCH 37/57] =?UTF-8?q?feat(health):=20=E5=81=A5=E5=BA=B7?= =?UTF-8?q?=E7=AB=AF=E7=82=B9=E5=9C=B0=E5=9D=80=E6=8C=89=20Expectation.Por?= =?UTF-8?q?t=20=E6=B4=BE=E7=94=9F=20(T13.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 DefaultPort / BaseURL / HealthURLForPort / ValidPort 作为端口与地址的唯一来源; Expectation.Port 零值回退缺省端口,越界在发出请求前失败关闭。HealthURL 保留为缺省端口的派生值。 Co-Authored-By: Claude Fable 5.1 --- internal/health/checker.go | 51 ++++++++++++++++++++++++++++++---- internal/health/health_test.go | 49 ++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/internal/health/checker.go b/internal/health/checker.go index 161c830..2cd0bb8 100644 --- a/internal/health/checker.go +++ b/internal/health/checker.go @@ -8,14 +8,25 @@ import ( "fmt" "io" "net/http" + "strconv" "time" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" ) const ( - // HealthURL 是受管后端健康端点的固定地址。 - HealthURL = "http://127.0.0.1:36163/api/core/health" + // DefaultPort 是受监督端口的缺省值,也是 managed 模式的缺省(增补 1 C12)。 + // Expectation.Port 为零时回退到它,使既有调用方行为不变。 + DefaultPort = 36163 + // MinPort 与 MaxPort 是受监督端口的合法范围(增补 1 C12)。 + MinPort = 1024 + MaxPort = 65535 + // HealthURL 是缺省端口下健康端点的地址;其他端口用 HealthURLForPort 派生。 + // 它与 HealthURLForPort(DefaultPort) 的相等由单测锁定。 + HealthURL = "http://127.0.0.1:36163" + healthPath + + loopbackHost = "127.0.0.1" + healthPath = "/api/core/health" maxHealthBodyBytes = 64 * 1024 defaultTotalTimeout = 60 * time.Second @@ -40,6 +51,32 @@ type Expectation struct { Protocol int Version string Commit string + // Port 是受监督后端的监听端口(增补 1 C12);健康端点地址由它派生, + // 零值回退 DefaultPort,非零但越界失败关闭。 + Port int +} + +// BaseURL 返回受监督后端的根地址(无结尾斜杠),是 baseUrl 与两个接口地址的唯一来源。 +func BaseURL(port int) string { + return "http://" + loopbackHost + ":" + strconv.Itoa(port) +} + +// HealthURLForPort 返回指定端口下健康端点的地址。 +func HealthURLForPort(port int) string { + return BaseURL(port) + healthPath +} + +// ValidPort 报告端口是否落在增补 1 C12 的合法范围内。 +func ValidPort(port int) bool { + return port >= MinPort && port <= MaxPort +} + +// resolvedPort 把 Expectation.Port 的零值解释为缺省端口。 +func (e Expectation) resolvedPort() int { + if e.Port == 0 { + return DefaultPort + } + return e.Port } // Probe 验证受管 Job 中的 uv/Python 身份和存活状态。 @@ -139,6 +176,7 @@ func (c *Checker) Check(ctx context.Context, expected Expectation, probe Probe) } exited := probe.Exited() + healthURL := HealthURLForPort(expected.resolvedPort()) totalTimer := c.clock.NewTimer(c.totalTimeout) defer totalTimer.Stop() total := totalTimer.C() @@ -155,7 +193,7 @@ func (c *Checker) Check(ctx context.Context, expected Expectation, probe Probe) return newError(protocol.CodeBackendExitedBeforeReady, "后端在就绪前退出", nil, nil) } - request := c.request(ctx, total, exited) + request := c.request(ctx, healthURL, total, exited) switch request.kind { case requestCancelled: if err := cancellationError(ctx); err != nil { @@ -302,9 +340,9 @@ type requestResult struct { transportErr error } -func (c *Checker) request(ctx context.Context, total <-chan time.Time, exited <-chan struct{}) requestResult { +func (c *Checker) request(ctx context.Context, healthURL string, total <-chan time.Time, exited <-chan struct{}) requestResult { requestContext, cancel := context.WithCancel(ctx) - request, err := http.NewRequestWithContext(requestContext, http.MethodGet, HealthURL, nil) + request, err := http.NewRequestWithContext(requestContext, http.MethodGet, healthURL, nil) if err != nil { cancel() return requestResult{kind: requestTransportError, transportErr: err} @@ -746,6 +784,9 @@ func validateExpectation(expected Expectation) error { if expected.Protocol != protocol.Version { return identityError(expected, "protocol", expected.Protocol) } + if expected.Port != 0 && !ValidPort(expected.Port) { + return newError(protocol.CodeBackendHealthInvalid, "受监督端口超出范围", map[string]any{"port": expected.Port}, errors.New("supervised port is out of range")) + } if expected.Mode == ModeManaged { if expected.Version == "" { return identityError(expected, "version", nil) diff --git a/internal/health/health_test.go b/internal/health/health_test.go index ac50fc0..3a61ee6 100644 --- a/internal/health/health_test.go +++ b/internal/health/health_test.go @@ -654,3 +654,52 @@ func (t *manualTimer) Stop() bool { t.fired = true return true } + +// TestHealth_RequestURLFollowsExpectationPort 锁定增补 1 C12:健康检查地址由 +// Expectation.Port 派生,零值回退缺省端口,因此既有调用方行为不变。 +func TestHealth_RequestURLFollowsExpectationPort(t *testing.T) { + tests := []struct { + name string + port int + want string + }{ + {name: "zero falls back to the default port", port: 0, want: HealthURL}, + {name: "development default", port: 36164, want: "http://127.0.0.1:36164/api/core/health"}, + {name: "explicit port", port: 5555, want: "http://127.0.0.1:5555/api/core/health"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ready := jsonResponse(healthBody("ready", "", 1, "v5.4.0", testCommit)) + rt := &sequenceTransport{responses: []transportResult{{response: ready}, {response: jsonResponse(healthBody("ready", "", 1, "v5.4.0", testCommit))}}} + expected := managedExpectation() + expected.Port = test.port + if err := testChecker(rt).Check(t.Context(), expected, testProbe()); err != nil { + t.Fatalf("Check() error = %v, want nil", err) + } + if got := rt.lastURL; got != test.want { + t.Fatalf("request URL = %q, want %q", got, test.want) + } + }) + } + if got, want := HealthURL, HealthURLForPort(DefaultPort); got != want { + t.Fatalf("HealthURL = %q, want the derived default %q", got, want) + } + if got := BaseURL(DefaultPort); got != "http://127.0.0.1:36163" || strings.HasSuffix(got, "/") { + t.Fatalf("BaseURL(DefaultPort) = %q, want no trailing slash", got) + } +} + +// TestHealth_RejectsOutOfRangePort 证明越界端口在发出任何请求之前失败关闭。 +func TestHealth_RejectsOutOfRangePort(t *testing.T) { + for _, port := range []int{1023, 65536, -1} { + t.Run(fmt.Sprint(port), func(t *testing.T) { + rt := &sequenceTransport{} + expected := managedExpectation() + expected.Port = port + assertHealthCode(t, testChecker(rt).Check(t.Context(), expected, testProbe()), protocol.CodeBackendHealthInvalid) + if rt.count != 0 { + t.Fatalf("request count = %d, want 0", rt.count) + } + }) + } +} From c09adc039e45d44b4556ab7c8c7ab1ec1bd25284 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 23:01:28 +0200 Subject: [PATCH 38/57] =?UTF-8?q?feat(backend):=20=E5=8F=97=E7=9B=91?= =?UTF-8?q?=E7=9D=A3=E7=AB=AF=E5=8F=A3=E6=8C=89=E6=A8=A1=E5=BC=8F=E5=8F=96?= =?UTF-8?q?=E7=BC=BA=E7=9C=81=E5=B9=B6=E6=B4=BE=E7=94=9F=E5=81=A5=E5=BA=B7?= =?UTF-8?q?=E3=80=81=E5=85=B3=E9=97=AD=E5=9C=B0=E5=9D=80=E4=B8=8E=20baseUr?= =?UTF-8?q?l=20(T13.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Request.Port 零值按模式取缺省(managed 36163 / development 36164),越界映射 INVALID_ARGUMENT 且不打开任何资源;解析一次写回 Request,首启与单次自动重启同值。注入 uv 的 AUTO_MAS_SUPERVISED_PORT、health.Expectation.Port、running 事件的 baseUrl 与 /api/core/close 地址全部由 health.BaseURL 派生,关闭器改为按端口构造的 loopbackHTTPCloser。 Co-Authored-By: Claude Fable 5.1 --- internal/backend/control.go | 44 +------ internal/backend/port.go | 89 ++++++++++++++ internal/backend/port_test.go | 182 ++++++++++++++++++++++++++++ internal/backend/supervisor.go | 9 +- internal/backend/supervisor_test.go | 2 +- internal/backend/types.go | 6 +- 6 files changed, 289 insertions(+), 43 deletions(-) create mode 100644 internal/backend/port.go create mode 100644 internal/backend/port_test.go diff --git a/internal/backend/control.go b/internal/backend/control.go index 9db16de..1eec2e8 100644 --- a/internal/backend/control.go +++ b/internal/backend/control.go @@ -3,9 +3,6 @@ package backend import ( "context" "errors" - "fmt" - "io" - "net/http" "sync" "time" @@ -20,7 +17,6 @@ const ( defaultShutdownTimeout = 5 * time.Second defaultRestartDelay = 2 * time.Second controlDrainTimeout = time.Second - backendCloseURL = "http://127.0.0.1:36163/api/core/close" // developmentEntryArgument 是 development 模式沿用的相对入口:cwd 就是 // --repo 指定的源码目录,绝对路径只属于 managed(增补 1 C6 第 2 条)。 developmentEntryArgument = "main.py" @@ -1308,6 +1304,7 @@ func (s *ManagedSupervisor) startControlAttempt(ctx context.Context, request Req RunOptions: uv.RunOptions{Stage: protocol.StageBackendSpawn, WorkingDir: workingDir, ProjectDir: projectDir, ProjectEnvDir: projectEnvDir}, Identity: identity, Infrastructure: s.infrastructure, + Port: request.Port, }, s.streamSink(request, logger, gate)) if err != nil || proc == nil { fault := gate.Fault() @@ -1493,7 +1490,7 @@ func (s *ManagedSupervisor) startControlAttempt(ctx context.Context, request Req } gate.SetStage(protocol.StageBackendRun) setControlStage(request.Control, protocol.StageBackendRun) - details := map[string]any{"pid": proc.PID(), "baseUrl": "http://127.0.0.1:36163", "logPath": logger.LogPath()} + details := map[string]any{"pid": proc.PID(), "baseUrl": health.BaseURL(request.Port), "logPath": logger.LogPath()} if err := s.emitState(request.Emitter, protocol.StageBackendRun, protocol.StateRunning, "后端已就绪", details); err != nil { cleanup := s.cleanupProcess(context.WithoutCancel(ctx), proc, tx, logger) return nil, markCommitted(errors.Join(cleanup.err, withFailureDetailsExtra(err, logger, proc, cleanup.details))) @@ -1505,7 +1502,7 @@ func (s *ManagedSupervisor) startControlAttempt(ctx context.Context, request Req func (s *ManagedSupervisor) awaitHealth(ctx context.Context, request Request, revision state.Revision, probe health.Probe, gate *streamGate, snapshot *controlState, results <-chan controlResult) error { healthCtx, cancel := context.WithCancel(ctx) defer cancel() - expectation := health.Expectation{Mode: health.ModeManaged, Protocol: protocol.Version, Version: revision.Version, Commit: revision.Commit} + expectation := health.Expectation{Mode: health.ModeManaged, Protocol: protocol.Version, Version: revision.Version, Commit: revision.Commit, Port: request.Port} if modeForRequest(request) == ModeDevelopment { expectation.Mode = health.ModeDevelopment expectation.Version = "" @@ -1935,7 +1932,7 @@ func (s *ManagedSupervisor) finishControlShutdown(ctx context.Context, request R defer cancel() closer := s.deps.HTTP if closer == nil { - closer = fixedHTTPCloser{} + closer = newLoopbackHTTPCloser(request.Port) } httpErr := closer.Close(closeCtx) graceful := httpErr == nil && waitProcessExit(closeCtx, attempt.process) @@ -2005,36 +2002,3 @@ func waitProcessExit(ctx context.Context, proc ManagedProcess) bool { return false } } - -type fixedHTTPCloser struct{} - -func (fixedHTTPCloser) Close(ctx context.Context) error { - if ctx == nil { - return errors.New("backend shutdown context is nil") - } - client := &http.Client{Transport: &http.Transport{Proxy: nil}} - request, err := http.NewRequestWithContext(ctx, http.MethodPost, backendCloseURL, nil) - if err != nil { - return err - } - response, err := client.Do(request) - if err != nil { - return err - } - readErr := error(nil) - if response.Body != nil { - _, readErr = io.Copy(io.Discard, io.LimitReader(response.Body, 64<<10)) - } - closeErr := error(nil) - if response.Body != nil { - closeErr = response.Body.Close() - } - client.CloseIdleConnections() - statusErr := error(nil) - if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { - statusErr = fmt.Errorf("backend close returned status %d", response.StatusCode) - } - return errors.Join(statusErr, readErr, closeErr) -} - -var _ HTTPCloser = fixedHTTPCloser{} diff --git a/internal/backend/port.go b/internal/backend/port.go new file mode 100644 index 0000000..6399875 --- /dev/null +++ b/internal/backend/port.go @@ -0,0 +1,89 @@ +package backend + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/health" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" +) + +const ( + // developmentDefaultPort 是 development 模式的缺省端口(增补 1 C12):与 AUTO-MAS dev + // 已合入的「开发版 36164 / 正式版 36163 并存」约定一致,让受监督的开发版不撞正式版。 + developmentDefaultPort = 36164 + backendClosePath = "/api/core/close" +) + +// defaultPortForMode 返回模式的缺省受监督端口:managed 沿用 health.DefaultPort。 +func defaultPortForMode(mode Mode) int { + if mode == ModeDevelopment { + return developmentDefaultPort + } + return health.DefaultPort +} + +// resolveSupervisedPort 把 Request.Port 解析成最终端口:零值按模式取缺省,越界失败关闭。 +// 这里是 backend 内唯一的解析点,首启、单次自动重启、managed 与 development 都读写回 +// Request 的值,因此同一监督进程生命周期内端口不变。 +func resolveSupervisedPort(request Request, mode Mode) (int, error) { + port := request.Port + if port == 0 { + port = defaultPortForMode(mode) + } + if !health.ValidPort(port) { + return 0, newError(protocol.CodeInvalidArgument, protocol.StageBackendSpawn, "受监督端口超出范围", map[string]any{ + "field": "port", + "port": port, + }, errors.New("supervised port is out of range")) + } + return port, nil +} + +// backendCloseURL 返回派生端口下的优雅关闭地址。 +func backendCloseURL(port int) string { + return health.BaseURL(port) + backendClosePath +} + +// loopbackHTTPCloser 向派生端口的 /api/core/close 发起关闭请求。 +type loopbackHTTPCloser struct { + port int +} + +func newLoopbackHTTPCloser(port int) loopbackHTTPCloser { + return loopbackHTTPCloser{port: port} +} + +func (c loopbackHTTPCloser) Close(ctx context.Context) error { + if ctx == nil { + return errors.New("backend shutdown context is nil") + } + client := &http.Client{Transport: &http.Transport{Proxy: nil}} + request, err := http.NewRequestWithContext(ctx, http.MethodPost, backendCloseURL(c.port), nil) + if err != nil { + return err + } + response, err := client.Do(request) + if err != nil { + return err + } + readErr := error(nil) + if response.Body != nil { + _, readErr = io.Copy(io.Discard, io.LimitReader(response.Body, 64<<10)) + } + closeErr := error(nil) + if response.Body != nil { + closeErr = response.Body.Close() + } + client.CloseIdleConnections() + statusErr := error(nil) + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + statusErr = fmt.Errorf("backend close returned status %d", response.StatusCode) + } + return errors.Join(statusErr, readErr, closeErr) +} + +var _ HTTPCloser = loopbackHTTPCloser{} diff --git a/internal/backend/port_test.go b/internal/backend/port_test.go new file mode 100644 index 0000000..1e9505e --- /dev/null +++ b/internal/backend/port_test.go @@ -0,0 +1,182 @@ +package backend + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "testing" + + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/health" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" +) + +// TestBackend_PortDefaultsByModeAndDerivesAddresses 锁定增补 1 C12 的三件事在 +// managed(无控制路径)与 development(受控路径)下同时成立:缺省端口按模式给出、 +// 显式端口原样使用;注入 uv 的 Port、健康检查的 Expectation.Port 与 running 事件的 +// baseUrl 三者由同一个端口派生。 +func TestBackend_PortDefaultsByModeAndDerivesAddresses(t *testing.T) { + tests := []struct { + name string + development bool + port int + want int + }{ + {name: "managed default", want: health.DefaultPort}, + {name: "development default", development: true, want: 36164}, + {name: "managed explicit", port: 5555, want: 5555}, + {name: "development explicit", development: true, port: 6666, want: 6666}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f := newBackendFixture(t) + f.proc.keepAlive = true + request := f.request() + request.Port = test.port + if test.development { + request = developmentRequest(request, newDevelopmentRepo(t)) + } + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- f.supervisor().Supervise(ctx, request) }() + waitFor(t, f.emitter.running) + cancel() + if err := <-done; !errors.Is(err, context.Canceled) && !hasBackendCode(err, protocol.CodeOperationCancelled) { + t.Fatalf("Supervise() error = %v, want cancellation", err) + } + if got := f.uv.options.Port; got != test.want { + t.Errorf("ManagedOptions.Port = %d, want %d", got, test.want) + } + if len(f.health.expectations) == 0 { + t.Fatal("health expectations are empty") + } + if got := f.health.expectations[0].Port; got != test.want { + t.Errorf("health Expectation.Port = %d, want %d", got, test.want) + } + var running *protocol.StateEvent + for _, event := range f.emitter.states() { + if event.Status == protocol.StateRunning { + clone := event + running = &clone + break + } + } + if running == nil { + t.Fatalf("states = %#v, want running", f.emitter.states()) + } + if got, want := running.Details["baseUrl"], health.BaseURL(test.want); got != want { + t.Errorf("running baseUrl = %#v, want %q", got, want) + } + }) + } +} + +// TestBackend_RestartReusesSupervisedPort 证明单次自动重启的第二代进程拿到同一个端口。 +func TestBackend_RestartReusesSupervisedPort(t *testing.T) { + f := newBackendFixture(t) + f.proc.keepAlive = true + second := &fakeProcess{pid: 4343, keepAlive: true} + f.uv.procSequence = []ManagedProcess{f.proc, second} + var mu sync.Mutex + var ports []int + f.uv.onStart = func() { + mu.Lock() + ports = append(ports, f.uv.options.Port) + mu.Unlock() + } + mailbox := NewControlMailbox(8) + done := make(chan error, 1) + go func() { + req := f.request() + req.Control = mailbox + req.Port = 7777 + done <- f.supervisor().Supervise(t.Context(), req) + }() + waitFor(t, f.emitter.running) + f.proc.Exit() + waitForStateStatusCount(t, f.emitter, protocol.StateRunning, 2) + if err := mailbox.Submit(context.Background(), protocol.ControlCommand{Command: protocol.ControlCancel, CommandID: "cancel-after-restart"}); err != nil { + t.Fatalf("Submit(cancel) error = %v", err) + } + if err := <-done; !hasBackendCode(err, protocol.CodeOperationCancelled) && !errors.Is(err, context.Canceled) { + t.Fatalf("Supervise() error = %v, want cancellation", err) + } + mu.Lock() + defer mu.Unlock() + if len(ports) != 2 || ports[0] != 7777 || ports[1] != 7777 { + t.Fatalf("supervised ports across restart = %v, want [7777 7777]", ports) + } + runningCount := 0 + for _, event := range f.emitter.states() { + if event.Status != protocol.StateRunning { + continue + } + runningCount++ + if got, want := event.Details["baseUrl"], health.BaseURL(7777); got != want { + t.Errorf("running #%d baseUrl = %#v, want %q", runningCount, got, want) + } + } +} + +// TestBackend_CloseRequestTargetsSupervisedPort 用真实回环 HTTP 服务证明关闭请求 +// 打到派生端口的 /api/core/close,且非 2xx 仍按失败处理。 +func TestBackend_CloseRequestTargetsSupervisedPort(t *testing.T) { + var mu sync.Mutex + var seen []string + status := http.StatusOK + server := httptest.NewUnstartedServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + mu.Lock() + seen = append(seen, request.Method+" "+request.URL.Path) + code := status + mu.Unlock() + writer.WriteHeader(code) + })) + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen() error = %v", err) + } + server.Listener = listener + server.Start() + t.Cleanup(server.Close) + port := listener.Addr().(*net.TCPAddr).Port + closer := newLoopbackHTTPCloser(port) + if err := closer.Close(t.Context()); err != nil { + t.Fatalf("Close() error = %v, want nil", err) + } + mu.Lock() + got := append([]string(nil), seen...) + status = http.StatusServiceUnavailable + mu.Unlock() + if len(got) != 1 || got[0] != "POST /api/core/close" { + t.Fatalf("requests = %#v, want a single POST /api/core/close on port %d", got, port) + } + if err := closer.Close(t.Context()); err == nil { + t.Fatal("Close() error = nil, want failure for status 503") + } + if got, want := backendCloseURL(port), health.BaseURL(port)+"/api/core/close"; got != want { + t.Fatalf("backendCloseURL(%d) = %q, want %q", port, got, want) + } +} + +// TestBackend_RejectsOutOfRangePort 证明越界端口在获取任何资源、创建任何进程之前 +// 映射 INVALID_ARGUMENT。 +func TestBackend_RejectsOutOfRangePort(t *testing.T) { + for _, port := range []int{1023, 65536, -1} { + t.Run(strconv.Itoa(port), func(t *testing.T) { + f := newBackendFixture(t) + request := f.request() + request.Port = port + err := f.supervisor().Supervise(t.Context(), request) + assertBackendCode(t, err, protocol.CodeInvalidArgument) + if f.uv.startCalls != 0 { + t.Fatalf("StartManaged calls = %d, want 0", f.uv.startCalls) + } + if f.logger.closeCalls != 0 { + t.Fatalf("logger close calls = %d, want 0 (no resources may be opened)", f.logger.closeCalls) + } + }) + } +} diff --git a/internal/backend/supervisor.go b/internal/backend/supervisor.go index 5808484..2163457 100644 --- a/internal/backend/supervisor.go +++ b/internal/backend/supervisor.go @@ -145,6 +145,11 @@ func (s *ManagedSupervisor) Supervise(ctx context.Context, request Request) (ret if err := ctx.Err(); err != nil { return err } + port, err := resolveSupervisedPort(request, mode) + if err != nil { + return err + } + request.Port = port if mode == ModeDevelopment { var err error request, err = s.normalizeDevelopmentRequest(ctx, request) @@ -283,6 +288,7 @@ func (s *ManagedSupervisor) Supervise(ctx context.Context, request Request) (ret }, Identity: &uv.SupervisionIdentity{Version: revision.Version, Commit: revision.Commit}, Infrastructure: s.infrastructure, + Port: request.Port, }, sink) if err != nil || proc == nil { if fault := gate.Fault(); fault != nil { @@ -343,6 +349,7 @@ func (s *ManagedSupervisor) Supervise(ctx context.Context, request Request) (ret Protocol: protocol.Version, Version: revision.Version, Commit: revision.Commit, + Port: request.Port, }, probe); err != nil { if fault := gate.Fault(); fault != nil { cleanup := s.cleanupProcess(context.WithoutCancel(ctx), proc, tx, logger) @@ -377,7 +384,7 @@ func (s *ManagedSupervisor) Supervise(ctx context.Context, request Request) (ret gate.SetStage(protocol.StageBackendRun) if err := s.emitState(request.Emitter, protocol.StageBackendRun, protocol.StateRunning, "后端已就绪", map[string]any{ "pid": proc.PID(), - "baseUrl": "http://127.0.0.1:36163", + "baseUrl": health.BaseURL(request.Port), "logPath": logger.LogPath(), }); err != nil { return s.failAfterStarting(request, proc, tx, logger, gate, err, &processOwned, &txOwned, &loggerOwned) diff --git a/internal/backend/supervisor_test.go b/internal/backend/supervisor_test.go index db2e081..b3f13e4 100644 --- a/internal/backend/supervisor_test.go +++ b/internal/backend/supervisor_test.go @@ -170,7 +170,7 @@ func TestBackendManaged_ReadyEmitsRunningState(t *testing.T) { if got := states[1].Details["pid"]; got != f.proc.pid { t.Fatalf("running pid detail = %#v, want %d", got, f.proc.pid) } - if got := states[1].Details["baseUrl"]; got != "http://127.0.0.1:36163" { + if got := states[1].Details["baseUrl"]; got != health.BaseURL(health.DefaultPort) { t.Fatalf("running baseUrl = %#v", got) } diff --git a/internal/backend/types.go b/internal/backend/types.go index 6a994a7..989036f 100644 --- a/internal/backend/types.go +++ b/internal/backend/types.go @@ -28,7 +28,11 @@ type Request struct { // ShutdownTimeout 是从发出 POST /api/core/close 到进程退出的等待上限, // 超时才收 Job(增补 1 C9)。CLI 由 --shutdown-timeout 提供,取值 1~120 秒; // 为零或负数时回退 Dependencies.ShutdownTimeout。 - ShutdownTimeout time.Duration + ShutdownTimeout time.Duration + // Port 是受监督后端的监听端口(增补 1 C12)。CLI 由 --port 提供;为零时按模式 + // 取缺省(managed 36163 / development 36164),越界映射 INVALID_ARGUMENT。 + // 注入 uv 的 AUTO_MAS_SUPERVISED_PORT、健康检查地址、关闭地址与 baseUrl 全部由它派生。 + Port int Emitter EventEmitter Control ControlReceiver BeforeShutdown func(string) From 77bb0f2ba7b466f9e455a0b603d814fd51afaca7 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 23:02:54 +0200 Subject: [PATCH 39/57] =?UTF-8?q?feat(cli):=20backend=20supervise=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20--port=20(T13.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 整数、合法范围 1024~65535;未显式给出时按模式取缺省(managed 36163 / development 36164), 显式空串、非整数与越界映射 INVALID_ARGUMENT(details.field=port)并在 backendFactory 之前拒绝。 Co-Authored-By: Claude Fable 5.1 --- internal/cli/backend.go | 47 +++++++++++++ internal/cli/backend_test.go | 99 +++++++++++++++++++++++++++ internal/cli/contract_backend_test.go | 3 +- 3 files changed, 148 insertions(+), 1 deletion(-) diff --git a/internal/cli/backend.go b/internal/cli/backend.go index bfdbad8..bcea840 100644 --- a/internal/cli/backend.go +++ b/internal/cli/backend.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/backend" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/health" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" ) @@ -24,12 +25,17 @@ const ( backendShutdownTimeoutDefault = "5" backendShutdownTimeoutMin = 1 backendShutdownTimeoutMax = 120 + // 受监督端口按增补 1 C12:缺省随模式而定(managed 36163 / development 36164), + // 因此 flag 的默认值留空,由 parseBackendPort 在解析 --mode 之后给出。 + backendPortManagedDefault = health.DefaultPort + backendPortDevelopmentDefault = 36164 ) func backendSuperviseCommand(deps *deps) *cobra.Command { var mode string var repo string var shutdownTimeout string + var port string command := &cobra.Command{ Use: "supervise", Short: "启动并监督后端进程", @@ -86,6 +92,10 @@ func backendSuperviseCommand(deps *deps) *cobra.Command { if err != nil { return sessionSuccess{}, err } + supervisedPort, err := parseBackendPort(port, cmd.Flags().Changed("port"), mode) + if err != nil { + return sessionSuccess{}, err + } service, err := deps.options.backendFactory( ctx, deps.global.layout, @@ -121,6 +131,7 @@ func backendSuperviseCommand(deps *deps) *cobra.Command { Mode: backend.Mode(mode), DevelopmentRepo: repo, ShutdownTimeout: shutdownBudget, + Port: supervisedPort, Emitter: &backendEventEmitter{emitter: emitter, control: control}, Control: mailbox, BeforeShutdown: mailbox.BeforeShutdown, @@ -146,9 +157,45 @@ func backendSuperviseCommand(deps *deps) *cobra.Command { backendShutdownTimeoutDefault, "关闭后端的等待上限(秒),取值 1~120", ) + command.Flags().StringVar( + &port, + "port", + "", + "受监督后端监听端口,取值 1024~65535;缺省 managed 36163、development 36164", + ) return command } +// parseBackendPort 校验 --port(增补 1 C12):整数、合法范围 1024~65535,未显式给出时 +// 按模式取缺省;显式给出的空串与非整数一样拒绝。与 --shutdown-timeout 同理收 string +// 自行解析,让非整数与越界共用 INVALID_ARGUMENT 的 result 语义,而不是落到 Cobra 的 +// stderr 诊断通道。 +func parseBackendPort(raw string, explicit bool, mode string) (int, error) { + if !explicit { + if mode == backendModeDevelopment { + return backendPortDevelopmentDefault, nil + } + return backendPortManagedDefault, nil + } + reject := func(cause error) error { + return &commandError{ + code: protocol.CodeInvalidArgument, + stage: protocol.StageBackendSpawn, + message: "受监督端口必须是 1024 到 65535 之间的整数", + details: map[string]any{"field": "port", "value": raw}, + cause: cause, + } + } + port, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil { + return 0, reject(errors.New("backend port is not an integer")) + } + if !health.ValidPort(port) { + return 0, reject(errors.New("backend port is out of range")) + } + return port, nil +} + // parseBackendShutdownTimeout 校验 --shutdown-timeout(增补 1 C9):正整数秒、 // 合法范围 1~120。这里刻意收 string 而不是让 pflag 收 int——pflag 的整数解析失败 // 发生在 Cobra 解析阶段,只会走 stderr 诊断通道,产不出 INVALID_ARGUMENT 的 diff --git a/internal/cli/backend_test.go b/internal/cli/backend_test.go index 7d52c4f..81ee7c5 100644 --- a/internal/cli/backend_test.go +++ b/internal/cli/backend_test.go @@ -428,3 +428,102 @@ func (r *backendReadError) Read([]byte) (int, error) { } func (*backendReadError) Close() error { return nil } + +// TestBackendSupervise_PortArgument 覆盖增补 1 C12 的参数契约:整数、合法范围 +// 1024~65535,缺省按模式 managed 36163 / development 36164;越界或非整数映射 +// INVALID_ARGUMENT 并在建立任何后端资源之前失败关闭。 +func TestBackendSupervise_PortArgument(t *testing.T) { + t.Parallel() + + accepted := []struct { + name string + mode []string + args []string + want int + }{ + {name: "managed default", mode: []string{"--mode", "managed"}, want: 36163}, + {name: "development default", mode: []string{"--mode", "development", "--repo", "source"}, want: 36164}, + {name: "lower bound", mode: []string{"--mode", "managed"}, args: []string{"--port", "1024"}, want: 1024}, + {name: "upper bound", mode: []string{"--mode", "managed"}, args: []string{"--port", "65535"}, want: 65535}, + {name: "development explicit", mode: []string{"--mode", "development", "--repo", "source"}, args: []string{"--port", "36170"}, want: 36170}, + } + for _, test := range accepted { + t.Run("accepted/"+test.name, func(t *testing.T) { + t.Parallel() + var captured backend.Request + var stdout, stderr bytes.Buffer + args := []string{"--app-root", t.TempDir(), "--output", "ndjson", "backend", "supervise"} + args = append(args, test.mode...) + args = append(args, test.args...) + code := Execute( + context.Background(), + args, + IO{In: strings.NewReader(""), Out: &stdout, Err: &stderr}, + WithCWD(t.TempDir()), + WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time, mirror.Policy) (backendService, error) { + return backendServiceFunc(func(_ context.Context, request backend.Request) error { + captured = request + return nil + }), nil + }), + ) + if code != protocol.ExitCodeSuccess { + t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) + } + if got := captured.Port; got != test.want { + t.Fatalf("supervised port = %d, want %d", got, test.want) + } + }) + } + + rejected := []struct { + name string + value string + }{ + {name: "below lower bound", value: "1023"}, + {name: "above upper bound", value: "65536"}, + {name: "zero", value: "0"}, + {name: "negative", value: "-1"}, + {name: "not an integer", value: "abc"}, + {name: "fractional", value: "1.5"}, + {name: "empty", value: ""}, + } + for _, test := range rejected { + t.Run("rejected/"+test.name, func(t *testing.T) { + t.Parallel() + var factoryCalls int + var stdout, stderr bytes.Buffer + code := Execute( + context.Background(), + []string{ + "--app-root", t.TempDir(), "--output", "ndjson", + "backend", "supervise", "--mode", "managed", "--port", test.value, + }, + IO{In: strings.NewReader(""), Out: &stdout, Err: &stderr}, + WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time, mirror.Policy) (backendService, error) { + factoryCalls++ + return backendServiceFunc(func(context.Context, backend.Request) error { return nil }), nil + }), + ) + if factoryCalls != 0 { + t.Fatalf("backend factory calls = %d, want 0", factoryCalls) + } + definition, ok := protocol.LookupErrorDefinition(protocol.CodeInvalidArgument) + if !ok { + t.Fatal("INVALID_ARGUMENT definition is missing") + } + if code != definition.ExitCode { + t.Fatalf("exit code = %d, want %d; stderr=%q", code, definition.ExitCode, stderr.String()) + } + events := parseNDJSON(t, stdout.String()) + result := events[len(events)-1] + if got := eventString(result, "code"); got != string(protocol.CodeInvalidArgument) { + t.Fatalf("result code = %q, want INVALID_ARGUMENT", got) + } + details, ok := result.object["details"].(map[string]any) + if !ok || details["field"] != "port" { + t.Fatalf("result details = %#v, want field=port", result.object["details"]) + } + }) + } +} diff --git a/internal/cli/contract_backend_test.go b/internal/cli/contract_backend_test.go index b21053f..d07c135 100644 --- a/internal/cli/contract_backend_test.go +++ b/internal/cli/contract_backend_test.go @@ -10,6 +10,7 @@ import ( "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/backend" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/config" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/health" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol/contracttest" @@ -90,7 +91,7 @@ func backendContractRunner() contracttest.Runner { return request.Emitter.EmitState(protocol.StateEvent{ Stage: protocol.StageBackendRun, Status: protocol.StateRunning, Message: "后端已就绪", Details: map[string]any{ - "pid": uint32(42), "baseUrl": "http://127.0.0.1:36163", "logPath": "backend.log", + "pid": uint32(42), "baseUrl": health.BaseURL(health.DefaultPort), "logPath": "backend.log", }, }) }) From f7d5edb1b6ac0773d4c6bff8ca3b43849f606b38 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 23:05:14 +0200 Subject: [PATCH 40/57] =?UTF-8?q?test:=20=E5=81=87=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E4=BB=8E=20AUTO=5FMAS=5FSUPERVISED=5FPORT=20=E5=8F=96=E7=9B=91?= =?UTF-8?q?=E5=90=AC=E7=AB=AF=E5=8F=A3=EF=BC=8CE2E=20=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E7=A9=BA=E9=97=B2=E7=AB=AF=E5=8F=A3=20(T13.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 夹具不再设置 listenAddress,健康检查能通过即证明后端从环境变量读到了端口;E2E 用 net.Listen(":0") 探空闲端口并经 Request.Port 传入,端口辅助函数改为带端口参数, 跨进程 helper 的 signal 增加 port,串行化 Mutex 改为与端口无关的名字。 Co-Authored-By: Claude Fable 5.1 --- internal/backend/e2e_windows_test.go | 96 +++++++++++++++++++--------- testdata/README.md | 6 +- testdata/fakebackend/main.go | 20 +++++- testdata/fakebackend/main_test.go | 53 +++++++++++++++ 4 files changed, 140 insertions(+), 35 deletions(-) diff --git a/internal/backend/e2e_windows_test.go b/internal/backend/e2e_windows_test.go index fa7fe0a..94edd0e 100644 --- a/internal/backend/e2e_windows_test.go +++ b/internal/backend/e2e_windows_test.go @@ -13,6 +13,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "strings" "sync" "testing" @@ -35,8 +36,10 @@ const ( e2eSkipPortMutex = "BACKEND_E2E_SKIP_PORT_MUTEX" e2eRuntimeHelper = "BACKEND_E2E_RUNTIME_HELPER" e2eRuntimeSignal = "BACKEND_E2E_RUNTIME_SIGNAL" - e2ePortMutexName = `Local\AUTO-MAS-RUNTIME-M6-E2E-PORT-36163` - e2eOperationID = "01ARZ3NDEKTSV4RRFFQ69G5FAV" + // e2ePortMutexName 串行化本包的 Windows E2E(含跨进程 helper);端口按夹具动态探取, + // Mutex 只负责限制同时活跃的真实进程树数量,与具体端口无关。 + e2ePortMutexName = `Local\AUTO-MAS-RUNTIME-M6-E2E-SERIAL` + e2eOperationID = "01ARZ3NDEKTSV4RRFFQ69G5FAV" ) type backendE2EConfig struct { @@ -73,6 +76,7 @@ type backendE2EEvent struct { type backendE2ERuntimeSignal struct { Root string `json:"root"` + Port int `json:"port"` AppRoot string `json:"appRoot"` Repo string `json:"repo"` BackendState string `json:"backendState"` @@ -254,6 +258,9 @@ type backendE2EFixture struct { supervisor *ManagedSupervisor config backendE2EConfig timerReady chan *backendE2ETimer + // port 是本次夹具探出的空闲端口(增补 1 C12):E2E 不再依赖 36163 空闲, + // 假后端也不再被告知监听地址,只能从 AUTO_MAS_SUPERVISED_PORT 读到它。 + port int } // backendE2EPIDGeneration 保存一次启动中三个真实进程的同步句柄;句柄 @@ -412,7 +419,7 @@ func newBackendE2EFixture(t *testing.T, configValue backendE2EConfig) *backendE2 if err := copyE2EFile(fakeBackend, developmentPythonPath); err != nil { t.Fatalf("copy fake backend to development venv: %v", err) } - configValue.ListenAddress = "127.0.0.1:36163" + port := pickE2EFreePort(t) configValue.PIDFile = filepath.Join(root, "python.pid") configValue.WorkingDirFile = filepath.Join(root, "backend.cwd") configValue.EnvironmentFile = filepath.Join(root, "backend.env") @@ -436,11 +443,11 @@ func newBackendE2EFixture(t *testing.T, configValue backendE2EConfig) *backendE2 t.Setenv("UV_DEFAULT_INDEX", "https://must-not-leak.example/simple") t.Setenv("AUTO_MAS_EXPECTED_VERSION", "stale-version") t.Setenv("AUTO_MAS_EXPECTED_COMMIT", "stale-commit") - if err := waitE2EPortClosed(t.Context()); err != nil { - t.Fatalf("port 36163 is occupied before fixture: %v", err) + if err := waitE2EPortClosed(t.Context(), port); err != nil { + t.Fatalf("port %d is occupied before fixture: %v", port, err) } - if err := assertE2EPortBindable(); err != nil { - t.Fatalf("port 36163 cannot bind before fixture: %v", err) + if err := assertE2EPortBindable(port); err != nil { + t.Fatalf("port %d cannot bind before fixture: %v", port, err) } mirrorPolicy, err := mirror.NewPolicy(mirror.PolicySpec{}) if err != nil { @@ -486,9 +493,25 @@ func newBackendE2EFixture(t *testing.T, configValue backendE2EConfig) *backendE2 supervisor: supervisor, config: configValue, timerReady: timerReady, + port: port, } } +// pickE2EFreePort 让内核分配一个空闲端口后立即释放;本机 36163 被正式版 AUTO-MAS 占用, +// E2E 不得触碰它(增补 1 C12)。 +func pickE2EFreePort(t *testing.T) int { + t.Helper() + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen(127.0.0.1:0) error = %v", err) + } + port := listener.Addr().(*net.TCPAddr).Port + if err := listener.Close(); err != nil { + t.Fatalf("close free port probe: %v", err) + } + return port +} + func (f *backendE2EFixture) request() Request { f.t.Helper() return Request{ @@ -496,6 +519,7 @@ func (f *backendE2EFixture) request() Request { RuntimePID: uint32(os.Getpid()), Mode: ModeDevelopment, DevelopmentRepo: f.repo, + Port: f.port, Emitter: f.emitter, Control: f.mailbox, BeforeShutdown: f.mailbox.BeforeShutdown, @@ -505,11 +529,11 @@ func (f *backendE2EFixture) request() Request { func (f *backendE2EFixture) supervise(ctx context.Context) <-chan error { f.t.Helper() - if err := waitE2EPortClosed(ctx); err != nil { - f.t.Fatalf("port 36163 is occupied before supervise: %v", err) + if err := waitE2EPortClosed(ctx, f.port); err != nil { + f.t.Fatalf("port %d is occupied before supervise: %v", f.port, err) } - if err := assertE2EPortBindable(); err != nil { - f.t.Fatalf("port 36163 cannot bind before supervise: %v", err) + if err := assertE2EPortBindable(f.port); err != nil { + f.t.Fatalf("port %d cannot bind before supervise: %v", f.port, err) } done := make(chan error, 1) go func() { @@ -572,6 +596,9 @@ func TestBackendE2E_LifecycleSpawnReadyShutdown(t *testing.T) { if running.Details["pid"] == nil || running.Details["logPath"] == nil { t.Fatalf("running details = %#v, want pid/logPath", running.Details) } + if got, want := running.Details["baseUrl"], health.BaseURL(fixture.port); got != want { + t.Fatalf("running baseUrl = %#v, want %q derived from the supervised port", got, want) + } generation := fixture.captureGeneration(t, running) assertE2ETransactionRunning(t, fixture) assertE2EBackendMutexHeld(t, fixture.layout) @@ -735,6 +762,7 @@ func assertE2EDevelopmentUVEnvironment(t *testing.T, fixture *backendE2EFixture) "UV_PROJECT_ENVIRONMENT": filepath.Join(fixture.repo, ".venv"), "AUTO_MAS_SUPERVISED": "1", "AUTO_MAS_RUNTIME_PROTOCOL": "1", + "AUTO_MAS_SUPERVISED_PORT": strconv.Itoa(fixture.port), } for key, want := range wantControlled { if got := record.Environment[key]; got != want { @@ -880,7 +908,7 @@ func TestBackendE2E_FirstCrashRestartSuccess(t *testing.T) { generation1 := fixture.captureGeneration(t, running1) assertE2ETransactionRunning(t, fixture) assertE2EBackendMutexHeld(t, fixture.layout) - triggerE2ECrash(t) + triggerE2ECrash(t, fixture.port) fixture.emitter.waitState(t, protocol.StateRestarting, 1) fixture.config.CrashAfterHealthRequests = 0 fixture.config.Events = e2EOutputEvents("first-restart") @@ -921,14 +949,14 @@ func TestBackendE2E_SecondCrashTerminates(t *testing.T) { done := fixture.supervise(t.Context()) running1 := fixture.emitter.waitState(t, protocol.StateRunning, 1) generation1 := fixture.captureGeneration(t, running1) - triggerE2ECrash(t) + triggerE2ECrash(t, fixture.port) fixture.emitter.waitState(t, protocol.StateRestarting, 1) fixture.config.Events = e2EOutputEvents("second-restart") writeE2EConfig(t, fixture.configPath, fixture.config) fixture.waitRestartTimer(t).Fire() running2 := fixture.emitter.waitState(t, protocol.StateRunning, 2) generation2 := fixture.captureGeneration(t, running2) - triggerE2ECrash(t) + triggerE2ECrash(t, fixture.port) err := <-done assertBackendCode(t, err, protocol.CodeBackendExitedUnexpectedly) if got := countE2EStates(fixture.emitter.statesSnapshot(), protocol.StateRunning); got != 2 { @@ -1045,7 +1073,7 @@ func TestBackendE2E_RuntimeTerminationLeavesNoDescendants(t *testing.T) { t.Fatal("runtime helper exited successfully, want TerminateProcess interruption") } waitE2EHandles(t, runtimeHandles) - if err := waitE2EPortClosed(t.Context()); err != nil { + if err := waitE2EPortClosed(t.Context(), signal.Port); err != nil { t.Fatalf("port after runtime termination: %v", err) } if payload, err := os.ReadFile(signal.BackendState); err != nil { @@ -1094,6 +1122,7 @@ func TestBackendE2E_RuntimeTerminationLeavesNoDescendants(t *testing.T) { RuntimePID: uint32(os.Getpid()), Mode: ModeDevelopment, DevelopmentRepo: signal.Repo, + Port: signal.Port, Emitter: emitter, Control: mailbox, BeforeShutdown: mailbox.BeforeShutdown, @@ -1107,7 +1136,7 @@ func TestBackendE2E_RuntimeTerminationLeavesNoDescendants(t *testing.T) { if err := <-done; err != nil { t.Fatalf("recovery supervisor error = %v", err) } - recovery := &backendE2EFixture{layout: layout, grandchildPID: signal.GrandchildPID} + recovery := &backendE2EFixture{layout: layout, grandchildPID: signal.GrandchildPID, port: signal.Port} recovery.assertResourcesReleased(t) } @@ -1131,6 +1160,7 @@ func runBackendE2ERuntimeHelper(t *testing.T) { } payload, err := json.Marshal(backendE2ERuntimeSignal{ Root: fixture.root, + Port: fixture.port, AppRoot: fixture.appRoot, Repo: fixture.repo, BackendState: fixture.layout.BackendStateFile(), @@ -1164,7 +1194,7 @@ func TestBackendE2E_DescendantHoldingPipeCannotBlockCleanup(t *testing.T) { generation1 := fixture.captureGeneration(t, running1) assertE2ETransactionRunning(t, fixture) crashStarted := time.Now() - triggerE2ECrash(t) + triggerE2ECrash(t, fixture.port) waitE2EFile(t, fixture.uvExecReady) if result, err := windows.WaitForSingleObject(generation1.handles[0], 0); err != nil || result != uint32(windows.WAIT_TIMEOUT) { t.Fatalf("uv root during descendant cleanup = result %d, err %v, want WAIT_TIMEOUT", result, err) @@ -1366,7 +1396,7 @@ func (f *backendE2EFixture) assertResourcesReleased(t *testing.T, generations .. } else if !errors.Is(err, os.ErrNotExist) { t.Fatalf("ReadDir(%q) error = %v", f.layout.StateDir(), err) } - if err := assertE2EPortClosed(); err != nil { + if err := assertE2EPortClosed(f.port); err != nil { t.Fatal(err) } if payload, err := os.ReadFile(f.grandchildPID); err == nil { @@ -1495,18 +1525,22 @@ func assertE2EBackendMutexHeld(t *testing.T, layout *config.Layout) { } } -func assertE2EPortClosed() error { - conn, err := net.DialTimeout("tcp", "127.0.0.1:36163", 200*time.Millisecond) +func e2EPortAddress(port int) string { + return net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) +} + +func assertE2EPortClosed(port int) error { + conn, err := net.DialTimeout("tcp", e2EPortAddress(port), 200*time.Millisecond) if err == nil { - return errors.Join(errors.New("backend port 36163 still accepts connections"), conn.Close()) + return errors.Join(fmt.Errorf("backend port %d still accepts connections", port), conn.Close()) } return nil } -func assertE2EPortBindable() error { - listener, err := net.Listen("tcp4", "127.0.0.1:36163") +func assertE2EPortBindable(port int) error { + listener, err := net.Listen("tcp4", e2EPortAddress(port)) if err != nil { - return fmt.Errorf("bind/listen 127.0.0.1:36163: %w", err) + return fmt.Errorf("bind/listen %s: %w", e2EPortAddress(port), err) } if err := listener.Close(); err != nil { return fmt.Errorf("close bind/listen probe: %w", err) @@ -1514,7 +1548,7 @@ func assertE2EPortBindable() error { return nil } -func waitE2EPortClosed(ctx context.Context) error { +func waitE2EPortClosed(ctx context.Context, port int) error { if ctx == nil { ctx = context.Background() } @@ -1523,14 +1557,14 @@ func waitE2EPortClosed(ctx context.Context) error { ticker := time.NewTicker(20 * time.Millisecond) defer ticker.Stop() for { - if err := assertE2EPortClosed(); err == nil { + if err := assertE2EPortClosed(port); err == nil { return nil } select { case <-ctx.Done(): return ctx.Err() case <-deadline.C: - return errors.New("backend port 36163 remained occupied") + return fmt.Errorf("backend port %d remained occupied", port) case <-ticker.C: } } @@ -1706,12 +1740,12 @@ func e2EExitCode(value any) (int, bool) { } } -func triggerE2ECrash(t *testing.T) { +func triggerE2ECrash(t *testing.T, port int) { t.Helper() ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() for request := 0; request < 20; request++ { - if err := triggerE2EHealthRequest(ctx); err != nil { + if err := triggerE2EHealthRequest(ctx, port); err != nil { // 连接被重置/拒绝表示假后端已越过崩溃阈值;随后状态断言 // 负责证明生命周期确实完成了退出。 return @@ -1720,9 +1754,9 @@ func triggerE2ECrash(t *testing.T) { t.Fatalf("fake backend did not exit after 20 health requests") } -func triggerE2EHealthRequest(ctx context.Context) error { +func triggerE2EHealthRequest(ctx context.Context, port int) error { client := &net.Dialer{Timeout: time.Second} - conn, err := client.DialContext(ctx, "tcp", "127.0.0.1:36163") + conn, err := client.DialContext(ctx, "tcp", e2EPortAddress(port)) if err != nil { return err } diff --git a/testdata/README.md b/testdata/README.md index 172ea26..50dd3e0 100644 --- a/testdata/README.md +++ b/testdata/README.md @@ -16,8 +16,10 @@ pack 和响应中断,同时记录 Depth、分支移动和请求次数;测试 在子进程退出后提供确定性的退出屏障,用于观察 Job 清理窗口; - `fakebackend`:通过 `FAKE_BACKEND_CONFIG=` 配置监听延迟、health 序列、 原始/畸形 health body、HTTP 状态、protocol/version/commit、崩溃退出、close 接受或拒绝、 - stdout/stderr 事件和孙进程。配置采用严格 JSON 解码并拒绝未知字段和越界值。默认监听协议 - 固定端口 `127.0.0.1:36163`;夹具自测显式使用 `127.0.0.1:0`,不争抢生产契约端口。 + stdout/stderr 事件和孙进程。配置采用严格 JSON 解码并拒绝未知字段和越界值。`listenAddress` + 留空时像真后端一样只认 Runtime 注入的 `AUTO_MAS_SUPERVISED_PORT`(增补 1 C12),缺失或非法 + 回退 `127.0.0.1:36163`;E2E 夹具不设 `listenAddress`,健康检查能通过即证明端口来自环境变量。 + 夹具自测显式使用 `127.0.0.1:0`,不争抢任何固定端口。 `readyFile` 写入实际 base URL,`pidFile` 写入后端自身 PID,`grandchildPidFile` 只用于测试 断言和收口;孙进程默认随父进程退出,`leaveGrandchildOnCrash` 仅用于 Job Object 异常回收 测试,并会让孙进程继续持有继承的 stdout/stderr 管道。 diff --git a/testdata/fakebackend/main.go b/testdata/fakebackend/main.go index 87bcb26..4ecbf00 100644 --- a/testdata/fakebackend/main.go +++ b/testdata/fakebackend/main.go @@ -14,6 +14,7 @@ import ( "os/exec" "path/filepath" "strconv" + "strings" "sync" "time" ) @@ -26,7 +27,12 @@ const ( grandchildRole = "grandchild" healthPath = "/api/core/health" closePath = "/api/core/close" - defaultListenAddress = "127.0.0.1:36163" + // supervisedPortEnv 是增补 1 C12 下 Runtime 注入的端口;listenAddress 留空时假后端 + // 像真后端一样只认它,缺失或非法则回退缺省地址。 + supervisedPortEnv = "AUTO_MAS_SUPERVISED_PORT" + defaultListenAddress = "127.0.0.1:36163" + minSupervisedPort = 1024 + maxSupervisedPort = 65535 ) type fakeBackendConfig struct { @@ -245,7 +251,7 @@ func runFakeBackend() int { } address := config.ListenAddress if address == "" { - address = defaultListenAddress + address = listenAddressFromEnvironment(os.Getenv(supervisedPortEnv)) } listener, err := net.Listen("tcp", address) if err != nil { @@ -315,6 +321,16 @@ func runFakeBackend() int { } } +// listenAddressFromEnvironment 按 C12 解释 AUTO_MAS_SUPERVISED_PORT:十进制且落在 +// 合法范围内才采用,否则按缺失处理回退缺省地址——与真后端的回退语义一致。 +func listenAddressFromEnvironment(raw string) string { + port, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || port < minSupervisedPort || port > maxSupervisedPort { + return defaultListenAddress + } + return net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) +} + func loadFakeBackendConfig(path string) (fakeBackendConfig, error) { if path == "" { return fakeBackendConfig{}, nil diff --git a/testdata/fakebackend/main_test.go b/testdata/fakebackend/main_test.go index 983e80d..15e6910 100644 --- a/testdata/fakebackend/main_test.go +++ b/testdata/fakebackend/main_test.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/http/httptest" "os" @@ -562,3 +563,55 @@ func ensureRecordedProcessStopped(t *testing.T, path string) { t.Errorf("wait grandchild PID %d: %v", pid, err) } } + +// TestFakeBackend_ListensOnSupervisedPortEnv 锁定增补 1 C12 的夹具行为:listenAddress +// 留空时从 AUTO_MAS_SUPERVISED_PORT 决定监听端口,非法值回退缺省地址。 +func TestFakeBackend_ListensOnSupervisedPortEnv(t *testing.T) { + root := t.TempDir() + executable := buildFakeBackend(t, root) + probe, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen(:0) error = %v", err) + } + port := probe.Addr().(*net.TCPAddr).Port + if err := probe.Close(); err != nil { + t.Fatalf("close port probe: %v", err) + } + readyFile := filepath.Join(root, "env-ready.txt") + configPath := filepath.Join(root, "env-config.json") + writeConfig(t, configPath, fakeBackendConfig{ReadyFile: readyFile}) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + command := exec.CommandContext(ctx, executable) + command.Env = append(os.Environ(), + fakeBackendConfigEnv+"="+configPath, + supervisedPortEnv+"="+strconv.Itoa(port), + ) + if err := command.Start(); err != nil { + t.Fatalf("start fake backend: %v", err) + } + t.Cleanup(func() { _ = command.Process.Kill(); _, _ = command.Process.Wait() }) + baseURL := strings.TrimSpace(waitForFile(t, readyFile)) + if want := "http://127.0.0.1:" + strconv.Itoa(port); baseURL != want { + t.Fatalf("ready base URL = %q, want %q (listen address must come from %s)", baseURL, want, supervisedPortEnv) + } + response, err := fakeBackendHTTPClient.Post(baseURL+closePath, "application/json", nil) + if err != nil { + t.Fatalf("close request: %v", err) + } + _ = response.Body.Close() + if err := command.Wait(); err != nil { + t.Fatalf("fake backend exit = %v, want 0", err) + } + + for _, invalid := range []string{"", "abc", "70000", "80"} { + t.Run("invalid "+invalid, func(t *testing.T) { + if got := listenAddressFromEnvironment(invalid); got != defaultListenAddress { + t.Fatalf("listenAddressFromEnvironment(%q) = %q, want %q", invalid, got, defaultListenAddress) + } + }) + } + if got, want := listenAddressFromEnvironment("36164"), "127.0.0.1:36164"; got != want { + t.Fatalf("listenAddressFromEnvironment(36164) = %q, want %q", got, want) + } +} From 33c6bc8079af3f36d8c2e896da1d0c47cd6bc4a9 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 23:07:40 +0200 Subject: [PATCH 41/57] =?UTF-8?q?feat(protocol):=20ControlReader=20?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E8=BE=93=E5=85=A5=E6=98=AF=E5=90=A6=E5=B7=B2?= =?UTF-8?q?=E5=88=B0=E8=BE=BE=20EOF=20(T13.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 InputClosed:Run 因 EOF 结束时为 true,因 StopAccepting 或 ctx 取消结束时为 false; Run 的返回值不变,一次性命令的行为不受影响。 Co-Authored-By: Claude Fable 5.1 --- internal/protocol/control.go | 42 ++++++++++------ internal/protocol/control_test.go | 82 +++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 14 deletions(-) diff --git a/internal/protocol/control.go b/internal/protocol/control.go index e89f19e..cb60a1a 100644 --- a/internal/protocol/control.go +++ b/internal/protocol/control.go @@ -68,18 +68,22 @@ type ControlWarningEmitter interface { // ControlReader 读取并分派以换行符分隔的 stdin 控制命令。 type ControlReader struct { // mu 保护一次性运行状态和 commandId 去重账本,同时串行化 prepare、action 与 warning。 - mu sync.Mutex - started bool - stopped bool - input *bufio.Reader - read *controlReadTracker - warnings ControlWarningEmitter - handler ControlHandler - allowed map[ControlKind]struct{} - seen map[string]ControlKind - order [controlLedgerCapacity]string - next int - size int + mu sync.Mutex + started bool + stopped bool + // inputClosed 记录 Run 是否因输入到达 EOF 而结束;被 StopAccepting 或 ctx 终止时 + // 保持 false。backend supervise 据此把宿主断开解释为隐式 shutdown(增补 1 C13), + // 其他命令不读它,Run 的返回值也不因此改变。 + inputClosed bool + input *bufio.Reader + read *controlReadTracker + warnings ControlWarningEmitter + handler ControlHandler + allowed map[ControlKind]struct{} + seen map[string]ControlKind + order [controlLedgerCapacity]string + next int + size int } // NewControlReader 构造带输入与去重上限的 stdin 控制读取器。 @@ -167,6 +171,7 @@ func (r *ControlReader) Run(ctx context.Context) error { return fmt.Errorf("read stdin control: %w", err) } if !hasLine { + r.inputClosed = true r.mu.Unlock() return nil } @@ -191,11 +196,12 @@ func (r *ControlReader) Run(ctx context.Context) error { return err } } - r.mu.Unlock() - if reachedEOF { + r.inputClosed = true + r.mu.Unlock() return nil } + r.mu.Unlock() } } @@ -207,6 +213,14 @@ func (r *ControlReader) StopAccepting() { r.mu.Unlock() } +// InputClosed 报告 Run 是否因输入到达 EOF 而结束。 +// 因 StopAccepting 或 ctx 取消结束时返回 false;Run 之前恒为 false。 +func (r *ControlReader) InputClosed() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.inputClosed +} + func (r *ControlReader) readPhysicalLine() ( line []byte, lineBytes int, diff --git a/internal/protocol/control_test.go b/internal/protocol/control_test.go index a93389e..ff6810f 100644 --- a/internal/protocol/control_test.go +++ b/internal/protocol/control_test.go @@ -1924,3 +1924,85 @@ func assertInvalidControlWarning(t *testing.T, got WarningEvent, wantDetails map t.Fatalf("warning details = %#v, want %#v", got.Details, wantDetails) } } + +// TestControlReader_InputClosedDistinguishesEOFFromStop 锁定增补 1 C13 需要的唯一 +// 协议层事实:Run 因输入 EOF 返回时 InputClosed 为 true,因 StopAccepting 或 ctx 取消 +// 返回时为 false——Run 的返回值本身不变,其他命令的行为因此一个字节都不动。 +func TestControlReader_InputClosedDistinguishesEOFFromStop(t *testing.T) { + t.Run("empty input reaches EOF", func(t *testing.T) { + reader, err := NewControlReader(strings.NewReader(""), &controlContractWarningEmitter{}, &controlContractHandler{}, ControlCancel) + if err != nil { + t.Fatalf("NewControlReader() error = %v", err) + } + if reader.InputClosed() { + t.Fatal("InputClosed() = true before Run") + } + if err := reader.Run(context.Background()); err != nil { + t.Fatalf("Run() error = %v", err) + } + if !reader.InputClosed() { + t.Fatal("InputClosed() = false after EOF, want true") + } + }) + + t.Run("last line without newline reaches EOF", func(t *testing.T) { + handler := &controlContractHandler{} + reader, err := NewControlReader(strings.NewReader(controlTestLine(ControlStatus, controlTestID(1))), &controlContractWarningEmitter{}, handler, ControlStatus) + if err != nil { + t.Fatalf("NewControlReader() error = %v", err) + } + if err := reader.Run(context.Background()); err != nil { + t.Fatalf("Run() error = %v", err) + } + if len(handler.prepared) != 1 { + t.Fatalf("prepared commands = %#v, want the unterminated line dispatched", handler.prepared) + } + if !reader.InputClosed() { + t.Fatal("InputClosed() = false after unterminated EOF, want true") + } + }) + + t.Run("stop accepting is not input closure", func(t *testing.T) { + pipeReader, pipeWriter := io.Pipe() + input := &signalingControlInput{reader: pipeReader, started: make(chan struct{})} + reader, err := NewControlReader(input, &controlContractWarningEmitter{}, &controlContractHandler{}, ControlCancel) + if err != nil { + t.Fatalf("NewControlReader() error = %v", err) + } + runDone := make(chan error, 1) + go func() { runDone <- reader.Run(context.Background()) }() + select { + case <-input.started: + case <-time.After(time.Second): + t.Fatal("timed out waiting for stdin read") + } + reader.StopAccepting() + if err := pipeReader.CloseWithError(errors.New("owner closed stdin")); err != nil { + t.Fatalf("CloseWithError() error = %v", err) + } + if err := waitControlDone(t, runDone); err != nil { + t.Fatalf("Run() error = %v, want nil after stop", err) + } + if reader.InputClosed() { + t.Fatal("InputClosed() = true after StopAccepting, want false") + } + if err := pipeWriter.Close(); err != nil && !errors.Is(err, io.ErrClosedPipe) { + t.Fatalf("close pipe writer: %v", err) + } + }) + + t.Run("context cancellation is not input closure", func(t *testing.T) { + reader, err := NewControlReader(strings.NewReader(""), &controlContractWarningEmitter{}, &controlContractHandler{}, ControlCancel) + if err != nil { + t.Fatalf("NewControlReader() error = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := reader.Run(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want context.Canceled", err) + } + if reader.InputClosed() { + t.Fatal("InputClosed() = true after context cancellation, want false") + } + }) +} From 64a0554af9142f8d7f6260c9e08105c2a48fdea9 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 23:10:15 +0200 Subject: [PATCH 42/57] =?UTF-8?q?feat(cli):=20backend=20supervise=20?= =?UTF-8?q?=E6=8A=8A=20stdin=20EOF=20=E4=B8=8E=E8=AF=BB=E5=8F=96=E5=87=BA?= =?UTF-8?q?=E9=94=99=E8=A7=86=E4=B8=BA=E9=9A=90=E5=BC=8F=20shutdown=20(T13?= =?UTF-8?q?.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hello 之后 reader 因 EOF 结束(InputClosed)或读取出错时,向同一个 mailbox 投一条没有 commandId 的 shutdown,走与显式 shutdown 相同的优雅关闭路径与结局;已有终止命令时 Submit 被拒即幂等。读取出错保留 stderr 诊断;reader 自身 panic 仍走基础设施故障路径。 Co-Authored-By: Claude Fable 5.1 --- internal/cli/backend.go | 31 ++++++- internal/cli/backend_test.go | 159 +++++++++++++++++++++++++++++++++-- 2 files changed, 181 insertions(+), 9 deletions(-) diff --git a/internal/cli/backend.go b/internal/cli/backend.go index bcea840..85d4864 100644 --- a/internal/cli/backend.go +++ b/internal/cli/backend.go @@ -3,6 +3,7 @@ package cli import ( "context" "errors" + "fmt" "os" "path/filepath" "strconv" @@ -307,8 +308,25 @@ func runBackendSuperviseSession( controlDone := make(chan error, 1) go func() { readErr := runControlReaderSafely(readerContext, reader) - if readErr != nil && !isWorkspaceControlContextCancellation(readerContext, readErr) { - control.SetReaderError(readErr) + switch { + case readErr == nil: + // 增补 1 C13:hello 之后 stdin 到达 EOF 即宿主断开,视为隐式 shutdown; + // 被 StopAccepting 或 ctx 停止的 reader 不满足 InputClosed,不会误触发。 + if reader.InputClosed() { + control.SubmitImplicitShutdown() + } + case isWorkspaceControlContextCancellation(readerContext, readErr): + default: + if _, panicked := recoveredControlReaderPanic(readErr); panicked { + // reader 自身崩溃是 Runtime 缺陷,仍走基础设施故障路径并上报。 + control.SetReaderError(readErr) + break + } + // C13:读取出错与 EOF 同样视为宿主断开——走优雅关闭对用户只会更好, + // 硬失败路径反而会把后端 Job 硬杀;错误本身保留在 stderr 诊断里。 + writeDiagnostic(deps.io, fmt.Errorf("stdin control read failed, treating as host disconnect: %w", readErr)) + control.SubmitImplicitShutdown() + readErr = nil } controlDone <- readErr }() @@ -397,6 +415,15 @@ func (c *backendControl) PrepareControl(command protocol.ControlCommand) (protoc }, nil } +// SubmitImplicitShutdown 把宿主断开(stdin EOF 或读取出错)翻译成一条没有 commandId 的 +// shutdown 投进同一个 mailbox(增补 1 C13)。已有终止命令、mailbox 已停止或操作已收口时 +// Submit 返回错误,这正是隐式关闭的幂等语义,因此安全忽略;result 因 commandId 为空 +// 而不回显 controlCommandId。 +func (c *backendControl) SubmitImplicitShutdown() { + // 忽略 ErrControlStopped / ErrControlMailboxClosed / ctx 错误:它们都表示关闭已在路上。 + _ = c.submit(protocol.ControlCommand{Protocol: protocol.Version, Command: protocol.ControlShutdown}) +} + // StopAfterShutdown 保留 cancel-first 后续命令进入 mailbox 的机会;shutdown-first // 仍沿用协议默认的 reader 停止语义。 func (c *backendControl) StopAfterShutdown(protocol.ControlCommand) bool { diff --git a/internal/cli/backend_test.go b/internal/cli/backend_test.go index 81ee7c5..427717e 100644 --- a/internal/cli/backend_test.go +++ b/internal/cli/backend_test.go @@ -243,7 +243,9 @@ func (f backendServiceFunc) Supervise(ctx context.Context, request backend.Reque } func TestBackend_ControlReaderJoinAndReadFailure(t *testing.T) { - t.Run("reader failure joins within bound", func(t *testing.T) { + // 增补 1 C13:stdin 读取出错等价于宿主断开,走隐式 shutdown 而不是 INTERNAL_ERROR, + // 但 Execute 仍必须在有限时间内收口。 + t.Run("reader failure joins within bound and shuts down", func(t *testing.T) { input := &backendReadError{err: errors.New("stdin read failed")} started := make(chan struct{}) var stdout, stderr bytes.Buffer @@ -256,8 +258,15 @@ func TestBackend_ControlReaderJoinAndReadFailure(t *testing.T) { WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time, mirror.Policy) (backendService, error) { return backendServiceFunc(func(ctx context.Context, request backend.Request) error { close(started) - _, err := request.Control.Receive(ctx) - return err + command, err := request.Control.Receive(ctx) + if err != nil { + return err + } + if command.Command != protocol.ControlShutdown { + return errors.New("unexpected control command") + } + request.BeforeShutdown(command.CommandID) + return nil }), nil }), ) @@ -269,15 +278,15 @@ func TestBackend_ControlReaderJoinAndReadFailure(t *testing.T) { } select { case code := <-done: - if code != protocol.ExitCodePreconditionFailed { - t.Fatalf("Execute() exit code = %d, want %d; stderr=%q", code, protocol.ExitCodePreconditionFailed, stderr.String()) + if code != protocol.ExitCodeSuccess { + t.Fatalf("Execute() exit code = %d, want 0; stderr=%q", code, stderr.String()) } case <-time.After(time.Second): t.Fatal("Execute() did not join failed control reader") } events := parseNDJSON(t, stdout.String()) - if got := eventString(events[len(events)-1], "code"); got != string(protocol.CodeInternalError) { - t.Fatalf("result code = %q, want INTERNAL_ERROR", got) + if got := eventString(events[len(events)-1], "status"); got != string(protocol.StateStopped) { + t.Fatalf("result status = %q, want stopped", got) } assertBackendCapabilities(t, events[0]) }) @@ -527,3 +536,139 @@ func TestBackendSupervise_PortArgument(t *testing.T) { }) } } + +// implicitShutdownService 是 C13 用例共用的假监督器:只接收一条控制命令并把它交给断言, +// 收到 shutdown 时像真实监督器一样调用 BeforeShutdown 后正常返回。 +func implicitShutdownService(received chan<- protocol.ControlCommand) backendService { + return backendServiceFunc(func(ctx context.Context, request backend.Request) error { + waitCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + command, err := request.Control.Receive(waitCtx) + if err != nil { + return err + } + received <- command + if command.Command != protocol.ControlShutdown { + return errors.New("unexpected control command") + } + request.BeforeShutdown(command.CommandID) + return nil + }) +} + +// TestBackendSupervise_StdinEOFSubmitsImplicitShutdown 锁定增补 1 C13 的主路径: +// hello 之后 stdin 到达 EOF,监督器从同一个 mailbox 收到一条没有 commandId 的 shutdown, +// 结局与显式 shutdown 相同(result.status=stopped、退出码 0),只是不回显 controlCommandId。 +func TestBackendSupervise_StdinEOFSubmitsImplicitShutdown(t *testing.T) { + received := make(chan protocol.ControlCommand, 1) + var stdout, stderr bytes.Buffer + code := Execute( + context.Background(), + []string{"--app-root", t.TempDir(), "--output", "ndjson", "backend", "supervise", "--mode", "managed"}, + IO{In: strings.NewReader(""), Out: &stdout, Err: &stderr}, + WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time, mirror.Policy) (backendService, error) { + return implicitShutdownService(received), nil + }), + ) + if code != protocol.ExitCodeSuccess { + t.Fatalf("Execute() exit code = %d, want 0; stderr=%q", code, stderr.String()) + } + select { + case command := <-received: + if command.Command != protocol.ControlShutdown || command.CommandID != "" { + t.Fatalf("received command = %#v, want implicit shutdown without commandId", command) + } + default: + t.Fatal("backend service received no control command") + } + events := parseNDJSON(t, stdout.String()) + result := events[len(events)-1] + if got := eventString(result, "status"); got != string(protocol.StateStopped) { + t.Fatalf("result status = %q, want stopped", got) + } + if details, ok := result.object["details"].(map[string]any); ok { + if _, exists := details["controlCommandId"]; exists { + t.Fatalf("result details = %#v, want no controlCommandId for implicit shutdown", details) + } + } +} + +// TestBackendSupervise_StdinReadErrorSubmitsImplicitShutdown 证明读取出错与 EOF 同样 +// 视为宿主断开:监督器收到隐式 shutdown、退出码 0,且 stderr 保留一条诊断。 +func TestBackendSupervise_StdinReadErrorSubmitsImplicitShutdown(t *testing.T) { + received := make(chan protocol.ControlCommand, 1) + var stdout, stderr bytes.Buffer + code := Execute( + context.Background(), + []string{"--app-root", t.TempDir(), "--output", "ndjson", "backend", "supervise", "--mode", "managed"}, + IO{In: &backendReadError{err: errors.New("stdin read failed")}, Out: &stdout, Err: &stderr}, + WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time, mirror.Policy) (backendService, error) { + return implicitShutdownService(received), nil + }), + ) + if code != protocol.ExitCodeSuccess { + t.Fatalf("Execute() exit code = %d, want 0; stderr=%q", code, stderr.String()) + } + select { + case command := <-received: + if command.Command != protocol.ControlShutdown || command.CommandID != "" { + t.Fatalf("received command = %#v, want implicit shutdown without commandId", command) + } + default: + t.Fatal("backend service received no control command") + } + if !strings.Contains(stderr.String(), "stdin read failed") { + t.Fatalf("stderr = %q, want a diagnostic naming the read failure", stderr.String()) + } + events := parseNDJSON(t, stdout.String()) + if got := eventString(events[len(events)-1], "status"); got != string(protocol.StateStopped) { + t.Fatalf("result status = %q, want stopped", got) + } +} + +// TestBackendSupervise_ExplicitShutdownThenEOFIsIdempotent 证明显式 shutdown 之后 +// 再到达的 EOF 不产生第二次关闭:监督器只收到那一条带 commandId 的 shutdown, +// 后续 Receive 立即得到「已停止」,result 回显的是显式那条的 commandId。 +func TestBackendSupervise_ExplicitShutdownThenEOFIsIdempotent(t *testing.T) { + const commandID = "01ARZ3NDEKTSV4RRFFQ69G5FAV" + var stdout, stderr bytes.Buffer + var got []protocol.ControlCommand + var secondErr error + code := Execute( + context.Background(), + []string{"--app-root", t.TempDir(), "--output", "ndjson", "backend", "supervise", "--mode", "managed"}, + IO{In: strings.NewReader(`{"protocol":1,"command":"shutdown","commandId":"` + commandID + `"}` + "\n"), Out: &stdout, Err: &stderr}, + WithBackendFactory(func(context.Context, *config.Layout, io.Writer, func() time.Time, mirror.Policy) (backendService, error) { + return backendServiceFunc(func(ctx context.Context, request backend.Request) error { + command, err := request.Control.Receive(ctx) + if err != nil { + return err + } + got = append(got, command) + waitCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) + defer cancel() + second, err := request.Control.Receive(waitCtx) + if err == nil { + got = append(got, second) + } + secondErr = err + request.BeforeShutdown(command.CommandID) + return nil + }), nil + }), + ) + if code != protocol.ExitCodeSuccess { + t.Fatalf("Execute() exit code = %d, want 0; stderr=%q", code, stderr.String()) + } + if len(got) != 1 || got[0].Command != protocol.ControlShutdown || got[0].CommandID != commandID { + t.Fatalf("received commands = %#v, want exactly the explicit shutdown", got) + } + if !errors.Is(secondErr, backend.ErrControlStopped) { + t.Fatalf("second Receive error = %v, want ErrControlStopped (no implicit shutdown after explicit one)", secondErr) + } + events := parseNDJSON(t, stdout.String()) + details, ok := events[len(events)-1].object["details"].(map[string]any) + if !ok || details["controlCommandId"] != commandID { + t.Fatalf("result details = %#v, want controlCommandId=%q", events[len(events)-1].object["details"], commandID) + } +} From 41c51d32272367143a9209ae1fecc860389923a6 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 23:11:16 +0200 Subject: [PATCH 43/57] =?UTF-8?q?test:=20=E7=9C=9F=E5=AE=9E=20exe=20?= =?UTF-8?q?=E9=BB=91=E7=9B=92=E8=AF=81=E6=98=8E=20stdin=20EOF=20=E4=BC=98?= =?UTF-8?q?=E9=9B=85=E5=85=B3=E9=97=AD=E4=B8=94=20stdout=20=E5=B7=B2?= =?UTF-8?q?=E6=96=AD=E4=BB=8D=E8=83=BD=E9=80=80=E5=87=BA=20(T13.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用 go build 出的 auto-mas-runtime.exe 以 development 模式监督夹具后端:关闭 stdin 后 Runtime 走 stopping_backend → stopped、result.status=stopped 且无 controlCommandId、退出码 0、 无 BACKEND_FORCE_TERMINATED,后端 PID 退出、端口 / Mutex / 事务无残留;stdout 读端先关再 关 stdin 时 Runtime 仍在有限时间内退出并收口。 Co-Authored-By: Claude Fable 5.1 --- internal/backend/e2e_stdin_windows_test.go | 265 +++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 internal/backend/e2e_stdin_windows_test.go diff --git a/internal/backend/e2e_stdin_windows_test.go b/internal/backend/e2e_stdin_windows_test.go new file mode 100644 index 0000000..e86401c --- /dev/null +++ b/internal/backend/e2e_stdin_windows_test.go @@ -0,0 +1,265 @@ +//go:build windows + +package backend + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "sync" + "testing" + "time" + + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" +) + +// 本文件用真实的 auto-mas-runtime.exe 黑盒验证增补 1 C13:既有 E2E 直接驱动 +// ManagedSupervisor 并自己喂 mailbox,绕过了 CLI 的 stdin reader goroutine, +// 而「宿主断开即关闭」的实现恰恰在那里。 + +type backendE2EProcessEvents struct { + mu sync.Mutex + events []map[string]any + closed bool + wake chan struct{} +} + +func newBackendE2EProcessEvents() *backendE2EProcessEvents { + return &backendE2EProcessEvents{wake: make(chan struct{})} +} + +func (e *backendE2EProcessEvents) append(event map[string]any) { + e.mu.Lock() + e.events = append(e.events, event) + e.signalLocked() + e.mu.Unlock() +} + +func (e *backendE2EProcessEvents) close() { + e.mu.Lock() + e.closed = true + e.signalLocked() + e.mu.Unlock() +} + +func (e *backendE2EProcessEvents) signalLocked() { + close(e.wake) + e.wake = make(chan struct{}) +} + +func (e *backendE2EProcessEvents) snapshot() []map[string]any { + e.mu.Lock() + defer e.mu.Unlock() + return append([]map[string]any(nil), e.events...) +} + +// waitFor 等到某条事件满足谓词;stdout 已关闭且仍未出现时失败。 +func (e *backendE2EProcessEvents) waitFor(t *testing.T, what string, predicate func(map[string]any) bool) map[string]any { + t.Helper() + deadline := time.NewTimer(30 * time.Second) + defer deadline.Stop() + for { + e.mu.Lock() + for _, event := range e.events { + if predicate(event) { + e.mu.Unlock() + return event + } + } + closed := e.closed + wake := e.wake + e.mu.Unlock() + if closed { + t.Fatalf("stdout closed before %s; events=%#v", what, e.snapshot()) + } + select { + case <-wake: + case <-deadline.C: + t.Fatalf("timed out waiting for %s; events=%#v", what, e.snapshot()) + } + } +} + +func e2EEventIs(eventType string, status protocol.StateStatus) func(map[string]any) bool { + return func(event map[string]any) bool { + return event["type"] == eventType && event["status"] == string(status) + } +} + +// backendE2ERuntimeProcess 是一次真实 exe 的 backend supervise 会话。 +type backendE2ERuntimeProcess struct { + command *exec.Cmd + stdin io.WriteCloser + stdoutRead *os.File + stderr bytes.Buffer + events *backendE2EProcessEvents + waitErr chan error +} + +// startBackendE2ERuntime 构建并启动真实 Runtime,以 development 模式监督夹具后端。 +// stdout 用测试自建的管道,读端由测试持有,因此可以在中途关闭以模拟宿主管道失效。 +func startBackendE2ERuntime(t *testing.T, fixture *backendE2EFixture) *backendE2ERuntimeProcess { + t.Helper() + executable := buildE2EFixture(t, filepath.Join("..", "..", "cmd", "auto-mas-runtime"), fixture.root, "auto-mas-runtime.exe") + if err := waitE2EPortClosed(t.Context(), fixture.port); err != nil { + t.Fatalf("port %d is occupied before supervise: %v", fixture.port, err) + } + command := exec.Command(executable, + "--app-root", fixture.appRoot, + "--output", "ndjson", + "backend", "supervise", + "--mode", "development", + "--repo", fixture.repo, + "--port", strconv.Itoa(fixture.port), + ) + stdin, err := command.StdinPipe() + if err != nil { + t.Fatalf("StdinPipe() error = %v", err) + } + stdoutRead, stdoutWrite, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() error = %v", err) + } + command.Stdout = stdoutWrite + process := &backendE2ERuntimeProcess{ + command: command, + stdin: stdin, + stdoutRead: stdoutRead, + events: newBackendE2EProcessEvents(), + waitErr: make(chan error, 1), + } + command.Stderr = &process.stderr + if err := command.Start(); err != nil { + _ = stdoutWrite.Close() + _ = stdoutRead.Close() + t.Fatalf("start runtime: %v", err) + } + // 子进程已持有写端副本,父进程这一份必须立刻关闭,否则读端永远等不到 EOF。 + if err := stdoutWrite.Close(); err != nil { + t.Fatalf("close stdout write end: %v", err) + } + t.Cleanup(func() { + select { + case <-process.waitErr: + default: + if command.Process != nil { + _ = command.Process.Kill() + } + <-process.waitErr + } + _ = stdoutRead.Close() + }) + go func() { + scanner := bufio.NewScanner(stdoutRead) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for scanner.Scan() { + var event map[string]any + if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { + process.events.append(map[string]any{"type": "unparseable", "raw": scanner.Text(), "error": err.Error()}) + continue + } + process.events.append(event) + } + process.events.close() + }() + go func() { process.waitErr <- command.Wait() }() + return process +} + +// waitExit 等待 Runtime 退出并返回退出码;超时即失败——C13 的核心断言正是「会退出」。 +func (p *backendE2ERuntimeProcess) waitExit(t *testing.T, timeout time.Duration) int { + t.Helper() + select { + case err := <-p.waitErr: + // 结果放回,供 Cleanup 判断进程已经收口。 + p.waitErr <- err + var exitErr *exec.ExitError + switch { + case err == nil: + return 0 + case errors.As(err, &exitErr): + return exitErr.ExitCode() + default: + t.Fatalf("Wait() error = %v", err) + return -1 + } + case <-time.After(timeout): + t.Fatalf("runtime did not exit within %s after stdin EOF; events=%#v; stderr=%q", timeout, p.events.snapshot(), p.stderr.String()) + return -1 + } +} + +func (p *backendE2ERuntimeProcess) closeStdin(t *testing.T) { + t.Helper() + if err := p.stdin.Close(); err != nil { + t.Fatalf("close runtime stdin: %v", err) + } +} + +// TestBackendE2E_StdinEOFShutsDownGracefully 是增补 1 C13 的主用例:真实 exe 到达 +// running 后关闭 stdin,Runtime 必须像收到 shutdown 一样优雅关闭后端并以 0 退出, +// result.status=stopped 且不回显 controlCommandId,端口、Mutex 与事务无残留。 +func TestBackendE2E_StdinEOFShutsDownGracefully(t *testing.T) { + fixture := newBackendE2EFixture(t, backendE2EConfig{Events: e2EOutputEvents("eof")}) + process := startBackendE2ERuntime(t, fixture) + running := process.events.waitFor(t, "running state", e2EEventIs("state", protocol.StateRunning)) + details, _ := running["details"].(map[string]any) + if got, want := details["baseUrl"], "http://127.0.0.1:"+strconv.Itoa(fixture.port); got != want { + t.Fatalf("running baseUrl = %#v, want %q", got, want) + } + pythonPID := waitE2EPIDFile(t, fixture.config.PIDFile) + + process.closeStdin(t) + + if code := process.waitExit(t, 30*time.Second); code != 0 { + t.Fatalf("runtime exit code = %d, want 0; stderr=%q; events=%#v", code, process.stderr.String(), process.events.snapshot()) + } + process.events.waitFor(t, "stopping_backend state", e2EEventIs("state", protocol.StateStoppingBackend)) + process.events.waitFor(t, "stopped state", e2EEventIs("state", protocol.StateStopped)) + result := process.events.waitFor(t, "result", func(event map[string]any) bool { return event["type"] == "result" }) + if got := result["status"]; got != string(protocol.StateStopped) { + t.Fatalf("result status = %#v, want stopped; result=%#v", got, result) + } + if resultDetails, ok := result["details"].(map[string]any); ok { + if _, exists := resultDetails["controlCommandId"]; exists { + t.Fatalf("result details = %#v, want no controlCommandId for an implicit shutdown", resultDetails) + } + } + for _, event := range process.events.snapshot() { + if event["type"] == "warning" && event["code"] == string(protocol.CodeBackendForceTerminated) { + t.Fatalf("implicit shutdown emitted %s: %#v", protocol.CodeBackendForceTerminated, event) + } + if event["type"] == "unparseable" { + t.Fatalf("stdout carried a non-NDJSON line: %#v", event) + } + } + waitE2EPIDExit(t, pythonPID) + fixture.assertResourcesReleased(t) +} + +// TestBackendE2E_StdinEOFWithBrokenStdoutStillExits 模拟宿主崩溃的真实形态:stdout 管道 +// 先失效、随后 stdin EOF。Runtime 写不出任何事件,也必须在有限时间内退出并收口后端进程树、 +// 端口、Mutex 与事务;退出码不作要求。 +func TestBackendE2E_StdinEOFWithBrokenStdoutStillExits(t *testing.T) { + fixture := newBackendE2EFixture(t, backendE2EConfig{Events: e2EOutputEvents("broken")}) + process := startBackendE2ERuntime(t, fixture) + process.events.waitFor(t, "running state", e2EEventIs("state", protocol.StateRunning)) + pythonPID := waitE2EPIDFile(t, fixture.config.PIDFile) + + // 先断 stdout(宿主那端的读端没了),再断 stdin。 + if err := process.stdoutRead.Close(); err != nil { + t.Fatalf("close stdout read end: %v", err) + } + process.closeStdin(t) + + code := process.waitExit(t, 30*time.Second) + t.Logf("runtime exit code with broken stdout = %d; stderr=%q", code, process.stderr.String()) + waitE2EPIDExit(t, pythonPID) + fixture.assertResourcesReleased(t) +} From 6f7e5fc00ad6870d98f6cd283c6f87b3574e8dae Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 23:15:55 +0200 Subject: [PATCH 44/57] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20T13.7=20?= =?UTF-8?q?=E5=8F=97=E7=9B=91=E7=9D=A3=E7=AB=AF=E5=8F=A3=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E4=B8=8E=20T13.8=20=E5=AE=BF=E4=B8=BB=E6=96=AD=E5=BC=80?= =?UTF-8?q?=E5=8D=B3=E5=85=B3=E9=97=AD=E5=AE=8C=E6=88=90=E6=83=85=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 2 +- doc/current/README.md | 2 +- ...\273\273\345\212\241\346\213\206\345\210\206.md" | 13 +++++++++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 66e96b1..adf306d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Vue/Electron/Python 的改写、CI/CD 发布流程本身。 | M10 工程可维护性收敛 | T10.1 文档信息架构已完成;后续阶段见维护设计 | | M11 跨平台适配(Linux/macOS) | 规划中(决策 D7,2026-08-04 立项);仅 T11.1 设计任务可执行 | | M12 遥测与错误观测(Sentry-only) | T12.1、T12.2、T12.4、T12.5、T12.7 已完成;T12.6 本地发布配置已提交,待 Repository secret 与授权后的远端验收;T12.3/Umami 已取消 | -| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;T13.1(后端工作目录)、T13.2(Job 允许显式脱离)、T13.3(关闭超时参数化,含优雅关闭误报修复)、T13.4(锁副本改写参与包索引镜像轮换,显式 package-index 排最前)**已完成**;T13.5 **已完成**(注入面 `020b3b9`:`AUTO_MAS_UV_CACHE_DIR` / `AUTO_MAS_UV_PYTHON_INSTALL_DIR` / `AUTO_MAS_MIRROR_PACKAGE_INDEX` / `AUTO_MAS_MIRROR_PYTHON`;池目录重新分类按设计关闭——Runtime 的 repair/cleanup 只处理自己的目录,池 venv 失效后由后端判定重建);T13.6 ⏸ 未开始;2026-09-02 真机联调后按增补 1 **C12 / C13** 立项 T13.7(`backend supervise --port`,缺省 managed 36163 / development 36164,注入 `AUTO_MAS_SUPERVISED_PORT`,健康/关闭地址与 `baseUrl` 由它派生,E2E 改用空闲端口)与 T13.8(`backend supervise` 的 stdin EOF 视为隐式 shutdown,宿主崩溃不再留孤儿)🚧 进行中 | +| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;T13.1(后端工作目录)、T13.2(Job 允许显式脱离)、T13.3(关闭超时参数化,含优雅关闭误报修复)、T13.4(锁副本改写参与包索引镜像轮换,显式 package-index 排最前)**已完成**;T13.5 **已完成**(注入面 `020b3b9`:`AUTO_MAS_UV_CACHE_DIR` / `AUTO_MAS_UV_PYTHON_INSTALL_DIR` / `AUTO_MAS_MIRROR_PACKAGE_INDEX` / `AUTO_MAS_MIRROR_PYTHON`;池目录重新分类按设计关闭——Runtime 的 repair/cleanup 只处理自己的目录,池 venv 失效后由后端判定重建);T13.6 ⏸ 未开始;2026-09-02 真机联调后按增补 1 **C12 / C13** 完成 T13.7(`f7d5edb`:`backend supervise --port`,缺省 managed 36163 / development 36164,注入 `AUTO_MAS_SUPERVISED_PORT`,健康/关闭地址与 `baseUrl` 由 `internal/health` 派生,E2E 改用空闲端口)与 T13.8(`41c51d3`:`backend supervise` 的 stdin EOF / 读取出错视为隐式 shutdown,真实 exe 黑盒 E2E 锁定,宿主崩溃不再留孤儿)**已完成** | 代码现状: diff --git a/doc/current/README.md b/doc/current/README.md index 6898bcd..a1a8e45 100644 --- a/doc/current/README.md +++ b/doc/current/README.md @@ -21,7 +21,7 @@ M7 GitHub CI/CD 发布均已完成;设计和审查记录分别归档到 三项均已完成,待 T13.4~T13.6 收口后一并归档; - `M13/` 另有 [T13.7 受监督端口由 Runtime 注入](./M13/设计-T13.7-受监督端口由-Runtime-注入.md) 与 [T13.8 宿主断开即关闭](./M13/设计-T13.8-宿主断开即关闭.md)(2026-09-02 真机联调后立项, - 设计与计划合并为一份)。 + 设计与计划合并为一份,两项同日完成,待 M13 收口后一并归档)。 任务完成后的处理规则: diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index c712774..2e4f132 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -1015,14 +1015,22 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 - 依赖:T13.4;AUTO-MAS 发布 CI 侧按发布版本预生成缓存 - 内容:让 `cleanup` 区分「随包预置」与「运行期产生」的 uv 缓存,或提供重新播种入口——现在 `internal/cleanup/cleanup.go:280` 无条件删除 `runtime/cache/uv` 且没有补种路径,清理一次就得重新联网。这是纯加速项:受限网络地区的首装可达性已由 T13.4 解决,Lite 与 Full 在这件事上没有区别。 - 验收:删除预置缓存后仍能通过 T13.4 的轮换正常装上;预置缓存存在时不被无条件删除;不改变 T13.4 的任何路径与错误映射。 -- [ ] **T13.7 受监督端口由 Runtime 注入**(M)🚧 2026-09-02 立项 +- [x] **T13.7 受监督端口由 Runtime 注入**(M) ✅ 2026-09-02 `f7d5edb`(`5c21fb4` uv → `3d1ff06` health → `c09adc0` backend → `77bb0f2` cli → `f7d5edb` 假后端与 E2E) - 依赖:M6;契约 [增补 1 C12](./契约补充-v1-增补1.md#c12受监督端口由-runtime-注入)(同日修订 C1 与 C7 结论第 1 条);成对 TODO-PY-8(后端改读 `AUTO_MAS_SUPERVISED_PORT`,缺失回退 36163) - 内容:`backend supervise` 新增可选参数 `--port `(整数,`1024`~`65535`),缺省按模式 managed `36163` / development `36164`;非整数或越界映射 `INVALID_ARGUMENT`(`details.field = "port"`)并在 `backendFactory` 之前拒绝。Runtime 启动后端时注入 `AUTO_MAS_SUPERVISED_PORT=`,该键并入受控监督环境键集合(宿主同名变量被清除、`RunOptions.Environment` 覆盖不了、大小写变体规范化——照 T13.5 四个键的做法)。`internal/health/checker.go` 的 `HealthURL`、`internal/backend/control.go` 的 `backendCloseURL` 与 `baseUrl`、`internal/backend/supervisor.go` 的 `baseUrl` 全部改为由该端口派生;协议**不新增字段**。`testdata/fakebackend` 改为读 `AUTO_MAS_SUPERVISED_PORT` 决定监听地址(缺省仍 36163);E2E 改用 `net.Listen(":0")` 探出的空闲端口,不再依赖 36163 空闲(本机 36163 被用户正式版占用)。 - 验收:表驱动覆盖 `--port` 缺省(managed 36163 / development 36164)、边界 `1024` / `65535`、越界(`1023` / `65536` / `0` / 负数)与非整数(拒绝时 factory 零调用、`details.field = "port"`);`internal/uv` 单测证明 `AUTO_MAS_SUPERVISED_PORT` 注入且受控(宿主与选项同名变体被清除);`internal/health` 单测证明请求 URL 随端口变化;`internal/backend` 单测证明 managed 与 development 的 `baseUrl` 与关闭地址随端口派生且单次重启复用同一端口;E2E 在空闲端口上跑通并由假后端证明它是从环境变量读到端口的;`grep 36163` 只剩缺省常量与文档。 -- [ ] **T13.8 宿主断开即关闭**(M)🚧 2026-09-02 立项 + - 证据:设计与计划见 `doc/current/M13/设计-T13.7-受监督端口由-Runtime-注入.md`。单一真值来源在 `internal/health`:`DefaultPort` / `BaseURL(port)` / `HealthURLForPort(port)` / `ValidPort`,`HealthURL` 保留为缺省端口的派生值并由单测锁定相等;backend 的 `baseUrl` 与 `/api/core/close` 地址都用 `health.BaseURL` 拼出,关闭器改为按端口构造的 `loopbackHTTPCloser`。端口解析点唯一:CLI `parseBackendPort` 在 `--mode` 之后按 `Flags().Changed("port")` 区分「未给」与「给了空串」,未给按模式取缺省;`internal/backend` 的 `resolveSupervisedPort` 再做一次(`Request.Port` 零值按模式取缺省、越界 `INVALID_ARGUMENT` 且不打开任何资源),写回 `Request` 后首启与单次自动重启同值。`internal/uv` 的 `ManagedOptions.Port` 非零时注入十进制 `AUTO_MAS_SUPERVISED_PORT` 并并入 `canonicalSupervisionEnvironmentKey` 名单(零值不注入——通用启动器不替调用方决定端口,backend 由单测保证任何模式都传非零值)。 + 测试:`TestManaged_InjectsSupervisedPort` / `TestManaged_OmitsSupervisedPortWhenUnset` / `TestManaged_RejectsInvalidSupervisedPort`(uv);`TestHealth_RequestURLFollowsExpectationPort` / `TestHealth_RejectsOutOfRangePort`(health);`TestBackend_PortDefaultsByModeAndDerivesAddresses`(managed/development × 缺省/显式,断言 `ManagedOptions.Port`、`Expectation.Port`、`baseUrl` 三者同源)/ `TestBackend_RestartReusesSupervisedPort` / `TestBackend_CloseRequestTargetsSupervisedPort`(`httptest` 在 `127.0.0.1:0` 上证明 POST 打到派生端口且 503 仍报错)/ `TestBackend_RejectsOutOfRangePort`(backend);`TestBackendSupervise_PortArgument`(cli:managed/development 缺省、1024/65535/36170 接受,1023/65536/0/-1/abc/1.5/空串拒绝且 factory 零调用、`details.field=port`);`TestFakeBackend_ListensOnSupervisedPortEnv`(假后端)。E2E:夹具 `pickE2EFreePort` 用 `net.Listen("tcp4","127.0.0.1:0")` 探空闲端口,**不再设置 `listenAddress`**,假后端只能从 `AUTO_MAS_SUPERVISED_PORT` 读到端口——健康检查能通过即证明注入穿过 uv 到达真实后端进程;`assertE2EDevelopmentUVEnvironment` 同时断言 uv 收到的 `AUTO_MAS_SUPERVISED_PORT` 等于夹具端口,`running.baseUrl` 等于 `health.BaseURL(port)`;端口辅助函数改为带端口参数,跨进程 helper 的 signal 增加 `port`,串行化 Mutex 改名 `Local\AUTO-MAS-RUNTIME-M6-E2E-SERIAL`。`grep -rn 36163 --include=*.go` 只剩 `health.DefaultPort` / `HealthURL` 常量、假后端 `defaultListenAddress`、cli 缺省断言与注释。 + 验证:gofmt -l(无输出)/ `go vet ./...` / `go build -buildvcs=false ./...` / `go test ./... -count=1` / `git diff --check` 各 exit 0;`go test ./testdata/fakebackend ./testdata/fakeuv -count=1` exit 0;`go test ./internal/protocol -count=100` exit 0;`go test ./internal/cli -run 'StdinEOF|StdinReadError|ThenEOF|ControlReaderJoin|PortArgument' -count=20` exit 0;GCC 目录前置 PATH 后 `go test -race ./... -count=1` exit 0(全部 19 个含测试的包 ok)。本机 36163 全程被用户正式版 AUTO-MAS 占用,E2E 在此前提下全绿。 + **缺口**:AUTO-MAS 侧 TODO-PY-8(后端改读 `AUTO_MAS_SUPERVISED_PORT`)由另一位代理同步实现,跨仓联合验收(开发版 36164 与正式版 36163 并存)待主线程真机复跑。 +- [x] **T13.8 宿主断开即关闭**(M) ✅ 2026-09-02 `41c51d3`(`33c6bc8` protocol → `64a0554` cli → `41c51d3` 真实 exe E2E) - 依赖:M6;契约 [增补 1 C13](./契约补充-v1-增补1.md#c13宿主断开即关闭);AUTO-MAS 侧无改动 - 内容:仅 `backend supervise`:`hello` 发出之后 stdin 到达 EOF 或读取出错视为**隐式 `shutdown`**,走与 stdin `shutdown` 控制命令完全相同的优雅关闭路径与结局(`stopping_backend` → `POST /api/core/close` → 关闭预算 → Job 兜底 → `stopped`;`result.status = "stopped"`、退出码 0;无 `controlCommandId`)。现状 `internal/protocol/control.go:196-198` 读到 EOF 只 `return nil`、`internal/cli/backend.go:262-267` 只记日志,Electron 崩溃后 Runtime 与后端继续占着端口和 `backend` Mutex,下次启动得到 `BACKEND_ALREADY_RUNNING`。写 stdout 若已失败(宿主管道断了)要能容错——不 panic、不阻塞,仍完成进程树回收与 Mutex / 事务收口后退出。其他命令(bootstrap / dependencies / repair / workspace 等)的 stdin EOF 行为**不变**。 - 验收:E2E(真实 exe 黑盒)——启动 `backend supervise` 到 `running`,关闭 stdin,断言后端被优雅关闭(假后端收到 close 自行退出、无 `BACKEND_FORCE_TERMINATED`)、事件序列 `stopping_backend` → `stopped`、`result.status = "stopped"` 且无 `controlCommandId`、退出码 0、端口 / Mutex / 事务无残留;补一条「stdout 已断也能退出」(stdout 接到已关闭的管道后再关 stdin,Runtime 有限时间内退出、进程树与 Mutex 无残留);已接受 shutdown 后再 EOF 不产生第二次关闭;一次性命令的 EOF 行为有既有测试锁定无回退;涉及并发,`go test -race` 通过。 + - 证据:设计与计划见 `doc/current/M13/设计-T13.8-宿主断开即关闭.md`。协议层只加一个只读方法 `ControlReader.InputClosed()`:`Run` 因 EOF(含最后一行无换行)结束时为 true,因 `StopAccepting` 或 ctx 取消结束时为 false,`Run` 的返回值不变,因此 workspace / bootstrap 等共用 reader 的一次性命令零影响(`TestControlReader_InputClosedDistinguishesEOFFromStop` 四个子用例)。CLI 层 `runBackendSuperviseSession` 的 reader goroutine 在 `Run` 返回后分三路:ctx 已取消 → 不动;返回 nil 且 `InputClosed()` → `backendControl.SubmitImplicitShutdown()`;返回非取消错误 → reader 自身 panic 仍走 `SetReaderError` 的基础设施故障路径并上报(`TestBackendSupervise_ControlReaderPanicReportsSentry` 不变),其余读取错误按 C13 同样视为宿主断开:写一条 stderr 诊断后投隐式 shutdown,并把 readErr 清零以免收口时被当成 INTERNAL_ERROR。隐式 shutdown 就是向同一个 mailbox `Submit` 一条 `{Protocol:1, Command:shutdown, CommandID:""}`:first-wins latch 与 `ErrControlStopped` 由既有 mailbox 语义给出幂等,不新增状态;`finishControlShutdown` 本就只在 commandID 非空时写 `controlCommandId`。 + 测试:`TestBackendSupervise_StdinEOFSubmitsImplicitShutdown`(监督器收到 `CommandID==""` 的 shutdown,`result.status=stopped`、无 `controlCommandId`、exit 0)、`TestBackendSupervise_StdinReadErrorSubmitsImplicitShutdown`(读错误同样得到隐式 shutdown、exit 0、stderr 含 `stdin read failed`)、`TestBackendSupervise_ExplicitShutdownThenEOFIsIdempotent`(只收到显式那条、第二次 `Receive` 立即 `ErrControlStopped`、result 回显显式 commandId);既有 `TestBackend_ControlReaderJoinAndReadFailure/reader failure joins within bound` 的期望从 INTERNAL_ERROR 改为「有限时间内收口并以 stopped 退出」(契约变更随本任务同步)。E2E 新增 `internal/backend/e2e_stdin_windows_test.go`,用 `go build` 出真实 `auto-mas-runtime.exe` 黑盒驱动(既有 E2E 直接调 `ManagedSupervisor` 绕过了 CLI reader,修复点恰在那里):`TestBackendE2E_StdinEOFShutsDownGracefully` 到 `running` 后关 stdin,断言 exit 0、`stopping_backend` → `stopped`、`result.status=stopped` 且无 `controlCommandId`、无 `BACKEND_FORCE_TERMINATED`、stdout 无非 NDJSON 行、后端 PID 退出、端口 / Mutex / 事务无残留;`TestBackendE2E_StdinEOFWithBrokenStdoutStillExits` 先关自建 stdout 管道读端再关 stdin,Runtime 2.9 秒内退出(exit 20,stderr `write /dev/stdout: The pipe is being closed`),后端 PID 退出、资源无残留。红灯证据:把 `internal/cli/backend.go` 换回 `33c6bc8` 版本重跑主用例,Runtime 在 `hello`/`running` 之后 30 秒不退出(`runtime did not exit within 30s after stdin EOF`)。 + 验证:gofmt -l(无输出)/ `go vet ./...` / `go build -buildvcs=false ./...` / `go test ./... -count=1` / `git diff --check` 各 exit 0;`go test ./testdata/fakebackend ./testdata/fakeuv -count=1` exit 0;`go test ./internal/protocol -count=100` exit 0;`go test ./internal/cli -run 'StdinEOF|StdinReadError|ThenEOF|ControlReaderJoin|PortArgument' -count=20` exit 0;GCC 目录前置 PATH 后 `go test -race ./... -count=1` exit 0(全部 19 个含测试的包 ok)。本机 36163 全程被用户正式版 AUTO-MAS 占用,E2E 在此前提下全绿。 + **已知限制(登记为后续)**:stdout 已断时 `stopping_backend` 事件写不出去即走既有 `OUTPUT_WRITE_FAILED` 失败路径,后端由 Job 硬杀而不是 HTTP 优雅关闭;改成「写失败也继续 HTTP close」属于监督循环错误语义的变更,不在本任务范围。 --- @@ -1221,6 +1229,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 | 日期 | 变更 | 操作者 | | --- | --- | --- | +| 2026-09-02 | 完成 **T13.7**(`f7d5edb`)与 **T13.8**(`41c51d3`),均在分支 `feat/t13-port-eof-20260902` 上按四段式落地并合入 `integ/t13-20260901`。T13.7:`backend supervise --port`(1024~65535,缺省 managed 36163 / development 36164);`internal/health` 成为端口与地址的唯一来源(`DefaultPort` / `BaseURL` / `HealthURLForPort` / `ValidPort`),健康地址、`/api/core/close` 与 `baseUrl` 全部派生;`internal/uv` 注入受控键 `AUTO_MAS_SUPERVISED_PORT`;假后端改从该变量取监听端口;E2E 改用空闲端口,本机 36163 被正式版占用的前提下全绿。T13.8:`ControlReader.InputClosed()` + CLI 把 `hello` 之后的 stdin EOF / 读取出错翻译成无 commandId 的隐式 shutdown,走同一条优雅关闭路径;新增真实 exe 黑盒 E2E(stdin EOF 优雅关闭、stdout 已断仍退出),红灯为旧版 30 秒不退出。标准验证门、protocol ×100、定向 cli ×20 与完整 race 均 exit 0。遗留:TODO-PY-8 跨仓联合验收;stdout 已断时后端走 Job 硬杀而非 HTTP 优雅关闭 | Claude | | 2026-09-02 | 真机联调暴露两个首版前必须修掉的问题,按红线第 2 条先改文档:新增 [增补 1 **C12**「受监督端口由 Runtime 注入」](./契约补充-v1-增补1.md#c12受监督端口由-runtime-注入)(`backend supervise --port `,`1024`~`65535`,缺省 managed 36163 / development 36164;注入 `AUTO_MAS_SUPERVISED_PORT` 并并入受控监督键集合;健康 / 关闭地址与 `baseUrl` 由它派生;后端受监督时只认该变量、缺失回退 36163;协议不新增字段)与 [**C13**「宿主断开即关闭」](./契约补充-v1-增补1.md#c13宿主断开即关闭)(仅 `backend supervise`:`hello` 之后 stdin EOF 或读取出错视为隐式 `shutdown`,同一条优雅关闭路径与结局;stdout 已断也须能退出;其他命令不变);同步修订 C7 结论第 1 条、`契约补充-v1.md` C1、`架构设计.md` 五处(后端启动流程、后端关闭流程、CLI 设计、标准输入控制、后端健康检查与关闭契约)与 TODO-PY-8、D-open-2;M13 下立项 **T13.7**、**T13.8**,设计与计划见 `doc/current/M13/设计-T13.7-*.md`、`设计-T13.8-*.md`。背景:C7 把受监督端口钉死 36163,与 AUTO-MAS dev 已合入的 PR #451 开发版(36164)/ 正式版(36163)并存约定冲突,`backend supervise --mode development` 必撞正在运行的正式版,Runtime 自己的 E2E 在该机器上也必然失败;stdin EOF 只 `return nil` 让宿主崩溃后 Runtime 与后端继续占着端口与 `backend` Mutex,下次启动得到不可重试的 `BACKEND_ALREADY_RUNNING` | Claude | | 2026-09-02 | **T13.5 收口为 ✅**:「池目录重新分类」半段**按设计关闭**,不再实施,对应验收项撤销。注入面落地后,池真正可重建的 uv 缓存与受管解释器已物理落在 `/runtime/cache/uv` 与 `/runtime/environment/python`(真机 `manifest.json` 的 `installerMetadata.cache.path` / `installer.executable` 证实,`config/maafw_runtime_pool/` 下已无 `cache/`),本就在 `cleanup`/`repair` 的既有分类内;`config/maafw_runtime_pool/runtimes//` 只剩 venv 与 manifest,让 Runtime 识别该布局并删 venv 留 manifest 等于维护池清单,触红线第 4、6 条。分工改为 Runtime 只处理自己的目录,池 venv 因基解释器缺失失效后由后端自行判定重建。同步修订增补 1 C11 结论第 4 条、`架构设计.md` 三处(文档状态修订项、插件环境职责边界增补段、dev 基线目录分类表)、5.1 TODO-PY-13 的对应要求与 AGENTS.md 状态表;T13.6 保持 ⏸ | Claude | | 2026-09-02 | 完成 **T9.1** development 真后端联调:`integ/t13-20260901@ba27db3` 构建的 Runtime 对 AUTO-MAS 集成树 `integ/runtime-20260901` 的真后端 `backend supervise --mode development` 完整跑通「启动 → 就绪 → 优雅关闭」:ready 3.21 秒,health `protocol: 1 / version: v5.5.0-beta.3 / commit: ""`,stdin `shutdown` 被接受、后端退出后 Runtime 收口 0.35 秒、exit 0,`result.details` 无 warning,`hello.capabilities` 含 `stdin.shutdown` 与 `stdin.status`。过程中修掉优雅关闭误报 `BACKEND_FORCE_TERMINATED`(`e5ef0ab`,见 T13.3);契约偏差已由增补 1(C6~C11)与 9 月 1/2 日的协议偏差回写收口,不再新增 TODO。T9.2~T9.4 不动 | Claude | From 677586dde89ff959abc084a319744281d9de9ede Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 23:20:10 +0200 Subject: [PATCH 45/57] =?UTF-8?q?docs:=20C13=20=E5=AE=9A=E7=A8=BF=20stdout?= =?UTF-8?q?=20=E5=B7=B2=E6=96=AD=E4=B8=8D=E5=BD=B1=E5=93=8D=E4=BC=98?= =?UTF-8?q?=E9=9B=85=E5=85=B3=E9=97=AD=20(T13.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 进入 shutdown 之后协议事件写失败不再中断关闭流程:仍 HTTP close、等待关闭预算、超时才收 Job、 清理 Mutex 与事务,最后以 OUTPUT_WRITE_FAILED 退出并在 stderr 留诊断。架构设计两处与 T13.8 条目、设计文档同步。 Co-Authored-By: Claude Fable 5.1 --- ...00\345\215\263\345\205\263\351\227\255.md" | 22 +++++++++++-------- ...73\345\212\241\346\213\206\345\210\206.md" | 2 +- ...5\205\205-v1-\345\242\236\350\241\2451.md" | 4 ++-- ...66\346\236\204\350\256\276\350\256\241.md" | 5 +++-- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git "a/doc/current/M13/\350\256\276\350\256\241-T13.8-\345\256\277\344\270\273\346\226\255\345\274\200\345\215\263\345\205\263\351\227\255.md" "b/doc/current/M13/\350\256\276\350\256\241-T13.8-\345\256\277\344\270\273\346\226\255\345\274\200\345\215\263\345\205\263\351\227\255.md" index e092a12..99672da 100644 --- "a/doc/current/M13/\350\256\276\350\256\241-T13.8-\345\256\277\344\270\273\346\226\255\345\274\200\345\215\263\345\205\263\351\227\255.md" +++ "b/doc/current/M13/\350\256\276\350\256\241-T13.8-\345\256\277\344\270\273\346\226\255\345\274\200\345\215\263\345\205\263\351\227\255.md" @@ -46,14 +46,18 @@ `hello` 在 reader 启动之前发出,「hello 之后」的前提天然成立。 -### stdout 已断 - -写 stdout 失败时 `ProcessOutput` 返回 `ErrOutputWriteFailed`,监督循环按既有 -`OUTPUT_WRITE_FAILED` 路径 `cleanupProcess`(关 Job)并收口 Mutex 与事务,CLI 的 -`emitFailure` 写不出事件时只写 stderr 诊断——这条链路今天已经不 panic、不阻塞(Windows 上对已 -关闭管道的写立即返回错误),本任务只用 E2E 把它锁住。已知限制:stdout 已断时后端会被 Job 硬杀 -而不是 HTTP 优雅关闭,因为 `stopping_backend` 事件写不出去就走失败路径;改成「写失败也继续 -HTTP close」属于监督循环的错误语义变更,不在本任务范围,登记为后续项。 +### stdout 已断(2026-09-02 收尾修订) + +写 stdout 失败时 `ProcessOutput` 返回 `ErrOutputWriteFailed` 并记住它,此后每次 emit 立即返回同一 +错误(不阻塞)。原实现在 `finishControlShutdown` 里 `stopping_backend` 写失败即 `cleanupProcess`(关 +Job)返回——后端被硬杀。按 C13 结论第 4 条改为:**shutdown 路径上的写失败只记录、不中断**—— +`finishControlShutdown` 把首个输出错误存起来,照常 `POST /api/core/close` → `waitProcessExit`(沿用 +`--shutdown-timeout`)→ `cleanupProcess` → `finalizeResources`,之后的 `stopped` / 强制终止 warning 写失败 +同样只记录;最后把输出错误(与清理错误 join)返回,CLI 按既有分类得到 `OUTPUT_WRITE_FAILED`(退出码 +20),`emitFailure` 写不出事件时只写 stderr 诊断。同时,主循环里 `attempt.gate` 因 `OUTPUT_WRITE_FAILED` +故障、而 mailbox 已 latch 了 shutdown 时,也改走 `finishControlShutdown` 而不是硬杀——宿主崩溃时后端 +若恰好在输出日志,gate 会先于 EOF 观察到管道失效。显式 shutdown 同样受益:判定依据是「已进入 +shutdown」,不区分隐式与显式。残余窗口:gate 故障早于 EOF 被读到(mailbox 尚无终止命令)仍走硬杀。 ### 一次性命令 @@ -111,7 +115,7 @@ reader goroutine——而修复点正是那里。因此本任务的 E2E 用 `go | 任务拆分验收项 | 覆盖方式 | | --- | --- | | EOF 后优雅关闭、事件与 result、退出码 0、无残留 | Task 3 第一条 | -| stdout 已断也能退出 | Task 3 第二条 | +| stdout 已断也能退出且后端被 HTTP 优雅关闭 | Task 3 第二条(假后端 `shutdownFile` 落盘标记) + `TestBackend_ShutdownContinuesGracefullyWhenOutputFails` | | 已接受 shutdown 后再 EOF 不二次关闭 | Task 2 幂等用例 | | 一次性命令 EOF 行为不变 | 既有 protocol / workspace 测试无回退 | | race | 收尾 | diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index 2e4f132..27f3ad7 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -1030,7 +1030,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 - 证据:设计与计划见 `doc/current/M13/设计-T13.8-宿主断开即关闭.md`。协议层只加一个只读方法 `ControlReader.InputClosed()`:`Run` 因 EOF(含最后一行无换行)结束时为 true,因 `StopAccepting` 或 ctx 取消结束时为 false,`Run` 的返回值不变,因此 workspace / bootstrap 等共用 reader 的一次性命令零影响(`TestControlReader_InputClosedDistinguishesEOFFromStop` 四个子用例)。CLI 层 `runBackendSuperviseSession` 的 reader goroutine 在 `Run` 返回后分三路:ctx 已取消 → 不动;返回 nil 且 `InputClosed()` → `backendControl.SubmitImplicitShutdown()`;返回非取消错误 → reader 自身 panic 仍走 `SetReaderError` 的基础设施故障路径并上报(`TestBackendSupervise_ControlReaderPanicReportsSentry` 不变),其余读取错误按 C13 同样视为宿主断开:写一条 stderr 诊断后投隐式 shutdown,并把 readErr 清零以免收口时被当成 INTERNAL_ERROR。隐式 shutdown 就是向同一个 mailbox `Submit` 一条 `{Protocol:1, Command:shutdown, CommandID:""}`:first-wins latch 与 `ErrControlStopped` 由既有 mailbox 语义给出幂等,不新增状态;`finishControlShutdown` 本就只在 commandID 非空时写 `controlCommandId`。 测试:`TestBackendSupervise_StdinEOFSubmitsImplicitShutdown`(监督器收到 `CommandID==""` 的 shutdown,`result.status=stopped`、无 `controlCommandId`、exit 0)、`TestBackendSupervise_StdinReadErrorSubmitsImplicitShutdown`(读错误同样得到隐式 shutdown、exit 0、stderr 含 `stdin read failed`)、`TestBackendSupervise_ExplicitShutdownThenEOFIsIdempotent`(只收到显式那条、第二次 `Receive` 立即 `ErrControlStopped`、result 回显显式 commandId);既有 `TestBackend_ControlReaderJoinAndReadFailure/reader failure joins within bound` 的期望从 INTERNAL_ERROR 改为「有限时间内收口并以 stopped 退出」(契约变更随本任务同步)。E2E 新增 `internal/backend/e2e_stdin_windows_test.go`,用 `go build` 出真实 `auto-mas-runtime.exe` 黑盒驱动(既有 E2E 直接调 `ManagedSupervisor` 绕过了 CLI reader,修复点恰在那里):`TestBackendE2E_StdinEOFShutsDownGracefully` 到 `running` 后关 stdin,断言 exit 0、`stopping_backend` → `stopped`、`result.status=stopped` 且无 `controlCommandId`、无 `BACKEND_FORCE_TERMINATED`、stdout 无非 NDJSON 行、后端 PID 退出、端口 / Mutex / 事务无残留;`TestBackendE2E_StdinEOFWithBrokenStdoutStillExits` 先关自建 stdout 管道读端再关 stdin,Runtime 2.9 秒内退出(exit 20,stderr `write /dev/stdout: The pipe is being closed`),后端 PID 退出、资源无残留。红灯证据:把 `internal/cli/backend.go` 换回 `33c6bc8` 版本重跑主用例,Runtime 在 `hello`/`running` 之后 30 秒不退出(`runtime did not exit within 30s after stdin EOF`)。 验证:gofmt -l(无输出)/ `go vet ./...` / `go build -buildvcs=false ./...` / `go test ./... -count=1` / `git diff --check` 各 exit 0;`go test ./testdata/fakebackend ./testdata/fakeuv -count=1` exit 0;`go test ./internal/protocol -count=100` exit 0;`go test ./internal/cli -run 'StdinEOF|StdinReadError|ThenEOF|ControlReaderJoin|PortArgument' -count=20` exit 0;GCC 目录前置 PATH 后 `go test -race ./... -count=1` exit 0(全部 19 个含测试的包 ok)。本机 36163 全程被用户正式版 AUTO-MAS 占用,E2E 在此前提下全绿。 - **已知限制(登记为后续)**:stdout 已断时 `stopping_backend` 事件写不出去即走既有 `OUTPUT_WRITE_FAILED` 失败路径,后端由 Job 硬杀而不是 HTTP 优雅关闭;改成「写失败也继续 HTTP close」属于监督循环错误语义的变更,不在本任务范围。 + **收尾(2026-09-02,主线程要求)**:原登记的已知限制「stdout 已断时后端被 Job 硬杀」按 C13 结论第 4 条修订为「stdout 已断不影响优雅关闭」——见本条目下方的收尾证据。 --- diff --git "a/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" "b/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" index 526cc4f..4d6b0af 100644 --- "a/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" +++ "b/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" @@ -33,7 +33,7 @@ | C10 | 主项目依赖的镜像 | 锁文件在 PyPI 上生成;`dependencies sync` 改写锁内下载地址前缀参与镜像轮换,**不覆盖包索引**;显式首选源只改变尝试顺序(2026-09-01 修订) | | C11 | MaaFW 运行池的归属 | 只统一基础设施:Runtime 开放共享 uv 缓存与受管 Python 目录、下发有序镜像源;「装什么」仍留在后端 | | C12 | 受监督端口 | `backend supervise --port `(1024~65535),缺省 managed 36163 / development 36164;Runtime 注入 `AUTO_MAS_SUPERVISED_PORT`,健康/关闭地址与 `baseUrl` 由它派生;后端受监督时只认该变量、缺失回退 36163(2026-09-02 新增) | -| C13 | 宿主断开即关闭 | 仅 `backend supervise`:`hello` 之后 stdin EOF 或读取出错视为隐式 `shutdown`,走同一条优雅关闭路径;stdout 已断也必须能退出;其他命令不变(2026-09-02 新增) | +| C13 | 宿主断开即关闭 | 仅 `backend supervise`:`hello` 之后 stdin EOF 或读取出错视为隐式 `shutdown`,走同一条优雅关闭路径;stdout 已断不影响优雅关闭,收口后以 `OUTPUT_WRITE_FAILED` 退出;其他命令不变(2026-09-02 新增) | --- @@ -353,7 +353,7 @@ Runtime `T13.7`(`internal/cli/backend.go`、`internal/backend`、`internal/hea 1. `backend supervise` 在 `hello` 发出之后,stdin 到达 EOF 或读取出错,视为**隐式 `shutdown`**:走与 stdin `shutdown` 控制命令完全相同的优雅关闭路径与结局——`stopping_backend` → `POST /api/core/close` → 等待关闭预算(C9)→ 超时才收 Job → `stopped`;`result.status = "stopped"`、退出码 `0`。与显式 shutdown 的唯一差别是没有 `commandId`,因此 `details.controlCommandId` 不出现。 2. 隐式与显式 shutdown 幂等:已接受过 `shutdown` / `cancel` 之后再遇 EOF 不产生第二次关闭;EOF 之后不再有任何输入可读。 3. EOF 发生在后端就绪之前(预检、启动、健康检查期间)同样触发隐式 shutdown,语义与在这些阶段收到显式 shutdown 完全一致。 -4. **stdout 容错**:宿主崩溃时 stdout 管道通常与 stdin 同时失效。Runtime 对写 stdout 失败必须容错——不 panic、不阻塞,仍完成进程树回收、Mutex 与事务收口后退出。此时事件与 `result` 可能写不出去,退出码按既有 `OUTPUT_WRITE_FAILED` 分类;调用方本就已经不在,无人消费。 +4. **stdout 容错**:宿主崩溃时 stdout 管道通常与 stdin 同时失效。Runtime 对写 stdout 失败必须容错——不 panic、不阻塞,**且 stdout 已断不影响优雅关闭**:进入 shutdown(显式或隐式)之后,任何协议事件写失败都不中断关闭流程,仍按顺序执行 `POST /api/core/close` → 等待关闭预算(C9)→ 超时才收 Job → 清理 Mutex 与事务,最后才以 `OUTPUT_WRITE_FAILED`(退出码 20)退出并在 stderr 留诊断。理由:后端可能正在跑 MAA / 游戏任务,硬杀会留下半截状态,而宿主崩溃恰恰是最需要优雅收尾的场景。此时事件与 `result` 写不出去,调用方本就已经不在,无人消费。 5. **其他命令不变**:`bootstrap`、`dependencies`、`repair`、`workspace` 等一次性命令的 stdin EOF 行为一个字节都不动——它们可能在没有 stdin 的环境下运行(`< NUL`、CI、计划任务)。 6. 调用方义务:宿主在整个监督期间必须保持 stdin 打开;把 `backend supervise` 的 stdin 接到 `NUL` 或已关闭的管道等于立刻请求关闭。 diff --git "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" index 4ef6fe7..fd9c987 100644 --- "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" +++ "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -318,7 +318,8 @@ Runtime 随后: **2026-09-02 增补([增补 1 C13](./契约补充-v1-增补1.md#c13宿主断开即关闭)):** 宿主崩溃或被杀时发不出 `shutdown`。`backend supervise` 在 `hello` 之后遇到 stdin EOF 或读取出错,视为**隐式 `shutdown`**,走上述 同一条路径与结局(`result.status = "stopped"`、退出码 0,只是没有 `controlCommandId`)。stdout 已随宿主断开时 -Runtime 仍须完成第 4、6 步并退出,不 panic、不阻塞。其他一次性命令的 stdin EOF 行为不变。 +Runtime 仍须**完整**走完第 2~6 步——写事件失败不中断关闭流程,后端仍经 `POST /api/core/close` 优雅退出, +只在超时后才收 Job——最后以 `OUTPUT_WRITE_FAILED` 退出并在 stderr 留诊断,不 panic、不阻塞。其他一次性命令的 stdin EOF 行为不变。 ## 后端启动失败通知与日志 @@ -1648,7 +1649,7 @@ Runtime 通过 `AUTO_MAS_RUNTIME_PROTOCOL`、`AUTO_MAS_EXPECTED_VERSION` 和 `AU **2026-09-02 增补:** - **受监督端口由 Runtime 注入**([增补 1 C12](./契约补充-v1-增补1.md#c12受监督端口由-runtime-注入)):`backend supervise --port `(`1024`~`65535`,缺省 managed `36163` / development `36164`)决定端口,Runtime 注入 `AUTO_MAS_SUPERVISED_PORT=`(并入受控监督环境键集合),本节两个接口的地址与 `baseUrl` 由它派生;同一监督进程生命周期内端口不变,单次自动重启复用。后端受监督时只认该变量,缺失回退 36163。 -- **宿主断开即关闭**([增补 1 C13](./契约补充-v1-增补1.md#c13宿主断开即关闭)):`hello` 之后 stdin EOF 或读取出错等价于一条没有 `commandId` 的 `shutdown`,走上一段完全相同的 `POST /api/core/close` → 关闭预算 → Job 兜底路径;stdout 已断开时仍须完成进程树回收与 Mutex / 事务收口后退出。仅 `backend supervise` 适用。 +- **宿主断开即关闭**([增补 1 C13](./契约补充-v1-增补1.md#c13宿主断开即关闭)):`hello` 之后 stdin EOF 或读取出错等价于一条没有 `commandId` 的 `shutdown`,走上一段完全相同的 `POST /api/core/close` → 关闭预算 → Job 兜底路径;stdout 已断开时该路径**不被写失败中断**——后端仍被 HTTP 优雅关闭、超时才收 Job,Mutex / 事务收口后 Runtime 以 `OUTPUT_WRITE_FAILED` 退出并在 stderr 留诊断。仅 `backend supervise` 适用。 这两个接口是 Runtime 与 Python 后端的共同开发契约。任一侧修改字段、语义或协议版本时必须同步更新另一侧及对应测试。后端 schema 变更后通过 OpenAPI 生成器更新前端客户端,不能手工修改生成文件。 From 85b761525428c3c59f4a96dd323f8b24f4dd8828 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 23:23:13 +0200 Subject: [PATCH 46/57] =?UTF-8?q?test:=20=E5=81=87=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E8=90=BD=E7=9B=98=E4=BC=98=E9=9B=85=E5=85=B3=E9=97=AD=E6=A0=87?= =?UTF-8?q?=E8=AE=B0=EF=BC=8C=E6=96=AD=E8=A8=80=20stdout=20=E5=B7=B2?= =?UTF-8?q?=E6=96=AD=E6=97=B6=E5=90=8E=E7=AB=AF=E4=BB=8D=E8=A2=AB=20HTTP?= =?UTF-8?q?=20=E4=BC=98=E9=9B=85=E5=85=B3=E9=97=AD=20(T13.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fakebackend 新增 shutdownFile,只在 close → server.Shutdown 成功后写入,被 Job 硬杀时不出现; E2E 用它把「stdout 已断仍能退出」加强为「后端被 HTTP 优雅关闭且 Runtime 以 OUTPUT_WRITE_FAILED 退出」, 主用例同样断言优雅标记;backend 单测锁定关闭路径上输出失败仍执行 HTTP close。 Co-Authored-By: Claude Fable 5.1 --- internal/backend/control_test.go | 43 ++++++++++++++++++++++ internal/backend/e2e_stdin_windows_test.go | 23 +++++++++++- internal/backend/e2e_windows_test.go | 4 ++ testdata/fakebackend/main.go | 11 +++++- testdata/fakebackend/main_test.go | 6 ++- 5 files changed, 84 insertions(+), 3 deletions(-) diff --git a/internal/backend/control_test.go b/internal/backend/control_test.go index ce2751f..5206c05 100644 --- a/internal/backend/control_test.go +++ b/internal/backend/control_test.go @@ -1547,3 +1547,46 @@ func waitForStateStatusCount(t *testing.T, emitter *fakeEmitter, status protocol } } } + +// TestBackend_ShutdownContinuesGracefullyWhenOutputFails 锁定增补 1 C13 结论第 4 条: +// 进入 shutdown 之后 stdout 写失败不中断关闭流程——仍 HTTP close、等待退出、清理, +// 最后才把 OUTPUT_WRITE_FAILED 交给 CLI;后端不会因为宿主管道失效而被 Job 硬杀。 +func TestBackend_ShutdownContinuesGracefullyWhenOutputFails(t *testing.T) { + f := newBackendFixture(t) + f.proc.keepAlive = true + mailbox := NewControlMailbox(8) + var mu sync.Mutex + order := make([]string, 0, 2) + f.depsHTTP = &orderedHTTPCloser{process: f.proc, record: func(value string) { + mu.Lock() + order = append(order, value) + mu.Unlock() + }} + done := make(chan error, 1) + go func() { + req := f.request() + req.Control = mailbox + done <- f.supervisorWithHTTP(t.Context(), req) + }() + waitFor(t, f.emitter.running) + // running 之后宿主管道失效:此后每一次协议输出都失败。 + f.emitter.mu.Lock() + f.emitter.stateErr = errors.New("stdout pipe is closed") + f.emitter.mu.Unlock() + if err := mailbox.Submit(context.Background(), protocol.ControlCommand{Command: protocol.ControlShutdown, CommandID: "shutdown-broken-stdout"}); err != nil { + t.Fatalf("Submit(shutdown) error = %v", err) + } + err := <-done + assertBackendCode(t, err, protocol.CodeOutputWriteFailed) + mu.Lock() + defer mu.Unlock() + if len(order) != 1 || order[0] != "http" { + t.Fatalf("shutdown steps = %#v, want the HTTP close to be attempted despite the output failure", order) + } + if !f.proc.terminated || !f.proc.waitedEmpty || !f.proc.closed { + t.Fatalf("process cleanup = terminated:%v waitEmpty:%v closed:%v", f.proc.terminated, f.proc.waitedEmpty, f.proc.closed) + } + if indexOfEvent(f.emitter.eventsSnapshot(), "warning:"+string(protocol.CodeBackendForceTerminated)) >= 0 { + t.Fatalf("events = %#v, want no force warning: the backend exited on HTTP close", f.emitter.eventsSnapshot()) + } +} diff --git a/internal/backend/e2e_stdin_windows_test.go b/internal/backend/e2e_stdin_windows_test.go index e86401c..1c0e4dd 100644 --- a/internal/backend/e2e_stdin_windows_test.go +++ b/internal/backend/e2e_stdin_windows_test.go @@ -12,6 +12,7 @@ import ( "os/exec" "path/filepath" "strconv" + "strings" "sync" "testing" "time" @@ -240,12 +241,28 @@ func TestBackendE2E_StdinEOFShutsDownGracefully(t *testing.T) { } } waitE2EPIDExit(t, pythonPID) + assertE2EBackendClosedGracefully(t, fixture) fixture.assertResourcesReleased(t) } +// assertE2EBackendClosedGracefully 用假后端只在 close → server.Shutdown 成功后才落盘的标记, +// 证明后端是被 HTTP 优雅关闭而不是被 Job 硬杀。 +func assertE2EBackendClosedGracefully(t *testing.T, fixture *backendE2EFixture) { + t.Helper() + waitE2EFile(t, fixture.shutdownFile) + payload, err := os.ReadFile(fixture.shutdownFile) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", fixture.shutdownFile, err) + } + if got := strings.TrimSpace(string(payload)); got != "graceful" { + t.Fatalf("backend shutdown marker = %q, want graceful", got) + } +} + // TestBackendE2E_StdinEOFWithBrokenStdoutStillExits 模拟宿主崩溃的真实形态:stdout 管道 // 先失效、随后 stdin EOF。Runtime 写不出任何事件,也必须在有限时间内退出并收口后端进程树、 -// 端口、Mutex 与事务;退出码不作要求。 +// 端口、Mutex 与事务;且按 C13 结论第 4 条,后端仍须被 HTTP 优雅关闭而不是被 Job 硬杀, +// Runtime 最后以 OUTPUT_WRITE_FAILED(退出码 20)收场。 func TestBackendE2E_StdinEOFWithBrokenStdoutStillExits(t *testing.T) { fixture := newBackendE2EFixture(t, backendE2EConfig{Events: e2EOutputEvents("broken")}) process := startBackendE2ERuntime(t, fixture) @@ -260,6 +277,10 @@ func TestBackendE2E_StdinEOFWithBrokenStdoutStillExits(t *testing.T) { code := process.waitExit(t, 30*time.Second) t.Logf("runtime exit code with broken stdout = %d; stderr=%q", code, process.stderr.String()) + if code != protocol.ExitCodePreconditionFailed { + t.Fatalf("runtime exit code = %d, want %d (OUTPUT_WRITE_FAILED); stderr=%q", code, protocol.ExitCodePreconditionFailed, process.stderr.String()) + } waitE2EPIDExit(t, pythonPID) + assertE2EBackendClosedGracefully(t, fixture) fixture.assertResourcesReleased(t) } diff --git a/internal/backend/e2e_windows_test.go b/internal/backend/e2e_windows_test.go index 94edd0e..0fdc0a6 100644 --- a/internal/backend/e2e_windows_test.go +++ b/internal/backend/e2e_windows_test.go @@ -47,6 +47,7 @@ type backendE2EConfig struct { PIDFile string `json:"pidFile,omitempty"` WorkingDirFile string `json:"workingDirFile,omitempty"` EnvironmentFile string `json:"environmentFile,omitempty"` + ShutdownFile string `json:"shutdownFile,omitempty"` GrandchildPIDFile string `json:"grandchildPidFile,omitempty"` SpawnGrandchild bool `json:"spawnGrandchild,omitempty"` GrandchildLifetimeMS int `json:"grandchildLifetimeMs,omitempty"` @@ -248,6 +249,7 @@ type backendE2EFixture struct { rootPID string workingDir string environment string + shutdownFile string grandchildPID string uvExecReady string uvExecRelease string @@ -423,6 +425,7 @@ func newBackendE2EFixture(t *testing.T, configValue backendE2EConfig) *backendE2 configValue.PIDFile = filepath.Join(root, "python.pid") configValue.WorkingDirFile = filepath.Join(root, "backend.cwd") configValue.EnvironmentFile = filepath.Join(root, "backend.env") + configValue.ShutdownFile = filepath.Join(root, "backend.shutdown") configValue.GrandchildPIDFile = filepath.Join(root, "grandchild.pid") rootPIDPath := filepath.Join(root, "uv.pid") uvExecReadyPath := "" @@ -483,6 +486,7 @@ func newBackendE2EFixture(t *testing.T, configValue backendE2EConfig) *backendE2 rootPID: rootPIDPath, workingDir: configValue.WorkingDirFile, environment: configValue.EnvironmentFile, + shutdownFile: configValue.ShutdownFile, grandchildPID: configValue.GrandchildPIDFile, uvExecReady: uvExecReadyPath, uvExecRelease: uvExecReleasePath, diff --git a/testdata/fakebackend/main.go b/testdata/fakebackend/main.go index 4ecbf00..66b0744 100644 --- a/testdata/fakebackend/main.go +++ b/testdata/fakebackend/main.go @@ -46,7 +46,10 @@ type fakeBackendConfig struct { // EnvironmentFile 让假后端把自己进程里读到的受监督环境变量落盘,供 T13.5 // 端到端断言增补 1 C11 的四个变量确实穿过 uv 到达了真实后端进程;父进程侧 // 的 StartSpec 断言只能证明 Runtime 传了什么,证明不了后端收到了什么。 - EnvironmentFile string `json:"environmentFile"` + EnvironmentFile string `json:"environmentFile"` + // ShutdownFile 只在「收到 close 并完成 server.Shutdown」的优雅路径上落盘,被 Job 硬杀时 + // 永远不会出现;T13.8 的 E2E 据此区分「HTTP 优雅关闭」与「被杀」。 + ShutdownFile string `json:"shutdownFile"` GrandchildPIDFile string `json:"grandchildPidFile"` SpawnGrandchild bool `json:"spawnGrandchild"` GrandchildLifetimeMS int `json:"grandchildLifetimeMs"` @@ -311,6 +314,12 @@ func runFakeBackend() int { fmt.Fprintln(os.Stderr, err) return 95 } + if config.ShutdownFile != "" { + if err := writeSignalFile(config.ShutdownFile, []byte("graceful\n")); err != nil { + fmt.Fprintln(os.Stderr, err) + return 88 + } + } return 0 case err := <-serveResult: if err != nil && !errors.Is(err, http.ErrServerClosed) { diff --git a/testdata/fakebackend/main_test.go b/testdata/fakebackend/main_test.go index 15e6910..6fdc172 100644 --- a/testdata/fakebackend/main_test.go +++ b/testdata/fakebackend/main_test.go @@ -578,8 +578,9 @@ func TestFakeBackend_ListensOnSupervisedPortEnv(t *testing.T) { t.Fatalf("close port probe: %v", err) } readyFile := filepath.Join(root, "env-ready.txt") + shutdownFile := filepath.Join(root, "env-shutdown.txt") configPath := filepath.Join(root, "env-config.json") - writeConfig(t, configPath, fakeBackendConfig{ReadyFile: readyFile}) + writeConfig(t, configPath, fakeBackendConfig{ReadyFile: readyFile, ShutdownFile: shutdownFile}) ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() command := exec.CommandContext(ctx, executable) @@ -603,6 +604,9 @@ func TestFakeBackend_ListensOnSupervisedPortEnv(t *testing.T) { if err := command.Wait(); err != nil { t.Fatalf("fake backend exit = %v, want 0", err) } + if got := strings.TrimSpace(waitForFile(t, shutdownFile)); got != "graceful" { + t.Fatalf("shutdown marker = %q, want graceful", got) + } for _, invalid := range []string{"", "abc", "70000", "80"} { t.Run("invalid "+invalid, func(t *testing.T) { From 23033f9d38274efc4f1d379135d6817f05f85e6a Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 23:23:13 +0200 Subject: [PATCH 47/57] =?UTF-8?q?feat(backend):=20shutdown=20=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E4=B8=8A=E5=8D=8F=E8=AE=AE=E8=BE=93=E5=87=BA=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E4=B8=8D=E5=86=8D=E4=B8=AD=E6=96=AD=E4=BC=98=E9=9B=85?= =?UTF-8?q?=E5=85=B3=E9=97=AD=20(T13.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按增补 1 C13 结论第 4 条:finishControlShutdown 只记录首个输出错误,照常 HTTP close、等待关闭预算、 Job 兜底与 Mutex / 事务收口,最后才把 OUTPUT_WRITE_FAILED 交给 CLI;主循环里 gate 因 OUTPUT_WRITE_FAILED 故障而 shutdown 已 latch 时同样走优雅关闭而不是硬杀。 Co-Authored-By: Claude Fable 5.1 --- internal/backend/control.go | 68 ++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/internal/backend/control.go b/internal/backend/control.go index 1eec2e8..5055c03 100644 --- a/internal/backend/control.go +++ b/internal/backend/control.go @@ -722,6 +722,9 @@ func (s *ManagedSupervisor) superviseControlled(ctx context.Context, request Req var restartFacts map[string]any for { if fault := attempt.gate.Fault(); fault != nil { + if commandID, ok := shutdownLatchedBehindOutputFault(request.Control, fault); ok { + return errors.Join(fault, s.finishControlShutdown(ctx, request, attempt, stateSnapshot, commandID)) + } cleanup := s.cleanupProcess(context.WithoutCancel(ctx), attempt.process, attempt.tx, attempt.logger) return s.emitControlFailure(request, stateSnapshot, errors.Join(cleanup.err, fault)) } @@ -742,6 +745,9 @@ func (s *ManagedSupervisor) superviseControlled(ctx context.Context, request Req select { case <-attempt.gate.Faulted(): fault := attempt.gate.Fault() + if commandID, ok := shutdownLatchedBehindOutputFault(request.Control, fault); ok { + return errors.Join(fault, s.finishControlShutdown(ctx, request, attempt, stateSnapshot, commandID)) + } cleanup := s.cleanupProcess(context.WithoutCancel(ctx), attempt.process, attempt.tx, attempt.logger) return s.emitControlFailure(request, stateSnapshot, errors.Join(cleanup.err, fault)) case <-ctx.Done(): @@ -1897,6 +1903,23 @@ func (s *ManagedSupervisor) finishControlCancel(ctx context.Context, request Req } } +// shutdownLatchedBehindOutputFault 判断 gate 故障是否只是宿主管道失效、而关闭已在路上: +// 宿主崩溃时 stdout 与 stdin 一起失效,后端若恰好在输出日志,gate 会先于 EOF 观察到写失败; +// 此时按增补 1 C13 结论第 4 条仍应优雅关闭,而不是把后端硬杀。 +func shutdownLatchedBehindOutputFault(receiver ControlReceiver, fault error) (string, bool) { + if !backendErrorHasCode(fault, protocol.CodeOutputWriteFailed) { + return "", false + } + command, ok := terminalCommand(receiver) + if !ok || command.Command != protocol.ControlShutdown { + return "", false + } + return command.CommandID, true +} + +// finishControlShutdown 执行显式或隐式 shutdown 的优雅关闭。按增补 1 C13 结论第 4 条, +// 进入关闭之后协议输出失败只记录、不中断:HTTP close、等待退出、Job 兜底与资源收口照常执行, +// 首个输出错误最后才交给 CLI 映射为 OUTPUT_WRITE_FAILED;宿主崩溃时后端因此仍能优雅收尾。 func (s *ManagedSupervisor) finishControlShutdown(ctx context.Context, request Request, attempt *controlAttempt, snapshot *controlState, commandID string) error { details := map[string]any{} if commandID != "" { @@ -1914,19 +1937,20 @@ func (s *ManagedSupervisor) finishControlShutdown(ctx context.Context, request R } setControlStage(request.Control, protocol.StageBackendShutdown) snapshot.set(protocol.StageBackendShutdown, protocol.StateStoppingBackend, details) - if err := s.emitState(request.Emitter, protocol.StageBackendShutdown, protocol.StateStoppingBackend, "正在关闭后端", details); err != nil { - var cleanup processCleanup - if attempt != nil && attempt.process != nil { - cleanup = s.cleanupProcess(context.WithoutCancel(ctx), attempt.process, attempt.tx, attempt.logger) + var outputErr error + recordOutput := func(err error) { + if err != nil && outputErr == nil { + outputErr = err } - return errors.Join(cleanup.err, err) } + recordOutput(s.emitState(request.Emitter, protocol.StageBackendShutdown, protocol.StateStoppingBackend, "正在关闭后端", details)) if attempt == nil || attempt.process == nil { if resourceErr := snapshot.finalizeResources(); resourceErr != nil { - return s.emitControlFailure(request, snapshot, resourceErr) + return s.emitControlFailure(request, snapshot, errors.Join(outputErr, resourceErr)) } snapshot.set(protocol.StageBackendShutdown, protocol.StateStopped, details) - return s.emitState(request.Emitter, protocol.StageBackendShutdown, protocol.StateStopped, "后端已停止", details) + recordOutput(s.emitState(request.Emitter, protocol.StageBackendShutdown, protocol.StateStopped, "后端已停止", details)) + return outputErr } closeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), s.shutdownTimeout(request)) defer cancel() @@ -1936,35 +1960,23 @@ func (s *ManagedSupervisor) finishControlShutdown(ctx context.Context, request R } httpErr := closer.Close(closeCtx) graceful := httpErr == nil && waitProcessExit(closeCtx, attempt.process) - if httpErr == nil && graceful { - cleanup := s.cleanupProcess(context.WithoutCancel(ctx), attempt.process, attempt.tx, attempt.logger) - if cleanup.err != nil { - return s.emitControlFailure(request, snapshot, cleanup.err) - } - if resourceErr := snapshot.finalizeResources(); resourceErr != nil { - return s.emitControlFailure(request, snapshot, resourceErr) - } - if cleanup.forced { - if err := emitForceWarning(request.Emitter, cleanup.details); err != nil { - return err - } - } - snapshot.set(protocol.StageBackendShutdown, protocol.StateStopped, details) - return s.emitState(request.Emitter, protocol.StageBackendShutdown, protocol.StateStopped, "后端已停止", details) - } cleanup := s.cleanupProcess(context.WithoutCancel(ctx), attempt.process, attempt.tx, attempt.logger) if cleanup.err != nil { + if graceful { + return s.emitControlFailure(request, snapshot, errors.Join(outputErr, cleanup.err)) + } failure := newError(protocol.CodeBackendShutdownFailed, protocol.StageBackendCleanup, "后端进程树未能确认清空", cleanup.details, httpErr) - return s.emitControlFailure(request, snapshot, errors.Join(failure, cleanup.err)) + return s.emitControlFailure(request, snapshot, errors.Join(outputErr, failure, cleanup.err)) } if resourceErr := snapshot.finalizeResources(); resourceErr != nil { - return s.emitControlFailure(request, snapshot, resourceErr) + return s.emitControlFailure(request, snapshot, errors.Join(outputErr, resourceErr)) } - if err := emitForceWarning(request.Emitter, cleanup.details); err != nil { - return err + if !graceful || cleanup.forced { + recordOutput(emitForceWarning(request.Emitter, cleanup.details)) } snapshot.set(protocol.StageBackendShutdown, protocol.StateStopped, details) - return s.emitState(request.Emitter, protocol.StageBackendShutdown, protocol.StateStopped, "后端已停止", details) + recordOutput(s.emitState(request.Emitter, protocol.StageBackendShutdown, protocol.StateStopped, "后端已停止", details)) + return outputErr } // shutdownTimeout 解析本次关闭的等待上限(增补 1 C9):调用方显式给出的预算优先, From 846b92385c84f5e1f07442a57ab3298511810e55 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 23:25:35 +0200 Subject: [PATCH 48/57] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20T13.8=20?= =?UTF-8?q?=E6=94=B6=E5=B0=BE=E2=80=94=E2=80=94stdout=20=E5=B7=B2=E6=96=AD?= =?UTF-8?q?=E4=B8=8D=E5=BD=B1=E5=93=8D=E4=BC=98=E9=9B=85=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 2 +- "doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index adf306d..ba133b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Vue/Electron/Python 的改写、CI/CD 发布流程本身。 | M10 工程可维护性收敛 | T10.1 文档信息架构已完成;后续阶段见维护设计 | | M11 跨平台适配(Linux/macOS) | 规划中(决策 D7,2026-08-04 立项);仅 T11.1 设计任务可执行 | | M12 遥测与错误观测(Sentry-only) | T12.1、T12.2、T12.4、T12.5、T12.7 已完成;T12.6 本地发布配置已提交,待 Repository secret 与授权后的远端验收;T12.3/Umami 已取消 | -| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;T13.1(后端工作目录)、T13.2(Job 允许显式脱离)、T13.3(关闭超时参数化,含优雅关闭误报修复)、T13.4(锁副本改写参与包索引镜像轮换,显式 package-index 排最前)**已完成**;T13.5 **已完成**(注入面 `020b3b9`:`AUTO_MAS_UV_CACHE_DIR` / `AUTO_MAS_UV_PYTHON_INSTALL_DIR` / `AUTO_MAS_MIRROR_PACKAGE_INDEX` / `AUTO_MAS_MIRROR_PYTHON`;池目录重新分类按设计关闭——Runtime 的 repair/cleanup 只处理自己的目录,池 venv 失效后由后端判定重建);T13.6 ⏸ 未开始;2026-09-02 真机联调后按增补 1 **C12 / C13** 完成 T13.7(`f7d5edb`:`backend supervise --port`,缺省 managed 36163 / development 36164,注入 `AUTO_MAS_SUPERVISED_PORT`,健康/关闭地址与 `baseUrl` 由 `internal/health` 派生,E2E 改用空闲端口)与 T13.8(`41c51d3`:`backend supervise` 的 stdin EOF / 读取出错视为隐式 shutdown,真实 exe 黑盒 E2E 锁定,宿主崩溃不再留孤儿)**已完成** | +| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;T13.1(后端工作目录)、T13.2(Job 允许显式脱离)、T13.3(关闭超时参数化,含优雅关闭误报修复)、T13.4(锁副本改写参与包索引镜像轮换,显式 package-index 排最前)**已完成**;T13.5 **已完成**(注入面 `020b3b9`:`AUTO_MAS_UV_CACHE_DIR` / `AUTO_MAS_UV_PYTHON_INSTALL_DIR` / `AUTO_MAS_MIRROR_PACKAGE_INDEX` / `AUTO_MAS_MIRROR_PYTHON`;池目录重新分类按设计关闭——Runtime 的 repair/cleanup 只处理自己的目录,池 venv 失效后由后端判定重建);T13.6 ⏸ 未开始;2026-09-02 真机联调后按增补 1 **C12 / C13** 完成 T13.7(`f7d5edb`:`backend supervise --port`,缺省 managed 36163 / development 36164,注入 `AUTO_MAS_SUPERVISED_PORT`,健康/关闭地址与 `baseUrl` 由 `internal/health` 派生,E2E 改用空闲端口)与 T13.8(`41c51d3`,收尾 `23033f9`:`backend supervise` 的 stdin EOF / 读取出错视为隐式 shutdown,stdout 已断不影响 HTTP 优雅关闭,真实 exe 黑盒 E2E 锁定,宿主崩溃不再留孤儿)**已完成** | 代码现状: diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index 27f3ad7..8c56172 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -1023,14 +1023,14 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 测试:`TestManaged_InjectsSupervisedPort` / `TestManaged_OmitsSupervisedPortWhenUnset` / `TestManaged_RejectsInvalidSupervisedPort`(uv);`TestHealth_RequestURLFollowsExpectationPort` / `TestHealth_RejectsOutOfRangePort`(health);`TestBackend_PortDefaultsByModeAndDerivesAddresses`(managed/development × 缺省/显式,断言 `ManagedOptions.Port`、`Expectation.Port`、`baseUrl` 三者同源)/ `TestBackend_RestartReusesSupervisedPort` / `TestBackend_CloseRequestTargetsSupervisedPort`(`httptest` 在 `127.0.0.1:0` 上证明 POST 打到派生端口且 503 仍报错)/ `TestBackend_RejectsOutOfRangePort`(backend);`TestBackendSupervise_PortArgument`(cli:managed/development 缺省、1024/65535/36170 接受,1023/65536/0/-1/abc/1.5/空串拒绝且 factory 零调用、`details.field=port`);`TestFakeBackend_ListensOnSupervisedPortEnv`(假后端)。E2E:夹具 `pickE2EFreePort` 用 `net.Listen("tcp4","127.0.0.1:0")` 探空闲端口,**不再设置 `listenAddress`**,假后端只能从 `AUTO_MAS_SUPERVISED_PORT` 读到端口——健康检查能通过即证明注入穿过 uv 到达真实后端进程;`assertE2EDevelopmentUVEnvironment` 同时断言 uv 收到的 `AUTO_MAS_SUPERVISED_PORT` 等于夹具端口,`running.baseUrl` 等于 `health.BaseURL(port)`;端口辅助函数改为带端口参数,跨进程 helper 的 signal 增加 `port`,串行化 Mutex 改名 `Local\AUTO-MAS-RUNTIME-M6-E2E-SERIAL`。`grep -rn 36163 --include=*.go` 只剩 `health.DefaultPort` / `HealthURL` 常量、假后端 `defaultListenAddress`、cli 缺省断言与注释。 验证:gofmt -l(无输出)/ `go vet ./...` / `go build -buildvcs=false ./...` / `go test ./... -count=1` / `git diff --check` 各 exit 0;`go test ./testdata/fakebackend ./testdata/fakeuv -count=1` exit 0;`go test ./internal/protocol -count=100` exit 0;`go test ./internal/cli -run 'StdinEOF|StdinReadError|ThenEOF|ControlReaderJoin|PortArgument' -count=20` exit 0;GCC 目录前置 PATH 后 `go test -race ./... -count=1` exit 0(全部 19 个含测试的包 ok)。本机 36163 全程被用户正式版 AUTO-MAS 占用,E2E 在此前提下全绿。 **缺口**:AUTO-MAS 侧 TODO-PY-8(后端改读 `AUTO_MAS_SUPERVISED_PORT`)由另一位代理同步实现,跨仓联合验收(开发版 36164 与正式版 36163 并存)待主线程真机复跑。 -- [x] **T13.8 宿主断开即关闭**(M) ✅ 2026-09-02 `41c51d3`(`33c6bc8` protocol → `64a0554` cli → `41c51d3` 真实 exe E2E) +- [x] **T13.8 宿主断开即关闭**(M) ✅ 2026-09-02 `41c51d3`(`33c6bc8` protocol → `64a0554` cli → `41c51d3` 真实 exe E2E);收尾 `23033f9`(stdout 已断不影响优雅关闭:`677586d` 文档 → `85b7615` 假后端标记与断言 → `23033f9` backend) - 依赖:M6;契约 [增补 1 C13](./契约补充-v1-增补1.md#c13宿主断开即关闭);AUTO-MAS 侧无改动 - 内容:仅 `backend supervise`:`hello` 发出之后 stdin 到达 EOF 或读取出错视为**隐式 `shutdown`**,走与 stdin `shutdown` 控制命令完全相同的优雅关闭路径与结局(`stopping_backend` → `POST /api/core/close` → 关闭预算 → Job 兜底 → `stopped`;`result.status = "stopped"`、退出码 0;无 `controlCommandId`)。现状 `internal/protocol/control.go:196-198` 读到 EOF 只 `return nil`、`internal/cli/backend.go:262-267` 只记日志,Electron 崩溃后 Runtime 与后端继续占着端口和 `backend` Mutex,下次启动得到 `BACKEND_ALREADY_RUNNING`。写 stdout 若已失败(宿主管道断了)要能容错——不 panic、不阻塞,仍完成进程树回收与 Mutex / 事务收口后退出。其他命令(bootstrap / dependencies / repair / workspace 等)的 stdin EOF 行为**不变**。 - 验收:E2E(真实 exe 黑盒)——启动 `backend supervise` 到 `running`,关闭 stdin,断言后端被优雅关闭(假后端收到 close 自行退出、无 `BACKEND_FORCE_TERMINATED`)、事件序列 `stopping_backend` → `stopped`、`result.status = "stopped"` 且无 `controlCommandId`、退出码 0、端口 / Mutex / 事务无残留;补一条「stdout 已断也能退出」(stdout 接到已关闭的管道后再关 stdin,Runtime 有限时间内退出、进程树与 Mutex 无残留);已接受 shutdown 后再 EOF 不产生第二次关闭;一次性命令的 EOF 行为有既有测试锁定无回退;涉及并发,`go test -race` 通过。 - 证据:设计与计划见 `doc/current/M13/设计-T13.8-宿主断开即关闭.md`。协议层只加一个只读方法 `ControlReader.InputClosed()`:`Run` 因 EOF(含最后一行无换行)结束时为 true,因 `StopAccepting` 或 ctx 取消结束时为 false,`Run` 的返回值不变,因此 workspace / bootstrap 等共用 reader 的一次性命令零影响(`TestControlReader_InputClosedDistinguishesEOFFromStop` 四个子用例)。CLI 层 `runBackendSuperviseSession` 的 reader goroutine 在 `Run` 返回后分三路:ctx 已取消 → 不动;返回 nil 且 `InputClosed()` → `backendControl.SubmitImplicitShutdown()`;返回非取消错误 → reader 自身 panic 仍走 `SetReaderError` 的基础设施故障路径并上报(`TestBackendSupervise_ControlReaderPanicReportsSentry` 不变),其余读取错误按 C13 同样视为宿主断开:写一条 stderr 诊断后投隐式 shutdown,并把 readErr 清零以免收口时被当成 INTERNAL_ERROR。隐式 shutdown 就是向同一个 mailbox `Submit` 一条 `{Protocol:1, Command:shutdown, CommandID:""}`:first-wins latch 与 `ErrControlStopped` 由既有 mailbox 语义给出幂等,不新增状态;`finishControlShutdown` 本就只在 commandID 非空时写 `controlCommandId`。 测试:`TestBackendSupervise_StdinEOFSubmitsImplicitShutdown`(监督器收到 `CommandID==""` 的 shutdown,`result.status=stopped`、无 `controlCommandId`、exit 0)、`TestBackendSupervise_StdinReadErrorSubmitsImplicitShutdown`(读错误同样得到隐式 shutdown、exit 0、stderr 含 `stdin read failed`)、`TestBackendSupervise_ExplicitShutdownThenEOFIsIdempotent`(只收到显式那条、第二次 `Receive` 立即 `ErrControlStopped`、result 回显显式 commandId);既有 `TestBackend_ControlReaderJoinAndReadFailure/reader failure joins within bound` 的期望从 INTERNAL_ERROR 改为「有限时间内收口并以 stopped 退出」(契约变更随本任务同步)。E2E 新增 `internal/backend/e2e_stdin_windows_test.go`,用 `go build` 出真实 `auto-mas-runtime.exe` 黑盒驱动(既有 E2E 直接调 `ManagedSupervisor` 绕过了 CLI reader,修复点恰在那里):`TestBackendE2E_StdinEOFShutsDownGracefully` 到 `running` 后关 stdin,断言 exit 0、`stopping_backend` → `stopped`、`result.status=stopped` 且无 `controlCommandId`、无 `BACKEND_FORCE_TERMINATED`、stdout 无非 NDJSON 行、后端 PID 退出、端口 / Mutex / 事务无残留;`TestBackendE2E_StdinEOFWithBrokenStdoutStillExits` 先关自建 stdout 管道读端再关 stdin,Runtime 2.9 秒内退出(exit 20,stderr `write /dev/stdout: The pipe is being closed`),后端 PID 退出、资源无残留。红灯证据:把 `internal/cli/backend.go` 换回 `33c6bc8` 版本重跑主用例,Runtime 在 `hello`/`running` 之后 30 秒不退出(`runtime did not exit within 30s after stdin EOF`)。 验证:gofmt -l(无输出)/ `go vet ./...` / `go build -buildvcs=false ./...` / `go test ./... -count=1` / `git diff --check` 各 exit 0;`go test ./testdata/fakebackend ./testdata/fakeuv -count=1` exit 0;`go test ./internal/protocol -count=100` exit 0;`go test ./internal/cli -run 'StdinEOF|StdinReadError|ThenEOF|ControlReaderJoin|PortArgument' -count=20` exit 0;GCC 目录前置 PATH 后 `go test -race ./... -count=1` exit 0(全部 19 个含测试的包 ok)。本机 36163 全程被用户正式版 AUTO-MAS 占用,E2E 在此前提下全绿。 - **收尾(2026-09-02,主线程要求)**:原登记的已知限制「stdout 已断时后端被 Job 硬杀」按 C13 结论第 4 条修订为「stdout 已断不影响优雅关闭」——见本条目下方的收尾证据。 + **收尾(2026-09-02,主线程要求,`23033f9`)**:原登记的已知限制「stdout 已断时后端被 Job 硬杀」按 C13 结论第 4 条修订为「stdout 已断不影响优雅关闭」。实现:`finishControlShutdown` 把首个协议输出错误存起来,照常 `POST /api/core/close` → `waitProcessExit`(沿用 `--shutdown-timeout`)→ `cleanupProcess`(超时才收 Job)→ `finalizeResources`,之后的 `stopped` / 强制终止 warning 写失败同样只记录,最后把输出错误(与清理错误 join、输出错误在前)返回,CLI 按既有分类得到 `OUTPUT_WRITE_FAILED`(退出码 20)并在 stderr 留 `write protocol event: ... The pipe is being closed` 诊断;主循环里 `attempt.gate` 因 `OUTPUT_WRITE_FAILED` 故障而 mailbox 已 latch 了 shutdown 时(`shutdownLatchedBehindOutputFault`),同样改走 `finishControlShutdown` 而不是硬杀——宿主崩溃时后端若恰好在输出日志,gate 会先于 EOF 观察到管道失效。判定依据是「已进入 shutdown」,显式与隐式同样受益。测试:假后端新增 `shutdownFile`(只在 close → `server.Shutdown` 成功后落盘,被 Job 硬杀时不出现;`TestFakeBackend_ListensOnSupervisedPortEnv` 顺带断言);`TestBackend_ShutdownContinuesGracefullyWhenOutputFails`(running 之后让 `EmitState` 持续失败再 shutdown:HTTP close 仍被调用、进程 terminated/waitEmpty/closed、无强制终止 warning、返回码 `OUTPUT_WRITE_FAILED`;红灯 `shutdown steps = []`);`TestBackendE2E_StdinEOFWithBrokenStdoutStillExits` 加强为退出码 20 + `assertE2EBackendClosedGracefully`(红灯 `timed out waiting for file backend.shutdown`),`TestBackendE2E_StdinEOFShutsDownGracefully` 同样断言优雅标记。验证:gofmt -l(无输出)/ `go vet ./...` / `go build -buildvcs=false ./...` / `go test ./... -count=1` / `git diff --check` 各 exit 0;`go test ./testdata/fakebackend ./testdata/fakeuv -count=1`、`go test ./internal/protocol -count=100`、`go test ./internal/cli -run 'StdinEOF|StdinReadError|ThenEOF|ControlReaderJoin|PortArgument' -count=20` 各 exit 0;GCC 前置 PATH 后 `go test -race ./... -count=1` exit 0(19 个含测试的包 ok)。 残余窗口:gate 故障早于 EOF 被读到(mailbox 尚无终止命令)仍走既有硬杀路径。 --- @@ -1229,6 +1229,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 | 日期 | 变更 | 操作者 | | --- | --- | --- | +| 2026-09-02 | **T13.8 收尾**(`23033f9`):C13 结论第 4 条修订为「stdout 已断不影响优雅关闭」——进入 shutdown 之后协议输出失败只记录不中断,仍 HTTP close、等关闭预算、超时才收 Job、清理 Mutex 与事务,最后以 `OUTPUT_WRITE_FAILED` 退出并在 stderr 留诊断;gate 因输出故障而 shutdown 已 latch 时同样走优雅关闭。理由:宿主崩溃时后端可能正在跑 MAA / 游戏任务,硬杀会留半截状态。架构设计两处、设计文档同步;假后端新增 `shutdownFile` 优雅标记,E2E 与单测据此断言后端被 HTTP 优雅关闭。验证门五条、protocol ×100、cli 定向 ×20 与完整 race 均 exit 0 | Claude | | 2026-09-02 | 完成 **T13.7**(`f7d5edb`)与 **T13.8**(`41c51d3`),均在分支 `feat/t13-port-eof-20260902` 上按四段式落地并合入 `integ/t13-20260901`。T13.7:`backend supervise --port`(1024~65535,缺省 managed 36163 / development 36164);`internal/health` 成为端口与地址的唯一来源(`DefaultPort` / `BaseURL` / `HealthURLForPort` / `ValidPort`),健康地址、`/api/core/close` 与 `baseUrl` 全部派生;`internal/uv` 注入受控键 `AUTO_MAS_SUPERVISED_PORT`;假后端改从该变量取监听端口;E2E 改用空闲端口,本机 36163 被正式版占用的前提下全绿。T13.8:`ControlReader.InputClosed()` + CLI 把 `hello` 之后的 stdin EOF / 读取出错翻译成无 commandId 的隐式 shutdown,走同一条优雅关闭路径;新增真实 exe 黑盒 E2E(stdin EOF 优雅关闭、stdout 已断仍退出),红灯为旧版 30 秒不退出。标准验证门、protocol ×100、定向 cli ×20 与完整 race 均 exit 0。遗留:TODO-PY-8 跨仓联合验收;stdout 已断时后端走 Job 硬杀而非 HTTP 优雅关闭 | Claude | | 2026-09-02 | 真机联调暴露两个首版前必须修掉的问题,按红线第 2 条先改文档:新增 [增补 1 **C12**「受监督端口由 Runtime 注入」](./契约补充-v1-增补1.md#c12受监督端口由-runtime-注入)(`backend supervise --port `,`1024`~`65535`,缺省 managed 36163 / development 36164;注入 `AUTO_MAS_SUPERVISED_PORT` 并并入受控监督键集合;健康 / 关闭地址与 `baseUrl` 由它派生;后端受监督时只认该变量、缺失回退 36163;协议不新增字段)与 [**C13**「宿主断开即关闭」](./契约补充-v1-增补1.md#c13宿主断开即关闭)(仅 `backend supervise`:`hello` 之后 stdin EOF 或读取出错视为隐式 `shutdown`,同一条优雅关闭路径与结局;stdout 已断也须能退出;其他命令不变);同步修订 C7 结论第 1 条、`契约补充-v1.md` C1、`架构设计.md` 五处(后端启动流程、后端关闭流程、CLI 设计、标准输入控制、后端健康检查与关闭契约)与 TODO-PY-8、D-open-2;M13 下立项 **T13.7**、**T13.8**,设计与计划见 `doc/current/M13/设计-T13.7-*.md`、`设计-T13.8-*.md`。背景:C7 把受监督端口钉死 36163,与 AUTO-MAS dev 已合入的 PR #451 开发版(36164)/ 正式版(36163)并存约定冲突,`backend supervise --mode development` 必撞正在运行的正式版,Runtime 自己的 E2E 在该机器上也必然失败;stdin EOF 只 `return nil` 让宿主崩溃后 Runtime 与后端继续占着端口与 `backend` Mutex,下次启动得到不可重试的 `BACKEND_ALREADY_RUNNING` | Claude | | 2026-09-02 | **T13.5 收口为 ✅**:「池目录重新分类」半段**按设计关闭**,不再实施,对应验收项撤销。注入面落地后,池真正可重建的 uv 缓存与受管解释器已物理落在 `/runtime/cache/uv` 与 `/runtime/environment/python`(真机 `manifest.json` 的 `installerMetadata.cache.path` / `installer.executable` 证实,`config/maafw_runtime_pool/` 下已无 `cache/`),本就在 `cleanup`/`repair` 的既有分类内;`config/maafw_runtime_pool/runtimes//` 只剩 venv 与 manifest,让 Runtime 识别该布局并删 venv 留 manifest 等于维护池清单,触红线第 4、6 条。分工改为 Runtime 只处理自己的目录,池 venv 因基解释器缺失失效后由后端自行判定重建。同步修订增补 1 C11 结论第 4 条、`架构设计.md` 三处(文档状态修订项、插件环境职责边界增补段、dev 基线目录分类表)、5.1 TODO-PY-13 的对应要求与 AGENTS.md 状态表;T13.6 保持 ⏸ | Claude | From 88d8a6f0c614cadb2ff560cb737959e3f236648f Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 23:49:24 +0200 Subject: [PATCH 49/57] =?UTF-8?q?docs:=20=E5=AE=9A=E7=A8=BF=E5=A2=9E?= =?UTF-8?q?=E8=A1=A5=201=20C14=20=E5=AD=A4=E5=84=BF=E5=9B=9E=E6=94=B6?= =?UTF-8?q?=E4=B8=8E=E5=BC=BA=E5=88=B6=E7=BB=88=E6=AD=A2=E5=88=86=E7=A6=BB?= =?UTF-8?q?=E5=B9=B6=E7=AB=8B=E9=A1=B9=20T13.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 协议 v1 内追加 warning 码 BACKEND_ORPHANS_REAPED(后端主进程自己退出、Job 残留孤儿被回收), BACKEND_FORCE_TERMINATED 收窄为主进程被强杀;架构设计错误码全集与关闭契约同步,任务拆分 新增 T13.9 与变更记录,AGENTS.md 状态表同步,设计与计划文档就位。 Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 2 +- ...10\346\255\242\345\210\206\347\246\273.md" | 43 +++++++++++++++++++ ...73\345\212\241\346\213\206\345\210\206.md" | 5 +++ ...5\205\205-v1-\345\242\236\350\241\2451.md" | 38 ++++++++++++++-- ...66\346\236\204\350\256\276\350\256\241.md" | 8 +++- 5 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 "doc/current/M13/\350\256\276\350\256\241-T13.9-\345\255\244\345\204\277\345\233\236\346\224\266\344\270\216\345\274\272\345\210\266\347\273\210\346\255\242\345\210\206\347\246\273.md" diff --git a/AGENTS.md b/AGENTS.md index ba133b4..5019569 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Vue/Electron/Python 的改写、CI/CD 发布流程本身。 | M10 工程可维护性收敛 | T10.1 文档信息架构已完成;后续阶段见维护设计 | | M11 跨平台适配(Linux/macOS) | 规划中(决策 D7,2026-08-04 立项);仅 T11.1 设计任务可执行 | | M12 遥测与错误观测(Sentry-only) | T12.1、T12.2、T12.4、T12.5、T12.7 已完成;T12.6 本地发布配置已提交,待 Repository secret 与授权后的远端验收;T12.3/Umami 已取消 | -| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;T13.1(后端工作目录)、T13.2(Job 允许显式脱离)、T13.3(关闭超时参数化,含优雅关闭误报修复)、T13.4(锁副本改写参与包索引镜像轮换,显式 package-index 排最前)**已完成**;T13.5 **已完成**(注入面 `020b3b9`:`AUTO_MAS_UV_CACHE_DIR` / `AUTO_MAS_UV_PYTHON_INSTALL_DIR` / `AUTO_MAS_MIRROR_PACKAGE_INDEX` / `AUTO_MAS_MIRROR_PYTHON`;池目录重新分类按设计关闭——Runtime 的 repair/cleanup 只处理自己的目录,池 venv 失效后由后端判定重建);T13.6 ⏸ 未开始;2026-09-02 真机联调后按增补 1 **C12 / C13** 完成 T13.7(`f7d5edb`:`backend supervise --port`,缺省 managed 36163 / development 36164,注入 `AUTO_MAS_SUPERVISED_PORT`,健康/关闭地址与 `baseUrl` 由 `internal/health` 派生,E2E 改用空闲端口)与 T13.8(`41c51d3`,收尾 `23033f9`:`backend supervise` 的 stdin EOF / 读取出错视为隐式 shutdown,stdout 已断不影响 HTTP 优雅关闭,真实 exe 黑盒 E2E 锁定,宿主崩溃不再留孤儿)**已完成** | +| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;T13.1(后端工作目录)、T13.2(Job 允许显式脱离)、T13.3(关闭超时参数化,含优雅关闭误报修复)、T13.4(锁副本改写参与包索引镜像轮换,显式 package-index 排最前)**已完成**;T13.5 **已完成**(注入面 `020b3b9`:`AUTO_MAS_UV_CACHE_DIR` / `AUTO_MAS_UV_PYTHON_INSTALL_DIR` / `AUTO_MAS_MIRROR_PACKAGE_INDEX` / `AUTO_MAS_MIRROR_PYTHON`;池目录重新分类按设计关闭——Runtime 的 repair/cleanup 只处理自己的目录,池 venv 失效后由后端判定重建);T13.6 ⏸ 未开始;2026-09-02 真机联调后按增补 1 **C12 / C13** 完成 T13.7(`f7d5edb`:`backend supervise --port`,缺省 managed 36163 / development 36164,注入 `AUTO_MAS_SUPERVISED_PORT`,健康/关闭地址与 `baseUrl` 由 `internal/health` 派生,E2E 改用空闲端口)与 T13.8(`41c51d3`,收尾 `23033f9`:`backend supervise` 的 stdin EOF / 读取出错视为隐式 shutdown,stdout 已断不影响 HTTP 优雅关闭,真实 exe 黑盒 E2E 锁定,宿主崩溃不再留孤儿)**已完成**;T13.9(增补 1 C14:新 warning `BACKEND_ORPHANS_REAPED`,`BACKEND_FORCE_TERMINATED` 收窄为主进程被强杀)🚧 进行中 | 代码现状: diff --git "a/doc/current/M13/\350\256\276\350\256\241-T13.9-\345\255\244\345\204\277\345\233\236\346\224\266\344\270\216\345\274\272\345\210\266\347\273\210\346\255\242\345\210\206\347\246\273.md" "b/doc/current/M13/\350\256\276\350\256\241-T13.9-\345\255\244\345\204\277\345\233\236\346\224\266\344\270\216\345\274\272\345\210\266\347\273\210\346\255\242\345\210\206\347\246\273.md" new file mode 100644 index 0000000..85c0f61 --- /dev/null +++ "b/doc/current/M13/\350\256\276\350\256\241-T13.9-\345\255\244\345\204\277\345\233\236\346\224\266\344\270\216\345\274\272\345\210\266\347\273\210\346\255\242\345\210\206\347\246\273.md" @@ -0,0 +1,43 @@ +# 设计与计划 T13.9 孤儿回收与强制终止分离 + +- 契约:[增补 1 C14](../../契约补充-v1-增补1.md#c14孤儿回收与强制终止分离) +- 任务:[任务拆分 T13.9](../../任务拆分.md) +- 状态:设计 + 计划 + +## 目标 + +关闭收尾时把「后端主进程被强杀」与「主进程自己退出、Job 残留孤儿被回收」分开报告:前者仍是 +`BACKEND_FORCE_TERMINATED`,后者改报新 warning `BACKEND_ORPHANS_REAPED`,`details` 列出被回收的孤儿。 + +## 判定来源 + +`cleanupProcess` 本就有两条入口:`<-proc.Exited()` 分支(根进程已自行退出,只需回收残留成员)与 +`default` 分支(根进程仍存活,`Terminate` 整棵树)。因此: + +- `default` 分支 → `rootForced = true`; +- `Exited` 分支发现残留成员 → `forced = true`、`rootForced = false`,残留成员(排除根 PID)即孤儿清单; +- 首次 `WaitEmpty` 失败后的兜底强杀:进入前再取一次快照作为孤儿清单(排除根 PID),`rootForced` + 维持前面的判定。 + +`processCleanup` 因此新增 `rootForced bool` 与 `orphans []process.Info`。`finishControlShutdown` 的发射规则: +`!graceful || cleanup.rootForced` → `BACKEND_FORCE_TERMINATED`(details 不变);否则 `cleanup.forced` +→ `BACKEND_ORPHANS_REAPED`,details = 既有 pid/logPath/exitCode + `orphanCount` + `orphans[≤20]{pid, executable}` ++ `orphansTruncated`;快照失败时 `orphanCount = -1`、`orphans = []`。 + +## 不负责 + +- 不改 `BACKEND_SHUTDOWN_FAILED`(树无法确认清空);不改 cancel 路径以外的任何 stage / state; +- 不试图替后端清理孤儿(那是 AUTO-MAS ProcessManager 的事),Runtime 只如实报告。 + +## Task 拆分 + +1. `internal/protocol`:`CodeBackendOrphansReaped` 定义 + 文档表同步;红灯 `TestErrorDefinitionsMatchDoc` + / warning-only 列表; +2. `internal/backend`:`processCleanup.rootForced` / `orphans`,`emitOrphansWarning`;红灯 + `TestBackend_OrphansReapedWarnsWithoutForceTerminated`、`TestBackend_ForceTerminatedOnlyWhenRootKilled`, + 既有 `TestBackend_GracefulShutdownForceClearsDescendants` 期望改为孤儿 warning; +3. E2E:`TestBackendE2E_ShutdownWithSurvivingDescendantWarnsOrphansReaped`(假后端留下 detached 孙进程后 + 正常退出 → 新 warning,details 含孙进程 PID 与 `fakebackend.exe`),`TestBackendE2E_ForcedShutdownReapsTree` + 保持 `BACKEND_FORCE_TERMINATED` 且不得出现孤儿 warning。 + +验证:标准门 + `go test -race ./... -count=1`。 diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index 8c56172..43e311f 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -1031,6 +1031,10 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 测试:`TestBackendSupervise_StdinEOFSubmitsImplicitShutdown`(监督器收到 `CommandID==""` 的 shutdown,`result.status=stopped`、无 `controlCommandId`、exit 0)、`TestBackendSupervise_StdinReadErrorSubmitsImplicitShutdown`(读错误同样得到隐式 shutdown、exit 0、stderr 含 `stdin read failed`)、`TestBackendSupervise_ExplicitShutdownThenEOFIsIdempotent`(只收到显式那条、第二次 `Receive` 立即 `ErrControlStopped`、result 回显显式 commandId);既有 `TestBackend_ControlReaderJoinAndReadFailure/reader failure joins within bound` 的期望从 INTERNAL_ERROR 改为「有限时间内收口并以 stopped 退出」(契约变更随本任务同步)。E2E 新增 `internal/backend/e2e_stdin_windows_test.go`,用 `go build` 出真实 `auto-mas-runtime.exe` 黑盒驱动(既有 E2E 直接调 `ManagedSupervisor` 绕过了 CLI reader,修复点恰在那里):`TestBackendE2E_StdinEOFShutsDownGracefully` 到 `running` 后关 stdin,断言 exit 0、`stopping_backend` → `stopped`、`result.status=stopped` 且无 `controlCommandId`、无 `BACKEND_FORCE_TERMINATED`、stdout 无非 NDJSON 行、后端 PID 退出、端口 / Mutex / 事务无残留;`TestBackendE2E_StdinEOFWithBrokenStdoutStillExits` 先关自建 stdout 管道读端再关 stdin,Runtime 2.9 秒内退出(exit 20,stderr `write /dev/stdout: The pipe is being closed`),后端 PID 退出、资源无残留。红灯证据:把 `internal/cli/backend.go` 换回 `33c6bc8` 版本重跑主用例,Runtime 在 `hello`/`running` 之后 30 秒不退出(`runtime did not exit within 30s after stdin EOF`)。 验证:gofmt -l(无输出)/ `go vet ./...` / `go build -buildvcs=false ./...` / `go test ./... -count=1` / `git diff --check` 各 exit 0;`go test ./testdata/fakebackend ./testdata/fakeuv -count=1` exit 0;`go test ./internal/protocol -count=100` exit 0;`go test ./internal/cli -run 'StdinEOF|StdinReadError|ThenEOF|ControlReaderJoin|PortArgument' -count=20` exit 0;GCC 目录前置 PATH 后 `go test -race ./... -count=1` exit 0(全部 19 个含测试的包 ok)。本机 36163 全程被用户正式版 AUTO-MAS 占用,E2E 在此前提下全绿。 **收尾(2026-09-02,主线程要求,`23033f9`)**:原登记的已知限制「stdout 已断时后端被 Job 硬杀」按 C13 结论第 4 条修订为「stdout 已断不影响优雅关闭」。实现:`finishControlShutdown` 把首个协议输出错误存起来,照常 `POST /api/core/close` → `waitProcessExit`(沿用 `--shutdown-timeout`)→ `cleanupProcess`(超时才收 Job)→ `finalizeResources`,之后的 `stopped` / 强制终止 warning 写失败同样只记录,最后把输出错误(与清理错误 join、输出错误在前)返回,CLI 按既有分类得到 `OUTPUT_WRITE_FAILED`(退出码 20)并在 stderr 留 `write protocol event: ... The pipe is being closed` 诊断;主循环里 `attempt.gate` 因 `OUTPUT_WRITE_FAILED` 故障而 mailbox 已 latch 了 shutdown 时(`shutdownLatchedBehindOutputFault`),同样改走 `finishControlShutdown` 而不是硬杀——宿主崩溃时后端若恰好在输出日志,gate 会先于 EOF 观察到管道失效。判定依据是「已进入 shutdown」,显式与隐式同样受益。测试:假后端新增 `shutdownFile`(只在 close → `server.Shutdown` 成功后落盘,被 Job 硬杀时不出现;`TestFakeBackend_ListensOnSupervisedPortEnv` 顺带断言);`TestBackend_ShutdownContinuesGracefullyWhenOutputFails`(running 之后让 `EmitState` 持续失败再 shutdown:HTTP close 仍被调用、进程 terminated/waitEmpty/closed、无强制终止 warning、返回码 `OUTPUT_WRITE_FAILED`;红灯 `shutdown steps = []`);`TestBackendE2E_StdinEOFWithBrokenStdoutStillExits` 加强为退出码 20 + `assertE2EBackendClosedGracefully`(红灯 `timed out waiting for file backend.shutdown`),`TestBackendE2E_StdinEOFShutsDownGracefully` 同样断言优雅标记。验证:gofmt -l(无输出)/ `go vet ./...` / `go build -buildvcs=false ./...` / `go test ./... -count=1` / `git diff --check` 各 exit 0;`go test ./testdata/fakebackend ./testdata/fakeuv -count=1`、`go test ./internal/protocol -count=100`、`go test ./internal/cli -run 'StdinEOF|StdinReadError|ThenEOF|ControlReaderJoin|PortArgument' -count=20` 各 exit 0;GCC 前置 PATH 后 `go test -race ./... -count=1` exit 0(19 个含测试的包 ok)。 残余窗口:gate 故障早于 EOF 被读到(mailbox 尚无终止命令)仍走既有硬杀路径。 +- [ ] **T13.9 孤儿回收与强制终止分离**(M)🚧 2026-09-02 立项 + - 依赖:M6;契约 [增补 1 C14](./契约补充-v1-增补1.md#c14孤儿回收与强制终止分离);AUTO-MAS 侧无改动 + - 内容:真机设备轮(`rt-dev/evidence/rounda.ndjson`、`rounda_orphan_sim.txt`):后端收到 close 后优雅退出(exit 0),但脚本子进程 `cmd.exe` 被后端 terminate 时孙进程 `PING.EXE` 留在 Job 里,Runtime 收尾 `TerminateJobObject` 后报 `BACKEND_FORCE_TERMINATED`(`exitCode: 0`)——名不副实,真实场景脚本进程常留孤儿,每次正常关闭都会报,会淹没真正的强杀。改为:后端主进程自己正常退出、Job 里仍有其他进程被回收时报新 warning `BACKEND_ORPHANS_REAPED`(`details`:`orphanCount`、`orphans[≤20]{pid, executable}`、`orphansTruncated`,加既有 `pid`/`logPath`/`exitCode`);只有主进程超时未退被 Job 强杀才是 `BACKEND_FORCE_TERMINATED`;两者互斥。新增 warning 码属契约变更,先改增补 1(C14)与架构文档错误码表。 + - 验收:`internal/protocol` 错误码全集与文档表一致,新码为 warning-only;单元覆盖两种收尾分支的判定(主进程自己退出 + 残留成员 → `BACKEND_ORPHANS_REAPED` 且 details 正确、无 `BACKEND_FORCE_TERMINATED`;主进程超时被强杀 → 只有 `BACKEND_FORCE_TERMINATED`);E2E 用假后端拉起一个会留孤儿的 detached 子进程后正常退出,断言新 warning 与 details(孤儿 PID 与映像名);既有「close 被拒 → 强杀」E2E 仍报 `BACKEND_FORCE_TERMINATED`;验证门 + race。 --- @@ -1229,6 +1233,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 | 日期 | 变更 | 操作者 | | --- | --- | --- | +| 2026-09-02 | 真机设备轮暴露 `BACKEND_FORCE_TERMINATED` 名不副实(后端自己退出、被回收的是脚本遗留的 `PING.EXE` 孤儿),按红线第 2 条先改文档:新增 [增补 1 **C14**「孤儿回收与强制终止分离」](./契约补充-v1-增补1.md#c14孤儿回收与强制终止分离)——协议 v1 内追加 warning 码 `BACKEND_ORPHANS_REAPED`(`orphanCount` / `orphans[≤20]{pid, executable}` / `orphansTruncated`),`BACKEND_FORCE_TERMINATED` 收窄为「后端主进程被强杀」,两者互斥;架构设计错误码全集与关闭契约同步;M13 下立项 **T13.9** | Claude | | 2026-09-02 | **T13.8 收尾**(`23033f9`):C13 结论第 4 条修订为「stdout 已断不影响优雅关闭」——进入 shutdown 之后协议输出失败只记录不中断,仍 HTTP close、等关闭预算、超时才收 Job、清理 Mutex 与事务,最后以 `OUTPUT_WRITE_FAILED` 退出并在 stderr 留诊断;gate 因输出故障而 shutdown 已 latch 时同样走优雅关闭。理由:宿主崩溃时后端可能正在跑 MAA / 游戏任务,硬杀会留半截状态。架构设计两处、设计文档同步;假后端新增 `shutdownFile` 优雅标记,E2E 与单测据此断言后端被 HTTP 优雅关闭。验证门五条、protocol ×100、cli 定向 ×20 与完整 race 均 exit 0 | Claude | | 2026-09-02 | 完成 **T13.7**(`f7d5edb`)与 **T13.8**(`41c51d3`),均在分支 `feat/t13-port-eof-20260902` 上按四段式落地并合入 `integ/t13-20260901`。T13.7:`backend supervise --port`(1024~65535,缺省 managed 36163 / development 36164);`internal/health` 成为端口与地址的唯一来源(`DefaultPort` / `BaseURL` / `HealthURLForPort` / `ValidPort`),健康地址、`/api/core/close` 与 `baseUrl` 全部派生;`internal/uv` 注入受控键 `AUTO_MAS_SUPERVISED_PORT`;假后端改从该变量取监听端口;E2E 改用空闲端口,本机 36163 被正式版占用的前提下全绿。T13.8:`ControlReader.InputClosed()` + CLI 把 `hello` 之后的 stdin EOF / 读取出错翻译成无 commandId 的隐式 shutdown,走同一条优雅关闭路径;新增真实 exe 黑盒 E2E(stdin EOF 优雅关闭、stdout 已断仍退出),红灯为旧版 30 秒不退出。标准验证门、protocol ×100、定向 cli ×20 与完整 race 均 exit 0。遗留:TODO-PY-8 跨仓联合验收;stdout 已断时后端走 Job 硬杀而非 HTTP 优雅关闭 | Claude | | 2026-09-02 | 真机联调暴露两个首版前必须修掉的问题,按红线第 2 条先改文档:新增 [增补 1 **C12**「受监督端口由 Runtime 注入」](./契约补充-v1-增补1.md#c12受监督端口由-runtime-注入)(`backend supervise --port `,`1024`~`65535`,缺省 managed 36163 / development 36164;注入 `AUTO_MAS_SUPERVISED_PORT` 并并入受控监督键集合;健康 / 关闭地址与 `baseUrl` 由它派生;后端受监督时只认该变量、缺失回退 36163;协议不新增字段)与 [**C13**「宿主断开即关闭」](./契约补充-v1-增补1.md#c13宿主断开即关闭)(仅 `backend supervise`:`hello` 之后 stdin EOF 或读取出错视为隐式 `shutdown`,同一条优雅关闭路径与结局;stdout 已断也须能退出;其他命令不变);同步修订 C7 结论第 1 条、`契约补充-v1.md` C1、`架构设计.md` 五处(后端启动流程、后端关闭流程、CLI 设计、标准输入控制、后端健康检查与关闭契约)与 TODO-PY-8、D-open-2;M13 下立项 **T13.7**、**T13.8**,设计与计划见 `doc/current/M13/设计-T13.7-*.md`、`设计-T13.8-*.md`。背景:C7 把受监督端口钉死 36163,与 AUTO-MAS dev 已合入的 PR #451 开发版(36164)/ 正式版(36163)并存约定冲突,`backend supervise --mode development` 必撞正在运行的正式版,Runtime 自己的 E2E 在该机器上也必然失败;stdin EOF 只 `return nil` 让宿主崩溃后 Runtime 与后端继续占着端口与 `backend` Mutex,下次启动得到不可重试的 `BACKEND_ALREADY_RUNNING` | Claude | diff --git "a/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" "b/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" index 4d6b0af..1f55605 100644 --- "a/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" +++ "b/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" @@ -11,12 +11,13 @@ - 修订:2026-09-01 修订 C10 对显式 `--mirror package-index=` 的处理,并把镜像改写前缀由推导改为显式声明(T13.4 实施期间,见 C10) - 修订:2026-09-02 修订 C11 结论第 4 条:「池目录的重新分类」按设计关闭,改为「Runtime 的 `repair` / `cleanup` 只处理自己的目录;池 venv 因基解释器缺失失效后由后端自行判定重建」(T13.5 收口时,见 C11) - 修订:2026-09-02 新增 **C12「受监督端口由 Runtime 注入」** 与 **C13「宿主断开即关闭」**,并修订 C7 结论第 1 条与 C1(T13.7 / T13.8,真机联调暴露;见 C12、C13) +- 修订:2026-09-02 新增 **C14「孤儿回收与强制终止分离」**:协议 v1 内追加 warning 码 `BACKEND_ORPHANS_REAPED`,`BACKEND_FORCE_TERMINATED` 收窄为「后端主进程被强杀」(T13.9,真机设备轮暴露;见 C14) 本文档是对 [契约补充-v1](./契约补充-v1.md) 的**增量修订**,定稿六项此前未冻结或与实现矛盾的对外契约,并修订 C2、C4 各一条; -2026-09-02 追加 C12、C13 两项。 +2026-09-02 追加 C12、C13、C14 三项。 `契约补充-v1.md` 开头已规定「对外字段、字面量或环境变量发生变化时必须升级或增量修订共同契约和测试」,本文档走的正是增量修订这条路: -八项都不新增、删除或改名协议字段,也不改变任何 `stage` / `state` / 错误码字面量,因此协议版本保持 `1` -(C12 新增一个注入环境变量与一个 CLI 参数,属于允许追加的集合;`baseUrl` 字段本身不变,只是取值不再恒定)。 +九项都不删除或改名协议字段,也不改变任何既有 `stage` / `state` / 错误码字面量的语义,因此协议版本保持 `1` +(C12 新增一个注入环境变量与一个 CLI 参数,C14 追加一个 warning 码,都属于架构文档允许在 v1 内追加的集合;`baseUrl` 字段本身不变,只是取值不再恒定)。 优先级:**本文档 > `契约补充-v1.md` > `架构设计.md` > `任务拆分.md`**。本文档与 `契约补充-v1.md` 冲突时以本文档为准;未被本文档触及的条目一律沿用原文。 @@ -34,6 +35,7 @@ | C11 | MaaFW 运行池的归属 | 只统一基础设施:Runtime 开放共享 uv 缓存与受管 Python 目录、下发有序镜像源;「装什么」仍留在后端 | | C12 | 受监督端口 | `backend supervise --port `(1024~65535),缺省 managed 36163 / development 36164;Runtime 注入 `AUTO_MAS_SUPERVISED_PORT`,健康/关闭地址与 `baseUrl` 由它派生;后端受监督时只认该变量、缺失回退 36163(2026-09-02 新增) | | C13 | 宿主断开即关闭 | 仅 `backend supervise`:`hello` 之后 stdin EOF 或读取出错视为隐式 `shutdown`,走同一条优雅关闭路径;stdout 已断不影响优雅关闭,收口后以 `OUTPUT_WRITE_FAILED` 退出;其他命令不变(2026-09-02 新增) | +| C14 | 孤儿回收与强制终止分离 | 后端主进程自己退出、Job 里残留进程被回收 → 新 warning `BACKEND_ORPHANS_REAPED`(`orphanCount` / `orphans[≤20]{pid, executable}` / `orphansTruncated`);`BACKEND_FORCE_TERMINATED` 只在后端主进程超时未退被 Job 强杀时发出(2026-09-02 新增) | --- @@ -161,7 +163,7 @@ Runtime `T13.2`(`internal/process/job_windows.go` 与对应 Windows E2E);A 3. 默认值**待 AUTO-MAS 侧实测数据到位后再调**,本次不改; 4. 就绪预算(总启动超时 60 秒、轮询 500 毫秒、单次请求 2 秒、连续成功 2 次)本次**不参数化**,保持编译期常量。 -关闭流程的其余语义不变:超时后仍关闭 Job Object 兜底,确认进程树已清空即输出 `BACKEND_FORCE_TERMINATED` 警告并正常完成关闭。 +关闭流程的其余语义不变:超时后仍关闭 Job Object 兜底,确认进程树已清空即输出 `BACKEND_FORCE_TERMINATED` 警告并正常完成关闭(2026-09-02 [C14](#c14孤儿回收与强制终止分离) 收窄:只有后端主进程被强杀才是该警告;主进程自己退出、残留孤儿被回收改报 `BACKEND_ORPHANS_REAPED`)。 ### 依据 @@ -373,6 +375,33 @@ Runtime `T13.8`(`internal/protocol/control.go`、`internal/cli/backend.go` 与 --- +## C14:孤儿回收与强制终止分离 + +> 2026-09-02 定稿(T13.9)。协议 v1 内追加一个 warning 码 `BACKEND_ORPHANS_REAPED`;既有码不改名、不改语义。 + +### 结论 + +1. **`BACKEND_FORCE_TERMINATED` 只表示后端主进程被强杀**:`POST /api/core/close` 失败或后端在关闭预算(C9)内没有退出,Runtime 关闭 Job Object 把它连同整棵树一起终止。`details` 沿用既有的 `pid` / `logPath` / `exitCode`。 +2. **后端主进程自己正常退出、但 Job 里仍有其他进程被回收时,改报 `BACKEND_ORPHANS_REAPED`**(warning,退出码 0、不可重试、`remediation = [open-log]`)。`details` 在既有 `pid` / `logPath` / `exitCode` 之外给出:`orphanCount`(回收的进程数)、`orphans`(数组,每项 `{pid, executable}`,最多 **20** 项)、`orphansTruncated`(超过 20 项时为 `true`)。`executable` 是快照里的映像路径,快照取不到时为空串。 +3. 两条 warning 互斥:一次关闭最多只发其中一条;后端主进程被强杀时即使树里也有孤儿,仍只发 `BACKEND_FORCE_TERMINATED`。 +4. `BACKEND_ORPHANS_REAPED` 是 `warning`-only 码,不能作为 `error` / 失败 `result` 的主码;无法确认进程树清空仍是 `BACKEND_SHUTDOWN_FAILED`,本条不改。 +5. 适用于所有关闭路径:显式 shutdown、隐式 shutdown(C13)、cancel 后的收尾,以及 stdout 已断的收尾(C13 结论第 4 条)。 + +### 依据 + +- 2026-09-02 真机设备轮(`rt-dev/evidence/rounda.ndjson`、`rounda_orphan_sim.txt`):后端收到 close 后优雅退出(exit 0),但它拉起的脚本子进程 `cmd.exe` 被后端 `terminate()` 时,cmd 的孙进程 `PING.EXE` 留在 Job 里;Runtime 收尾发现 Job 非空只好 `TerminateJobObject`,于是 `result.details` 报 `BACKEND_FORCE_TERMINATED`(`exitCode: 0`)——名不副实:后端本身没有被强杀,被回收的是它遗留的孤儿; +- 真实场景里脚本进程(MAA.exe、adb 等)常留孤儿,每次正常关闭都会报这条,会把真正的强杀淹没;调用方按 `code` 判断,分不开就等于没有这条 warning。 + +### 判定规则(Runtime 侧) + +`cleanupProcess` 已经区分两条路径:根进程 `Exited` 之后才进入收尾(后端自己退了)与根进程仍存活时被 `Terminate`。前者残留的成员就是孤儿;后者是强杀。孤儿清单在终止前从 Job 成员快照中取得(排除根进程自身);快照失败时仍报 `BACKEND_ORPHANS_REAPED`,`orphans` 为空数组、`orphanCount` 取 `-1` 表示未知。 + +### 落点 + +Runtime `T13.9`(`internal/protocol/errors.go`、`internal/backend/supervisor.go`、`internal/backend/control.go`、E2E);AUTO-MAS / Electron 侧只需按 `code` 区分两条 warning,不需要改动。 + +--- + ## 新增注入环境变量 以下变量由 Runtime 在启动后端时注入,与 C2 的五个变量同属受监督进程的环境契约。**变量名与取值格式属于已冻结的对外契约**,改动须先改文档(红线第 2 条)。 @@ -406,5 +435,6 @@ Runtime `T13.8`(`internal/protocol/control.go`、`internal/cli/backend.go` 与 | C11 | T13.5 | TODO-PY-13 | | C12 | T13.7 | TODO-PY-8(改读 `AUTO_MAS_SUPERVISED_PORT`) | | C13 | T13.8 | 无(Electron 保持 stdin 打开即可) | +| C14 | T13.9 | 无(按 `code` 区分两条 warning 即可) | 验收:三关联调(development 一轮 → managed 全链路升降级各一轮 → MaaFW 真跑)见 `doc/任务拆分.md` M13 各任务的「验收」条目;C8 与 C9 的实际后果尚未实机验证,第三关就是为了验它们。 diff --git "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" index fd9c987..d5873be 100644 --- "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" +++ "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -33,6 +33,9 @@ `backend supervise` 的 stdin EOF 视为隐式 `shutdown`,宿主崩溃不再留下孤儿 Runtime 与后端。 回写「后端启动流程」「后端关闭流程」「CLI 设计」「标准输入控制」「后端健康检查与关闭契约」五处; 协议仍为 v1,不新增或改名任何字段、stage、state 与错误码 +- 修订:2026-09-02 按真机设备轮定稿 [增补 1 C14](./契约补充-v1-增补1.md#c14孤儿回收与强制终止分离): + 协议 v1 内追加 warning 码 `BACKEND_ORPHANS_REAPED`(后端主进程自己退出、Job 残留孤儿被回收), + `BACKEND_FORCE_TERMINATED` 收窄为「后端主进程被强杀」;错误码全集与关闭契约两处同步 - 当前范围:系统边界、职责划分、CLI 协议、纯 Git 更新、uv 环境、健康检查、进程监督、镜像、插件职责边界、目录安全、错误与状态契约、测试矩阵、验收标准和分阶段迁移 ## 背景 @@ -903,8 +906,9 @@ context 的取消覆盖。控制通道基础设施故障随后优先于业务错 | `BACKEND_RESTART_FAILED` | 60 | 是 | `restart-backend`、`rebuild-environment` | | `BACKEND_SHUTDOWN_FAILED` | 60 | 是 | `retry`、`open-log` | | `BACKEND_FORCE_TERMINATED` | 0 | 否 | `open-log` | +| `BACKEND_ORPHANS_REAPED` | 0 | 否 | `open-log` | -`BACKEND_FORCE_TERMINATED` 是优雅关闭超时后的 `warning`,只要 Job Object 已确认清空,关闭命令仍可成功;如果无法确认进程树已清理,则使用 `BACKEND_SHUTDOWN_FAILED`。 +`BACKEND_FORCE_TERMINATED` 是优雅关闭超时后的 `warning`:后端**主进程**在关闭预算内没有退出(或 close 请求失败),Runtime 关闭 Job Object 强杀整棵树;只要 Job Object 已确认清空,关闭命令仍可成功。**2026-09-02 增补([增补 1 C14](./契约补充-v1-增补1.md#c14孤儿回收与强制终止分离)):** 后端主进程自己正常退出、但 Job 里仍有它遗留的进程(脚本拉起的 `cmd.exe` / `PING.EXE` / MAA / adb 等孤儿)被回收时,改发 `BACKEND_ORPHANS_REAPED`,`details` 给出 `orphanCount`、`orphans`(`{pid, executable}`,最多 20 项)与 `orphansTruncated`;两者互斥,一次关闭只发其中一条,主进程被强杀时即使树里也有孤儿仍只发 `BACKEND_FORCE_TERMINATED`。两条都是 warning-only 码。如果无法确认进程树已清理,则使用 `BACKEND_SHUTDOWN_FAILED`。 ### 已确认决策 @@ -1638,7 +1642,7 @@ Runtime 通过 `AUTO_MAS_RUNTIME_PROTOCOL`、`AUTO_MAS_EXPECTED_VERSION` 和 `AU 后端初始化期间允许接口尚不可连接,或返回 `200` 且 `backgroundStatus=starting` / `running`。Runtime 把它视为尚未就绪而非立即失败;`backgroundStatus=failed`、`backgroundError` 非空、身份不匹配、进程提前退出或超过总超时才结束启动操作。失败字面量与其他状态的完整定义见 [协议 v1 契约补充](./契约补充-v1.md#c3后台初始化状态字面量)。 -收到 stdin `shutdown` 后,Runtime 调用 `POST /api/core/close` 请求优雅关闭,然后等待受管进程退出。`AUTO_MAS_SUPERVISED=1` 的优先级高于 `AUTO_MAS_DEV=1`,受监督 development 后端也必须真实退出。接口无法连接、返回失败或超过关闭超时时,Runtime 关闭 Job Object 作为兜底;只要确认进程树已经清空,就输出 `BACKEND_FORCE_TERMINATED` 警告并正常完成关闭。Electron 不直接调用该 HTTP 接口,也不直接终止 Python。 +收到 stdin `shutdown` 后,Runtime 调用 `POST /api/core/close` 请求优雅关闭,然后等待受管进程退出。`AUTO_MAS_SUPERVISED=1` 的优先级高于 `AUTO_MAS_DEV=1`,受监督 development 后端也必须真实退出。接口无法连接、返回失败或超过关闭超时时,Runtime 关闭 Job Object 作为兜底;只要确认进程树已经清空,就输出 `BACKEND_FORCE_TERMINATED` 警告并正常完成关闭。后端主进程自己退出、只是 Job 里残留了它遗留的孤儿被回收时,输出的是 `BACKEND_ORPHANS_REAPED` 而不是 `BACKEND_FORCE_TERMINATED`([增补 1 C14](./契约补充-v1-增补1.md#c14孤儿回收与强制终止分离))。Electron 不直接调用该 HTTP 接口,也不直接终止 Python。 **2026-08-31 增补:** From da710c49d85fc92a6c5f88004ce2f4e1c19e033c Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 23:52:30 +0200 Subject: [PATCH 50/57] =?UTF-8?q?feat(backend):=20=E5=AD=A4=E5=84=BF?= =?UTF-8?q?=E5=9B=9E=E6=94=B6=E6=94=B9=E6=8A=A5=20BACKEND=5FORPHANS=5FREAP?= =?UTF-8?q?ED=EF=BC=8C=E5=BC=BA=E5=88=B6=E7=BB=88=E6=AD=A2=E5=8F=AA?= =?UTF-8?q?=E7=95=99=E7=BB=99=E4=B8=BB=E8=BF=9B=E7=A8=8B=E8=A2=AB=E5=BC=BA?= =?UTF-8?q?=E6=9D=80=20(T13.9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 协议 v1 追加 warning-only 码 BACKEND_ORPHANS_REAPED(退出码 0、open-log)。cleanupProcess 区分 根进程仍存活时被 Terminate(rootForced)与根进程已自行退出、只回收残留成员两条路径,并在终止前 快照孤儿清单;finishControlShutdown 在 !graceful || rootForced 时发 BACKEND_FORCE_TERMINATED, 否则残留被回收时发 BACKEND_ORPHANS_REAPED(orphanCount / orphans[≤20]{pid, executable} / orphansTruncated)。既有「留下 detached 孙进程后自行退出」E2E 改为断言新 warning 与孙进程 PID。 Co-Authored-By: Claude Fable 5.1 --- internal/backend/control.go | 35 ++++++++- internal/backend/control_test.go | 104 ++++++++++++++++++++++++++- internal/backend/e2e_windows_test.go | 50 ++++++++++--- internal/backend/supervisor.go | 44 +++++++++++- internal/backend/supervisor_test.go | 8 +++ internal/protocol/errors.go | 4 ++ internal/protocol/errors_test.go | 2 + 7 files changed, 233 insertions(+), 14 deletions(-) diff --git a/internal/backend/control.go b/internal/backend/control.go index 5055c03..3d99c12 100644 --- a/internal/backend/control.go +++ b/internal/backend/control.go @@ -1971,8 +1971,10 @@ func (s *ManagedSupervisor) finishControlShutdown(ctx context.Context, request R if resourceErr := snapshot.finalizeResources(); resourceErr != nil { return s.emitControlFailure(request, snapshot, errors.Join(outputErr, resourceErr)) } - if !graceful || cleanup.forced { + if !graceful || cleanup.rootForced { recordOutput(emitForceWarning(request.Emitter, cleanup.details)) + } else if cleanup.forced { + recordOutput(emitOrphansWarning(request.Emitter, cleanup)) } snapshot.set(protocol.StageBackendShutdown, protocol.StateStopped, details) recordOutput(s.emitState(request.Emitter, protocol.StageBackendShutdown, protocol.StateStopped, "后端已停止", details)) @@ -1992,6 +1994,37 @@ func (s *ManagedSupervisor) shutdownTimeout(request Request) time.Duration { return defaultShutdownTimeout } +// maxReportedOrphans 是 BACKEND_ORPHANS_REAPED details 里孤儿清单的上限(增补 1 C14)。 +const maxReportedOrphans = 20 + +// emitOrphansWarning 发出增补 1 C14 的孤儿回收 warning:根进程自己退出、残留成员被 Job 回收。 +// details 在既有 pid / logPath / exitCode 之外附上 orphanCount、orphans 与 orphansTruncated。 +func emitOrphansWarning(emitter EventEmitter, cleanup processCleanup) error { + details := cloneControlDetails(cleanup.details) + reported := make([]map[string]any, 0, min(len(cleanup.orphans), maxReportedOrphans)) + for index, orphan := range cleanup.orphans { + if index == maxReportedOrphans { + break + } + reported = append(reported, map[string]any{"pid": orphan.PID, "executable": orphan.Executable}) + } + count := len(cleanup.orphans) + if cleanup.orphansUnknown && count == 0 { + count = -1 + } + details["orphanCount"] = count + details["orphans"] = reported + details["orphansTruncated"] = len(cleanup.orphans) > maxReportedOrphans + warning, err := protocol.NewWarningEvent(protocol.CodeBackendOrphansReaped, protocol.StageBackendShutdown, "后端已退出,已回收其遗留的孤儿进程", details) + if err != nil { + return err + } + if err := emitter.EmitWarning(warning); err != nil { + return newError(protocol.CodeOutputWriteFailed, protocol.StageBackendShutdown, "协议 warning 输出失败", nil, err) + } + return nil +} + func emitForceWarning(emitter EventEmitter, details map[string]any) error { warning, err := protocol.NewWarningEvent(protocol.CodeBackendForceTerminated, protocol.StageBackendShutdown, "后端已强制终止", details) if err != nil { diff --git a/internal/backend/control_test.go b/internal/backend/control_test.go index 5206c05..0483cde 100644 --- a/internal/backend/control_test.go +++ b/internal/backend/control_test.go @@ -897,8 +897,13 @@ func TestBackend_GracefulShutdownForceClearsDescendants(t *testing.T) { if err := <-done; err != nil { t.Fatalf("Supervise() error = %v, want nil after forced empty tree", err) } - if indexOfEvent(f.emitter.eventsSnapshot(), "warning:"+string(protocol.CodeBackendForceTerminated)) < 0 { - t.Fatalf("events = %#v, want force warning", f.emitter.eventsSnapshot()) + // 根进程是收到 close 后自己退出的,被兜底回收的只是残留后代:按增补 1 C14 报孤儿回收, + // 不再冒充强制终止。 + if indexOfEvent(f.emitter.eventsSnapshot(), "warning:"+string(protocol.CodeBackendOrphansReaped)) < 0 { + t.Fatalf("events = %#v, want orphans-reaped warning", f.emitter.eventsSnapshot()) + } + if indexOfEvent(f.emitter.eventsSnapshot(), "warning:"+string(protocol.CodeBackendForceTerminated)) >= 0 { + t.Fatalf("events = %#v, want no force warning when the root exited on its own", f.emitter.eventsSnapshot()) } } @@ -1590,3 +1595,98 @@ func TestBackend_ShutdownContinuesGracefullyWhenOutputFails(t *testing.T) { t.Fatalf("events = %#v, want no force warning: the backend exited on HTTP close", f.emitter.eventsSnapshot()) } } + +// TestBackend_OrphansReapedWarnsWithoutForceTerminated 锁定增补 1 C14:后端主进程收到 close +// 后自己退出,Job 里残留的孤儿被回收时报 BACKEND_ORPHANS_REAPED(details 列出孤儿), +// 且不再报 BACKEND_FORCE_TERMINATED。 +func TestBackend_OrphansReapedWarnsWithoutForceTerminated(t *testing.T) { + f := newBackendFixture(t) + f.proc.keepAlive = true + f.proc.snapshotSet = true + f.proc.snapshotMembers = []process.Info{ + {PID: f.proc.pid, Executable: "uv.exe"}, + {PID: 5150, ParentPID: 5100, Executable: `C:\Windows\System32\PING.EXE`}, + } + mailbox := NewControlMailbox(8) + f.depsHTTP = &orderedHTTPCloser{process: f.proc, record: func(string) {}} + done := make(chan error, 1) + go func() { + req := f.request() + req.Control = mailbox + done <- f.supervisorWithHTTP(t.Context(), req) + }() + waitFor(t, f.emitter.running) + if err := mailbox.Submit(context.Background(), protocol.ControlCommand{Command: protocol.ControlShutdown, CommandID: "shutdown-orphans"}); err != nil { + t.Fatalf("Submit(shutdown) error = %v", err) + } + if err := <-done; err != nil { + t.Fatalf("Supervise() error = %v, want nil", err) + } + if indexOfEvent(f.emitter.eventsSnapshot(), "warning:"+string(protocol.CodeBackendForceTerminated)) >= 0 { + t.Fatalf("events = %#v, want no BACKEND_FORCE_TERMINATED for a root that exited on its own", f.emitter.eventsSnapshot()) + } + var reaped *protocol.WarningEvent + for _, warning := range f.emitter.warningsSnapshot() { + if warning.Code == string(protocol.CodeBackendOrphansReaped) { + clone := warning + reaped = &clone + } + } + if reaped == nil { + t.Fatalf("warnings = %#v, want %s", f.emitter.warningsSnapshot(), protocol.CodeBackendOrphansReaped) + } + if reaped.Stage != protocol.StageBackendShutdown { + t.Errorf("orphans warning stage = %q, want %s", reaped.Stage, protocol.StageBackendShutdown) + } + if got := reaped.Details["orphanCount"]; got != 1 { + t.Errorf("orphanCount = %#v, want 1", got) + } + if got := reaped.Details["orphansTruncated"]; got != false { + t.Errorf("orphansTruncated = %#v, want false", got) + } + orphans, ok := reaped.Details["orphans"].([]map[string]any) + if !ok || len(orphans) != 1 { + t.Fatalf("orphans = %#v, want one entry", reaped.Details["orphans"]) + } + if orphans[0]["pid"] != uint32(5150) || orphans[0]["executable"] != `C:\Windows\System32\PING.EXE` { + t.Errorf("orphans[0] = %#v, want pid 5150 PING.EXE", orphans[0]) + } + if reaped.Details["pid"] != f.proc.pid || reaped.Details["logPath"] == nil { + t.Errorf("orphans warning details = %#v, want the existing pid/logPath facts", reaped.Details) + } +} + +// TestBackend_ForceTerminatedOnlyWhenRootKilled 是对照组:close 被拒、根进程在预算内没退出, +// Runtime 强杀整棵树——即使树里同样有别的成员,也只报 BACKEND_FORCE_TERMINATED,不报孤儿回收。 +func TestBackend_ForceTerminatedOnlyWhenRootKilled(t *testing.T) { + f := newBackendFixture(t) + f.proc.keepAlive = true + f.proc.snapshotSet = true + f.proc.snapshotMembers = []process.Info{ + {PID: f.proc.pid, Executable: "uv.exe"}, + {PID: 5150, ParentPID: f.proc.pid, Executable: "python.exe"}, + } + f.shutdownTimeout = 10 * time.Millisecond + f.depsHTTP = errorHTTPCloser{} + mailbox := NewControlMailbox(8) + done := make(chan error, 1) + go func() { + req := f.request() + req.Control = mailbox + done <- f.supervisorWithHTTP(t.Context(), req) + }() + waitFor(t, f.emitter.running) + if err := mailbox.Submit(context.Background(), protocol.ControlCommand{Command: protocol.ControlShutdown, CommandID: "shutdown-root-killed"}); err != nil { + t.Fatalf("Submit(shutdown) error = %v", err) + } + if err := <-done; err != nil { + t.Fatalf("Supervise() error = %v, want nil", err) + } + events := f.emitter.eventsSnapshot() + if indexOfEvent(events, "warning:"+string(protocol.CodeBackendForceTerminated)) < 0 { + t.Fatalf("events = %#v, want BACKEND_FORCE_TERMINATED", events) + } + if indexOfEvent(events, "warning:"+string(protocol.CodeBackendOrphansReaped)) >= 0 { + t.Fatalf("events = %#v, want no orphans warning when the root itself was killed", events) + } +} diff --git a/internal/backend/e2e_windows_test.go b/internal/backend/e2e_windows_test.go index 0fdc0a6..750e800 100644 --- a/internal/backend/e2e_windows_test.go +++ b/internal/backend/e2e_windows_test.go @@ -809,10 +809,11 @@ func TestBackendE2E_GracefulShutdownDoesNotWarnForceTerminated(t *testing.T) { } } -// TestBackendE2E_ShutdownWithSurvivingDescendantWarnsForceTerminated 是上一条的对照组: -// 后端优雅退出但故意漏下一个仍在运行的孙进程。这种情况必须仍然强制回收整棵树并 -// 发出 BACKEND_FORCE_TERMINATED——修误报不能顺手把真实的残留也一并放过。 -func TestBackendE2E_ShutdownWithSurvivingDescendantWarnsForceTerminated(t *testing.T) { +// TestBackendE2E_ShutdownWithSurvivingDescendantWarnsOrphansReaped 是上一条的对照组: +// 后端优雅退出但故意漏下一个仍在运行的 detached 孙进程。这种情况必须仍然强制回收整棵树, +// 但按增补 1 C14 报的是 BACKEND_ORPHANS_REAPED(details 列出被回收的孙进程), +// 而不是冒充 BACKEND_FORCE_TERMINATED——后端主进程本身是自己退出的。 +func TestBackendE2E_ShutdownWithSurvivingDescendantWarnsOrphansReaped(t *testing.T) { fixture := newBackendE2EFixture(t, backendE2EConfig{ SpawnGrandchild: true, GrandchildLifetimeMS: 60_000, @@ -829,14 +830,42 @@ func TestBackendE2E_ShutdownWithSurvivingDescendantWarnsForceTerminated(t *testi fixture.emitter.waitState(t, protocol.StateStopped, 1) // assertResourcesReleased 会等孙进程退出,证明它确实被 Job 回收了。 fixture.assertResourcesReleased(t, &generation) - forced := false + var reaped *protocol.WarningEvent for _, warning := range fixture.emitter.warningsSnapshot() { if warning.Code == string(protocol.CodeBackendForceTerminated) { - forced = true + t.Fatalf("warnings = %#v, want no %s: the backend exited on its own", fixture.emitter.warningsSnapshot(), protocol.CodeBackendForceTerminated) } + if warning.Code == string(protocol.CodeBackendOrphansReaped) { + clone := warning + reaped = &clone + } + } + if reaped == nil { + t.Fatalf("warnings = %#v, want %s for a surviving descendant", fixture.emitter.warningsSnapshot(), protocol.CodeBackendOrphansReaped) + } + orphans, ok := reaped.Details["orphans"].([]map[string]any) + if !ok || len(orphans) == 0 { + t.Fatalf("orphans warning details = %#v, want an orphans list", reaped.Details) + } + if count, ok := e2EUint32(reaped.Details["orphanCount"]); !ok || int(count) != len(orphans) { + t.Fatalf("orphanCount = %#v, want %d", reaped.Details["orphanCount"], len(orphans)) + } + found := false + for _, orphan := range orphans { + pid, ok := e2EUint32(orphan["pid"]) + executable, _ := orphan["executable"].(string) + if ok && pid == generation.grandchildPID { + found = true + if !strings.EqualFold(filepath.Base(executable), "python.exe") { + t.Fatalf("orphan executable = %q, want the fake backend image python.exe", executable) + } + } + } + if !found { + t.Fatalf("orphans = %#v, want the surviving grandchild pid %d", orphans, generation.grandchildPID) } - if !forced { - t.Fatalf("warnings = %#v, want %s for a surviving descendant", fixture.emitter.warningsSnapshot(), protocol.CodeBackendForceTerminated) + if reaped.Details["orphansTruncated"] != false { + t.Fatalf("orphansTruncated = %#v, want false", reaped.Details["orphansTruncated"]) } } @@ -1003,6 +1032,11 @@ func TestBackendE2E_ForcedShutdownReapsTree(t *testing.T) { if len(warnings) == 0 || warnings[len(warnings)-1].Code != string(protocol.CodeBackendForceTerminated) { t.Fatalf("warnings = %#v, want BACKEND_FORCE_TERMINATED", warnings) } + for _, warning := range warnings { + if warning.Code == string(protocol.CodeBackendOrphansReaped) { + t.Fatalf("warnings = %#v, want no orphans warning when the root itself was force-killed", warnings) + } + } fixture.assertResourcesReleased(t, &generation) assertE2EStateSequence(t, fixture.emitter.statesSnapshot(), protocol.StateStartingBackend, protocol.StateRunning, protocol.StateStoppingBackend, protocol.StateStopped) assertE2EPersistentLog(t, running, "forced") diff --git a/internal/backend/supervisor.go b/internal/backend/supervisor.go index 2163457..a65328d 100644 --- a/internal/backend/supervisor.go +++ b/internal/backend/supervisor.go @@ -749,10 +749,16 @@ func failureDetails(logger Logger, proc ManagedProcess, extra map[string]any) ma return details } +// processCleanup 是一次进程树收口的事实:forced 表示动用了 Job 兜底,rootForced 进一步 +// 区分「根进程仍存活时被强杀」与「根进程已自行退出、只回收残留孤儿」(增补 1 C14), +// orphans 是后一种情况下终止前快照到的残留成员(不含根进程);快照失败时 orphansUnknown 为 true。 type processCleanup struct { - details map[string]any - err error - forced bool + details map[string]any + err error + forced bool + rootForced bool + orphans []process.Info + orphansUnknown bool } func (s *ManagedSupervisor) cleanupProcess(ctx context.Context, proc ManagedProcess, tx TransactionHandle, logger Logger) processCleanup { @@ -782,6 +788,7 @@ func (s *ManagedSupervisor) cleanupProcess(ctx context.Context, proc ManagedProc snapshotErr = err if err != nil || hasSurvivingDescendant(members, proc.PID()) { outcome.forced = true + outcome.recordOrphans(members, err, proc.PID()) processErr = errors.Join(processErr, mapCleanupProcessError("terminate", proc.Terminate(1))) } exitResult, waitErr := proc.Wait(cleanupCtx) @@ -790,6 +797,8 @@ func (s *ManagedSupervisor) cleanupProcess(ctx context.Context, proc ManagedProc } processErr = errors.Join(processErr, mapCleanupProcessError("wait", withoutExpectedCancellation(waitErr, cleanupCtx.Err() != nil))) default: + // 根进程仍存活:这是真正的强制终止,无论树里还有没有别的成员。 + outcome.rootForced = true processErr = errors.Join(processErr, mapCleanupProcessError("terminate", proc.Terminate(1))) exitResult, waitErr := proc.Wait(cleanupCtx) if !errors.Is(waitErr, context.DeadlineExceeded) { @@ -802,6 +811,11 @@ func (s *ManagedSupervisor) cleanupProcess(ctx context.Context, proc ManagedProc // 根进程可能已退出但后代仍占用 Job;先强制终止,再次 Wait/WaitEmpty, // 只有第二次确认空树才允许后续成功收口。 forceCtx, forceCancel := context.WithTimeout(context.WithoutCancel(ctx), cleanupTimeout) + if !outcome.rootForced { + // 根进程早已自行退出,此刻残留的成员就是它遗留的孤儿;在终止前留下清单。 + members, err := proc.Snapshot() + outcome.recordOrphans(members, err, proc.PID()) + } terminateErr := proc.Terminate(1) exitResult, waitErr := proc.Wait(forceCtx) if !errors.Is(waitErr, context.DeadlineExceeded) { @@ -845,6 +859,30 @@ func (s *ManagedSupervisor) cleanupProcess(ctx context.Context, proc ManagedProc return outcome } +// recordOrphans 把快照里根进程以外的成员记为孤儿;快照失败时只能标记未知。 +// 多次调用取并集去重,因为 Exited 分支与 WaitEmpty 兜底分支可能先后各拍一次。 +func (c *processCleanup) recordOrphans(members []process.Info, snapshotErr error, rootPID uint32) { + if snapshotErr != nil { + c.orphansUnknown = true + return + } + for _, member := range members { + if member.PID == rootPID { + continue + } + duplicate := false + for _, known := range c.orphans { + if known.PID == member.PID { + duplicate = true + break + } + } + if !duplicate { + c.orphans = append(c.orphans, member) + } + } +} + // hasSurvivingDescendant 判断根进程退出后 Job 里是否还留着别的成员。 // 根进程自身可能因为进程表尚未收敛而短暂留在快照里,而 Exited 已经证明它退出了; // 把它算成残留会让优雅关闭误报 BACKEND_FORCE_TERMINATED。真正的后代(例如后端 diff --git a/internal/backend/supervisor_test.go b/internal/backend/supervisor_test.go index b3f13e4..24ac881 100644 --- a/internal/backend/supervisor_test.go +++ b/internal/backend/supervisor_test.go @@ -707,6 +707,7 @@ type fakeEmitter struct { mu sync.Mutex events []string state []protocol.StateEvent + warnings []protocol.WarningEvent stateErr error logErr error running chan struct{} @@ -746,10 +747,17 @@ func (e *fakeEmitter) EmitLog(event protocol.LogEvent) error { func (e *fakeEmitter) EmitWarning(event protocol.WarningEvent) error { e.mu.Lock() e.events = append(e.events, "warning:"+event.Code) + e.warnings = append(e.warnings, event) e.mu.Unlock() return nil } +func (e *fakeEmitter) warningsSnapshot() []protocol.WarningEvent { + e.mu.Lock() + defer e.mu.Unlock() + return append([]protocol.WarningEvent(nil), e.warnings...) +} + func (e *fakeEmitter) states() []protocol.StateEvent { e.mu.Lock() defer e.mu.Unlock() diff --git a/internal/protocol/errors.go b/internal/protocol/errors.go index ba6c6d9..b130668 100644 --- a/internal/protocol/errors.go +++ b/internal/protocol/errors.go @@ -61,6 +61,9 @@ const ( CodeBackendRestartFailed Code = "BACKEND_RESTART_FAILED" CodeBackendShutdownFailed Code = "BACKEND_SHUTDOWN_FAILED" CodeBackendForceTerminated Code = "BACKEND_FORCE_TERMINATED" + // CodeBackendOrphansReaped 是 warning-only 码(增补 1 C14):后端主进程自己退出, + // Job 里残留的孤儿被 Runtime 回收;与 BACKEND_FORCE_TERMINATED 互斥。 + CodeBackendOrphansReaped Code = "BACKEND_ORPHANS_REAPED" ) // ExitCode 是进程结果的粗粒度分类。 @@ -157,6 +160,7 @@ var errorDefinitions = []ErrorDefinition{ {Code: CodeBackendRestartFailed, ExitCode: ExitCodeBackendFailure, Retryable: true, Remediation: []Remediation{RemediationRestartBackend, RemediationRebuildEnvironment}}, {Code: CodeBackendShutdownFailed, ExitCode: ExitCodeBackendFailure, Retryable: true, Remediation: []Remediation{RemediationRetry, RemediationOpenLog}}, {Code: CodeBackendForceTerminated, ExitCode: ExitCodeSuccess, Retryable: false, Remediation: []Remediation{RemediationOpenLog}}, + {Code: CodeBackendOrphansReaped, ExitCode: ExitCodeSuccess, Retryable: false, Remediation: []Remediation{RemediationOpenLog}}, } var errorDefinitionByCode = buildErrorDefinitionIndex(errorDefinitions) diff --git a/internal/protocol/errors_test.go b/internal/protocol/errors_test.go index d4836a7..7db9411 100644 --- a/internal/protocol/errors_test.go +++ b/internal/protocol/errors_test.go @@ -72,6 +72,7 @@ func TestErrorDefinitionsMatchArchitectureDocument(t *testing.T) { protocol.CodeBackendRestartFailed, protocol.CodeBackendShutdownFailed, protocol.CodeBackendForceTerminated, + protocol.CodeBackendOrphansReaped, } if len(declaredCodes) != len(documented) { @@ -293,6 +294,7 @@ func TestNewErrorEventRejectsWarningOnlyCodes(t *testing.T) { for _, code := range []protocol.Code{ protocol.CodeInvalidControlCommand, protocol.CodeBackendForceTerminated, + protocol.CodeBackendOrphansReaped, } { code := code t.Run(string(code), func(t *testing.T) { From 33060c8790394775d035a0a7d60f11865bded7ae Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Wed, 2 Sep 2026 23:54:51 +0200 Subject: [PATCH 51/57] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20T13.9=20?= =?UTF-8?q?=E5=AD=A4=E5=84=BF=E5=9B=9E=E6=94=B6=E4=B8=8E=E5=BC=BA=E5=88=B6?= =?UTF-8?q?=E7=BB=88=E6=AD=A2=E5=88=86=E7=A6=BB=E5=AE=8C=E6=88=90=E6=83=85?= =?UTF-8?q?=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 2 +- doc/current/README.md | 3 ++- "doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" | 6 +++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5019569..26bbd63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Vue/Electron/Python 的改写、CI/CD 发布流程本身。 | M10 工程可维护性收敛 | T10.1 文档信息架构已完成;后续阶段见维护设计 | | M11 跨平台适配(Linux/macOS) | 规划中(决策 D7,2026-08-04 立项);仅 T11.1 设计任务可执行 | | M12 遥测与错误观测(Sentry-only) | T12.1、T12.2、T12.4、T12.5、T12.7 已完成;T12.6 本地发布配置已提交,待 Repository secret 与授权后的远端验收;T12.3/Umami 已取消 | -| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;T13.1(后端工作目录)、T13.2(Job 允许显式脱离)、T13.3(关闭超时参数化,含优雅关闭误报修复)、T13.4(锁副本改写参与包索引镜像轮换,显式 package-index 排最前)**已完成**;T13.5 **已完成**(注入面 `020b3b9`:`AUTO_MAS_UV_CACHE_DIR` / `AUTO_MAS_UV_PYTHON_INSTALL_DIR` / `AUTO_MAS_MIRROR_PACKAGE_INDEX` / `AUTO_MAS_MIRROR_PYTHON`;池目录重新分类按设计关闭——Runtime 的 repair/cleanup 只处理自己的目录,池 venv 失效后由后端判定重建);T13.6 ⏸ 未开始;2026-09-02 真机联调后按增补 1 **C12 / C13** 完成 T13.7(`f7d5edb`:`backend supervise --port`,缺省 managed 36163 / development 36164,注入 `AUTO_MAS_SUPERVISED_PORT`,健康/关闭地址与 `baseUrl` 由 `internal/health` 派生,E2E 改用空闲端口)与 T13.8(`41c51d3`,收尾 `23033f9`:`backend supervise` 的 stdin EOF / 读取出错视为隐式 shutdown,stdout 已断不影响 HTTP 优雅关闭,真实 exe 黑盒 E2E 锁定,宿主崩溃不再留孤儿)**已完成**;T13.9(增补 1 C14:新 warning `BACKEND_ORPHANS_REAPED`,`BACKEND_FORCE_TERMINATED` 收窄为主进程被强杀)🚧 进行中 | +| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;T13.1(后端工作目录)、T13.2(Job 允许显式脱离)、T13.3(关闭超时参数化,含优雅关闭误报修复)、T13.4(锁副本改写参与包索引镜像轮换,显式 package-index 排最前)**已完成**;T13.5 **已完成**(注入面 `020b3b9`:`AUTO_MAS_UV_CACHE_DIR` / `AUTO_MAS_UV_PYTHON_INSTALL_DIR` / `AUTO_MAS_MIRROR_PACKAGE_INDEX` / `AUTO_MAS_MIRROR_PYTHON`;池目录重新分类按设计关闭——Runtime 的 repair/cleanup 只处理自己的目录,池 venv 失效后由后端判定重建);T13.6 ⏸ 未开始;2026-09-02 真机联调后按增补 1 **C12 / C13** 完成 T13.7(`f7d5edb`:`backend supervise --port`,缺省 managed 36163 / development 36164,注入 `AUTO_MAS_SUPERVISED_PORT`,健康/关闭地址与 `baseUrl` 由 `internal/health` 派生,E2E 改用空闲端口)与 T13.8(`41c51d3`,收尾 `23033f9`:`backend supervise` 的 stdin EOF / 读取出错视为隐式 shutdown,stdout 已断不影响 HTTP 优雅关闭,真实 exe 黑盒 E2E 锁定,宿主崩溃不再留孤儿)**已完成**;T13.9(`da710c4`,增补 1 C14:新 warning `BACKEND_ORPHANS_REAPED`,`BACKEND_FORCE_TERMINATED` 收窄为主进程被强杀)**已完成** | 代码现状: diff --git a/doc/current/README.md b/doc/current/README.md index a1a8e45..eb90cb8 100644 --- a/doc/current/README.md +++ b/doc/current/README.md @@ -21,7 +21,8 @@ M7 GitHub CI/CD 发布均已完成;设计和审查记录分别归档到 三项均已完成,待 T13.4~T13.6 收口后一并归档; - `M13/` 另有 [T13.7 受监督端口由 Runtime 注入](./M13/设计-T13.7-受监督端口由-Runtime-注入.md) 与 [T13.8 宿主断开即关闭](./M13/设计-T13.8-宿主断开即关闭.md)(2026-09-02 真机联调后立项, - 设计与计划合并为一份,两项同日完成,待 M13 收口后一并归档)。 + 设计与计划合并为一份,两项同日完成,待 M13 收口后一并归档); +- [T13.9 孤儿回收与强制终止分离](./M13/设计-T13.9-孤儿回收与强制终止分离.md)(2026-09-02 真机设备轮后立项并完成)。 任务完成后的处理规则: diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index 43e311f..7e12ead 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -1031,10 +1031,13 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 测试:`TestBackendSupervise_StdinEOFSubmitsImplicitShutdown`(监督器收到 `CommandID==""` 的 shutdown,`result.status=stopped`、无 `controlCommandId`、exit 0)、`TestBackendSupervise_StdinReadErrorSubmitsImplicitShutdown`(读错误同样得到隐式 shutdown、exit 0、stderr 含 `stdin read failed`)、`TestBackendSupervise_ExplicitShutdownThenEOFIsIdempotent`(只收到显式那条、第二次 `Receive` 立即 `ErrControlStopped`、result 回显显式 commandId);既有 `TestBackend_ControlReaderJoinAndReadFailure/reader failure joins within bound` 的期望从 INTERNAL_ERROR 改为「有限时间内收口并以 stopped 退出」(契约变更随本任务同步)。E2E 新增 `internal/backend/e2e_stdin_windows_test.go`,用 `go build` 出真实 `auto-mas-runtime.exe` 黑盒驱动(既有 E2E 直接调 `ManagedSupervisor` 绕过了 CLI reader,修复点恰在那里):`TestBackendE2E_StdinEOFShutsDownGracefully` 到 `running` 后关 stdin,断言 exit 0、`stopping_backend` → `stopped`、`result.status=stopped` 且无 `controlCommandId`、无 `BACKEND_FORCE_TERMINATED`、stdout 无非 NDJSON 行、后端 PID 退出、端口 / Mutex / 事务无残留;`TestBackendE2E_StdinEOFWithBrokenStdoutStillExits` 先关自建 stdout 管道读端再关 stdin,Runtime 2.9 秒内退出(exit 20,stderr `write /dev/stdout: The pipe is being closed`),后端 PID 退出、资源无残留。红灯证据:把 `internal/cli/backend.go` 换回 `33c6bc8` 版本重跑主用例,Runtime 在 `hello`/`running` 之后 30 秒不退出(`runtime did not exit within 30s after stdin EOF`)。 验证:gofmt -l(无输出)/ `go vet ./...` / `go build -buildvcs=false ./...` / `go test ./... -count=1` / `git diff --check` 各 exit 0;`go test ./testdata/fakebackend ./testdata/fakeuv -count=1` exit 0;`go test ./internal/protocol -count=100` exit 0;`go test ./internal/cli -run 'StdinEOF|StdinReadError|ThenEOF|ControlReaderJoin|PortArgument' -count=20` exit 0;GCC 目录前置 PATH 后 `go test -race ./... -count=1` exit 0(全部 19 个含测试的包 ok)。本机 36163 全程被用户正式版 AUTO-MAS 占用,E2E 在此前提下全绿。 **收尾(2026-09-02,主线程要求,`23033f9`)**:原登记的已知限制「stdout 已断时后端被 Job 硬杀」按 C13 结论第 4 条修订为「stdout 已断不影响优雅关闭」。实现:`finishControlShutdown` 把首个协议输出错误存起来,照常 `POST /api/core/close` → `waitProcessExit`(沿用 `--shutdown-timeout`)→ `cleanupProcess`(超时才收 Job)→ `finalizeResources`,之后的 `stopped` / 强制终止 warning 写失败同样只记录,最后把输出错误(与清理错误 join、输出错误在前)返回,CLI 按既有分类得到 `OUTPUT_WRITE_FAILED`(退出码 20)并在 stderr 留 `write protocol event: ... The pipe is being closed` 诊断;主循环里 `attempt.gate` 因 `OUTPUT_WRITE_FAILED` 故障而 mailbox 已 latch 了 shutdown 时(`shutdownLatchedBehindOutputFault`),同样改走 `finishControlShutdown` 而不是硬杀——宿主崩溃时后端若恰好在输出日志,gate 会先于 EOF 观察到管道失效。判定依据是「已进入 shutdown」,显式与隐式同样受益。测试:假后端新增 `shutdownFile`(只在 close → `server.Shutdown` 成功后落盘,被 Job 硬杀时不出现;`TestFakeBackend_ListensOnSupervisedPortEnv` 顺带断言);`TestBackend_ShutdownContinuesGracefullyWhenOutputFails`(running 之后让 `EmitState` 持续失败再 shutdown:HTTP close 仍被调用、进程 terminated/waitEmpty/closed、无强制终止 warning、返回码 `OUTPUT_WRITE_FAILED`;红灯 `shutdown steps = []`);`TestBackendE2E_StdinEOFWithBrokenStdoutStillExits` 加强为退出码 20 + `assertE2EBackendClosedGracefully`(红灯 `timed out waiting for file backend.shutdown`),`TestBackendE2E_StdinEOFShutsDownGracefully` 同样断言优雅标记。验证:gofmt -l(无输出)/ `go vet ./...` / `go build -buildvcs=false ./...` / `go test ./... -count=1` / `git diff --check` 各 exit 0;`go test ./testdata/fakebackend ./testdata/fakeuv -count=1`、`go test ./internal/protocol -count=100`、`go test ./internal/cli -run 'StdinEOF|StdinReadError|ThenEOF|ControlReaderJoin|PortArgument' -count=20` 各 exit 0;GCC 前置 PATH 后 `go test -race ./... -count=1` exit 0(19 个含测试的包 ok)。 残余窗口:gate 故障早于 EOF 被读到(mailbox 尚无终止命令)仍走既有硬杀路径。 -- [ ] **T13.9 孤儿回收与强制终止分离**(M)🚧 2026-09-02 立项 +- [x] **T13.9 孤儿回收与强制终止分离**(M) ✅ 2026-09-02 `da710c4`(文档 `88d8a6f` → 实现与测试 `da710c4`) - 依赖:M6;契约 [增补 1 C14](./契约补充-v1-增补1.md#c14孤儿回收与强制终止分离);AUTO-MAS 侧无改动 - 内容:真机设备轮(`rt-dev/evidence/rounda.ndjson`、`rounda_orphan_sim.txt`):后端收到 close 后优雅退出(exit 0),但脚本子进程 `cmd.exe` 被后端 terminate 时孙进程 `PING.EXE` 留在 Job 里,Runtime 收尾 `TerminateJobObject` 后报 `BACKEND_FORCE_TERMINATED`(`exitCode: 0`)——名不副实,真实场景脚本进程常留孤儿,每次正常关闭都会报,会淹没真正的强杀。改为:后端主进程自己正常退出、Job 里仍有其他进程被回收时报新 warning `BACKEND_ORPHANS_REAPED`(`details`:`orphanCount`、`orphans[≤20]{pid, executable}`、`orphansTruncated`,加既有 `pid`/`logPath`/`exitCode`);只有主进程超时未退被 Job 强杀才是 `BACKEND_FORCE_TERMINATED`;两者互斥。新增 warning 码属契约变更,先改增补 1(C14)与架构文档错误码表。 - 验收:`internal/protocol` 错误码全集与文档表一致,新码为 warning-only;单元覆盖两种收尾分支的判定(主进程自己退出 + 残留成员 → `BACKEND_ORPHANS_REAPED` 且 details 正确、无 `BACKEND_FORCE_TERMINATED`;主进程超时被强杀 → 只有 `BACKEND_FORCE_TERMINATED`);E2E 用假后端拉起一个会留孤儿的 detached 子进程后正常退出,断言新 warning 与 details(孤儿 PID 与映像名);既有「close 被拒 → 强杀」E2E 仍报 `BACKEND_FORCE_TERMINATED`;验证门 + race。 + - 证据:设计与计划见 `doc/current/M13/设计-T13.9-孤儿回收与强制终止分离.md`。`internal/protocol/errors.go` 追加 `CodeBackendOrphansReaped`(退出码 0、不可重试、`open-log`),`TestErrorDefinitionsMatchDoc` 由架构文档错误码表驱动、`TestNewErrorEventRejectsWarningOnlyCodes` 锁定 warning-only。判定来源就是 `cleanupProcess` 既有的两条入口:`default` 分支(根进程仍存活被 `Terminate`)置 `rootForced`;`<-proc.Exited()` 分支与首次 `WaitEmpty` 失败后的兜底强杀在终止前用 Job 成员快照记孤儿(排除根 PID,去重;快照失败记 `orphansUnknown`,`orphanCount = -1`)。`finishControlShutdown` 在 `!graceful || rootForced` 时发 `BACKEND_FORCE_TERMINATED`(details 不变),否则 `forced` 时发 `BACKEND_ORPHANS_REAPED`(既有 `pid`/`logPath`/`exitCode` + `orphanCount` + `orphans[≤20]{pid, executable}` + `orphansTruncated`),两者互斥;显式/隐式 shutdown 与 stdout 已断路径共用这一处。 + 测试:`TestBackend_OrphansReapedWarnsWithoutForceTerminated`(根进程收到 close 自行退出、快照含 `PING.EXE` 5150 → 新 warning,`orphanCount=1`、`orphans[0]={5150, C:\\Windows\\System32\\PING.EXE}`、`orphansTruncated=false`、沿用 pid/logPath,无 FORCE)、`TestBackend_ForceTerminatedOnlyWhenRootKilled`(close 被拒 + 10 ms 预算、树里同样有别的成员 → 只有 FORCE、无孤儿 warning)、既有 `TestBackend_GracefulShutdownForceClearsDescendants` 改为期望孤儿 warning;E2E `TestBackendE2E_ShutdownWithSurvivingDescendantWarnsOrphansReaped`(假后端 `leaveGrandchildOnShutdown` 留下 detached 孙进程后 exit 0 → `BACKEND_ORPHANS_REAPED`,`orphans` 含孙进程 PID 且映像为 `python.exe`(夹具把假后端复制为 `.venv\Scripts\python.exe`)、`orphanCount == len(orphans)`、无 FORCE),`TestBackendE2E_ForcedShutdownReapsTree`(close 503 → 仍 FORCE 且无孤儿 warning)。红灯:三处 `undefined: protocol.CodeBackendOrphansReaped`。 + 验证:gofmt -l(无输出)/ `go vet ./...` / `go build -buildvcs=false ./...` / `go test ./... -count=1` / `git diff --check` 各 exit 0;`go test ./testdata/fakebackend ./testdata/fakeuv -count=1`、`go test ./internal/protocol -count=100`、cli 定向 `-count=20` 各 exit 0;GCC 前置 PATH 后 `go test -race ./... -count=1` exit 0(19 个含测试的包 ok)。 --- @@ -1233,6 +1236,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 | 日期 | 变更 | 操作者 | | --- | --- | --- | +| 2026-09-02 | 完成 **T13.9**(`da710c4`):`BACKEND_ORPHANS_REAPED` 落地,`cleanupProcess` 以「根进程是否仍存活时被 Terminate」区分强杀与孤儿回收并在终止前快照孤儿清单;`BACKEND_FORCE_TERMINATED` 只在主进程被强杀时发出。单测两条分支 + E2E 孤儿/强杀对照组;标准门、protocol ×100、cli 定向 ×20、完整 race 均 exit 0 | Claude | | 2026-09-02 | 真机设备轮暴露 `BACKEND_FORCE_TERMINATED` 名不副实(后端自己退出、被回收的是脚本遗留的 `PING.EXE` 孤儿),按红线第 2 条先改文档:新增 [增补 1 **C14**「孤儿回收与强制终止分离」](./契约补充-v1-增补1.md#c14孤儿回收与强制终止分离)——协议 v1 内追加 warning 码 `BACKEND_ORPHANS_REAPED`(`orphanCount` / `orphans[≤20]{pid, executable}` / `orphansTruncated`),`BACKEND_FORCE_TERMINATED` 收窄为「后端主进程被强杀」,两者互斥;架构设计错误码全集与关闭契约同步;M13 下立项 **T13.9** | Claude | | 2026-09-02 | **T13.8 收尾**(`23033f9`):C13 结论第 4 条修订为「stdout 已断不影响优雅关闭」——进入 shutdown 之后协议输出失败只记录不中断,仍 HTTP close、等关闭预算、超时才收 Job、清理 Mutex 与事务,最后以 `OUTPUT_WRITE_FAILED` 退出并在 stderr 留诊断;gate 因输出故障而 shutdown 已 latch 时同样走优雅关闭。理由:宿主崩溃时后端可能正在跑 MAA / 游戏任务,硬杀会留半截状态。架构设计两处、设计文档同步;假后端新增 `shutdownFile` 优雅标记,E2E 与单测据此断言后端被 HTTP 优雅关闭。验证门五条、protocol ×100、cli 定向 ×20 与完整 race 均 exit 0 | Claude | | 2026-09-02 | 完成 **T13.7**(`f7d5edb`)与 **T13.8**(`41c51d3`),均在分支 `feat/t13-port-eof-20260902` 上按四段式落地并合入 `integ/t13-20260901`。T13.7:`backend supervise --port`(1024~65535,缺省 managed 36163 / development 36164);`internal/health` 成为端口与地址的唯一来源(`DefaultPort` / `BaseURL` / `HealthURLForPort` / `ValidPort`),健康地址、`/api/core/close` 与 `baseUrl` 全部派生;`internal/uv` 注入受控键 `AUTO_MAS_SUPERVISED_PORT`;假后端改从该变量取监听端口;E2E 改用空闲端口,本机 36163 被正式版占用的前提下全绿。T13.8:`ControlReader.InputClosed()` + CLI 把 `hello` 之后的 stdin EOF / 读取出错翻译成无 commandId 的隐式 shutdown,走同一条优雅关闭路径;新增真实 exe 黑盒 E2E(stdin EOF 优雅关闭、stdout 已断仍退出),红灯为旧版 30 秒不退出。标准验证门、protocol ×100、定向 cli ×20 与完整 race 均 exit 0。遗留:TODO-PY-8 跨仓联合验收;stdout 已断时后端走 Job 硬杀而非 HTTP 优雅关闭 | Claude | From 303596e8e46b275d89524d0a9f2ac4cfe8293725 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Thu, 3 Sep 2026 08:49:56 +0200 Subject: [PATCH 52/57] =?UTF-8?q?docs:=20=E8=90=BD=E7=9B=98=E9=A6=96?= =?UTF-8?q?=E7=89=88=E9=AA=8C=E6=94=B6=E8=AE=B0=E5=BD=95=E5=B9=B6=E5=9B=9E?= =?UTF-8?q?=E5=86=99=20T9.2~T9.4=20=E7=8A=B6=E6=80=81=20(T9.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 doc/首版验收记录.md:对照架构设计「发布验收标准」14 条逐条核对,附测试名与联调记录; 第 1~11 条满足,第 12/14 条不满足、第 13 条未验证,均附解除条件; 开头显眼标注 T9.2 与第 3/4/5 条真机部分是用本地 smart-git 模拟发布分支完成的原因与缺口 - 任务拆分:T9.2 ✅(本地模拟,真实发布分支复跑待 push 后)、T9.3 🚧、T9.4 ✅; M9 里程碑行、0.3 节顺序说明与变更记录各加一行 - AGENTS.md 状态表 M9 行同步;doc/README.md 增加入口 Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 4 +- doc/README.md | 2 + ...73\345\212\241\346\213\206\345\210\206.md" | 14 +- ...14\346\224\266\350\256\260\345\275\225.md" | 214 ++++++++++++++++++ 4 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 "doc/\351\246\226\347\211\210\351\252\214\346\224\266\350\256\260\345\275\225.md" diff --git a/AGENTS.md b/AGENTS.md index 26bbd63..581a43f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ Vue/Electron/Python 的改写、CI/CD 发布流程本身。 --- -## 2. 当前状态(截至 2026-09-02) +## 2. 当前状态(截至 2026-09-03) | 里程碑 | 状态 | | --- | --- | @@ -43,7 +43,7 @@ Vue/Electron/Python 的改写、CI/CD 发布流程本身。 | M5 uv、Python 与依赖 | T5.1~T5.8 **已完成**(复审修复收口于 `8deb9d7`,含官方 uv 资产、完整组件矩阵与 race 验证) | | M6 后端监督 | T6.1~T6.7 **已完成**(真实 Windows Job/health/control/restart/development/E2E 与对抗复审收口于 `ea886f8`) | | M7 GitHub CI/CD 发布 | T7.1~T7.4 **已完成**(beta.2 Release run `31407585577`、三 job 全绿、独立资产/NDJSON 验收通过);T7.5 按 D3 延后 | -| M9 联调与首版验收 | T9.1 development 真后端联调 **已完成**(2026-09-02,`ba27db3` 构建对 AUTO-MAS 集成树 `integ/runtime-20260901` 跑通启动→就绪→优雅关闭);T9.2~T9.4 进行中,尚未回写 | +| M9 联调与首版验收 | T9.1 development 真后端联调 **已完成**(2026-09-02,`ba27db3` 构建对 AUTO-MAS 集成树 `integ/runtime-20260901` 跑通启动→就绪→优雅关闭);T9.2 managed 全链路 **已完成**(2026-09-02,用本地 HTTPS smart-git 模拟 `release/*` 分支:bootstrap → supervise → 升级 → 降级 → 关 stdin 隐式关闭,真实发布分支复跑待 push 后);T9.3 Electron 接入 🚧(`off` 六场景与 `development` 一轮桌面 E2E 完成,复跑进行中,未宣布通过);T9.4 首版验收清单核对 **已完成**(2026-09-03,[`doc/首版验收记录.md`](doc/首版验收记录.md):标准第 1~11 条满足,第 12/14 条不满足、第 13 条未验证,均属 AUTO-MAS 侧发布动作并附解除条件);M9 整体待真实 `release/*` 复跑与阶段 0/5/6 收口后勾选 | | M10 工程可维护性收敛 | T10.1 文档信息架构已完成;后续阶段见维护设计 | | M11 跨平台适配(Linux/macOS) | 规划中(决策 D7,2026-08-04 立项);仅 T11.1 设计任务可执行 | | M12 遥测与错误观测(Sentry-only) | T12.1、T12.2、T12.4、T12.5、T12.7 已完成;T12.6 本地发布配置已提交,待 Repository secret 与授权后的远端验收;T12.3/Umami 已取消 | diff --git a/doc/README.md b/doc/README.md index 4914ee9..2404a25 100644 --- a/doc/README.md +++ b/doc/README.md @@ -23,6 +23,8 @@ - [历史归档](./archive/README.md):已完成阶段仍有解释价值的设计与审查记录。 - [代码审查记录](./code-review.md):跨轮次 Review 发现的问题、修复状态与待执行的 Windows 侧验证清单;修复前先查这里是否已有记录。 +- [首版验收记录](./首版验收记录.md):T9.4 对架构设计「发布验收标准」的逐条核对、证据索引、 + 不满足项与本地模拟的缺口说明;真实发布分支复跑后在原文档上更新。 ## 文档生命周期 diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index 7e12ead..182ac25 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -27,7 +27,7 @@ ### 0.3 执行顺序 - 按各任务标注的「依赖」执行;无依赖关系的任务可并行; -- 里程碑整体顺序建议 M0 → M1 → M2 → M3 →(M4 ∥ M5)→ M6 → M7 → M9(M8 编号已随决策 D6 移除,保留空缺);M10 为跨阶段维护;M11 为规划中的跨平台扩展,M12 按 D11 收敛为 Sentry-only 并依 T12.1、T12.2、T12.4~T12.7 实施;M13 为 D12 与契约补充增补 1 的配套实施,T13.1/T13.2/T13.3 可并行,T13.4 → T13.5 → T13.6 有先后,整体建议排在 M9 联调之前(2026-09-02 状态:T13.1~T13.5 已完成,其中 T13.5 的「池目录重新分类」半段按设计关闭;T13.6 仍 ⏸;T9.1 development 联调已于同日在 T13.1~T13.5 之上完成;同日按联调暴露的问题立项 T13.7(受监督端口注入)与 T13.8(宿主断开即关闭),两者互不依赖、均在 M9 之前); +- 里程碑整体顺序建议 M0 → M1 → M2 → M3 →(M4 ∥ M5)→ M6 → M7 → M9(M8 编号已随决策 D6 移除,保留空缺);M10 为跨阶段维护;M11 为规划中的跨平台扩展,M12 按 D11 收敛为 Sentry-only 并依 T12.1、T12.2、T12.4~T12.7 实施;M13 为 D12 与契约补充增补 1 的配套实施,T13.1/T13.2/T13.3 可并行,T13.4 → T13.5 → T13.6 有先后,整体建议排在 M9 联调之前(2026-09-02 状态:T13.1~T13.5 已完成,其中 T13.5 的「池目录重新分类」半段按设计关闭;T13.6 仍 ⏸;T9.1 development 联调已于同日在 T13.1~T13.5 之上完成;同日按联调暴露的问题立项 T13.7(受监督端口注入)与 T13.8(宿主断开即关闭),两者互不依赖、均在 M9 之前;2026-09-03 状态:T13.7~T13.9 已完成,T9.2 以本地模拟发布分支完成、T9.4 核对表已落盘 `doc/首版验收记录.md`,T9.3 桌面 E2E 复跑进行中,M9 整体待真实 `release/*` 分支复跑与 AUTO-MAS 侧阶段 0/5/6 收口后才能勾选); - AUTO-MAS 侧 TODO 由那边的仓库执行,本仓库任务不阻塞在其上,除非「依赖」中显式标注。 ### 0.4 规模标记 @@ -69,7 +69,7 @@ D6 的推论(重要):`workspace sync` 按内部模板 `release/<完整版 | M5 | uv bootstrap、Python、依赖同步、bootstrap/repair 编排 | 阶段 3 | M3 | | M6 | 后端监督(Job Object、健康检查、单次重启、优雅关闭) | 阶段 4 | M5 | | M7 | 本仓库 GitHub CI/CD 发布 | —(本仓库交付要求) | M0(T7.2 依赖 M3 有可运行命令) | -| M9 | 联调与首版验收 | 阶段 5/6 配合 | M6;部分依赖 TODO-PY/TODO-EL | +| M9 | 联调与首版验收(2026-09-03:T9.1/T9.2/T9.4/T9.5 已完成,T9.3 进行中;核对结果见 [`doc/首版验收记录.md`](./首版验收记录.md),标准第 12/13/14 条待 AUTO-MAS 侧发布动作) | 阶段 5/6 配合 | M6;部分依赖 TODO-PY/TODO-EL | | M10 | 工程可维护性收敛 | —(跨阶段维护) | M2;T10.1 在 M3 前完成 | | M11 | 跨平台适配(Linux 少数发行版 + macOS) | —(2026-08-04 新增范围,见架构设计「平台支持策略」) | T11.1 设计依赖 M3,可提前;实施建议在 M9 首版 Windows 验收后启动 | | M12 | 遥测与错误观测(Sentry-only) | 阶段 7(发布观察)配套 | D11;M3 | @@ -779,18 +779,21 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 - 内容:`backend supervise --mode development --repo ""`(dev_v2 检出)跑真后端;验证 health 轮询、close 链路、日志转发、Job 树清理;发现契约偏差回写第 5 章 TODO 或 T1.0 契约文档。 - 验收:真后端完整「启动 → 就绪 → 优雅关闭」一轮通过;问题清单归档。 - 证据(2026-09-02):Runtime 可执行文件由 `integ/t13-20260901@ba27db3` 构建(`auto-mas-runtime-final.exe`),对 AUTO-MAS 集成树 `integ/runtime-20260901` 的真后端执行 `backend supervise --mode development --repo <集成树>`。结果:ready 3.21 秒;health 返回 `protocol: 1 / version: v5.5.0-beta.3 / commit: ""`(development 下 commit 为空符合 C2/C7 修订);stdin `shutdown` 被接受,后端退出后 Runtime 收口 0.35 秒、退出码 0;`result.details` 无 warning;`hello.capabilities` 含 `stdin.shutdown` 与 `stdin.status`。联调中发现并修掉的 Runtime 缺陷:优雅关闭后误报 `BACKEND_FORCE_TERMINATED`,根因是 `internal/process/job_windows.go` `snapshot()` 的 TOCTOU,修复 `e5ef0ab`(红绿灯与对照组见 T13.3「附带修复」,此处不重复)。更早一轮(2026-09-01)对同一后端的 M-0 测量:核心 API 首次 200 约 1.1 秒、ready 约 2.0 秒、受监督 close→退出 0.4 秒、无残留。契约偏差回写已由 2026-08-31 的增补 1(C6~C11)与 9 月 1 日/2 日的协议偏差回写提交完成,本任务不再新增 TODO。 -- [ ] **T9.2 managed 模式全链路联调**(L) +- [x] **T9.2 managed 模式全链路联调**(L)✅ 2026-09-02(用本地 git 模拟发布分支完成,真实发布分支复跑待 push 后;Runtime `6f7e5fc` 构建 + 一次性 `sim.patch`) - 依赖:T9.1;TODO-PY-4/6(repo 布局与锁定环境)合入某个真实 `release/*` 分支后才能完整执行 - 内容:全新临时根目录:`bootstrap --version <真实发布版本>` → `backend supervise --mode managed`;覆盖升级(旧 → 新)与显式降级(新 → 旧)各一轮。 - 验收:架构文档验收标准第 3、4、5 条实测通过(文档已按 D6 修订)。 -- [ ] **T9.3 Electron 接入支持**(M,持续性) + - 证据(2026-09-02,`D:/MAS/code/rt-sim/`,详见其 `README.md` 与 `evidence/`):因集成树改动未合入 `dev`、本仓库无 push 授权,GitHub 上没有可用的真实 `release/*` 分支,改用本地 HTTPS smart-git 服务托管 `release/v5.5.0-beta.3`(= 集成树 `5e91700d`)与人工制作的 `release/v5.5.0-beta.4`(`22cb65df`),Runtime 取 `6f7e5fc` 的 `git archive` 副本打上只改源 URL 与 TLS 信任的 `sim.patch` 一次性构建,**patch 不进仓库**。全新 app-root:4a bootstrap beta.3 18.74 s → 4b `supervise --mode managed --port 36173` ready 6.54 s、写入设置项与 `data/` 标记文件、shutdown 0.47 s → 4c 升级 beta.4(bootstrap 4.73 s,health `version/commit` 随之变为 beta.4/`22cb65df`)→ 4d 降级 beta.3 → 4e 就绪后直接关 stdin 隐式关闭 0.50 s。用户数据升降级后逐字保留,`repo/` 下无 `config/data/debug/history`,三轮 `/api/info/version` 均 `if_need_update=false`,七份事件流 `warning`/`error` 计数为 0,`environment.json` 的 `lastSuccessful` 按 beta.3 → beta.4 → beta.3 迁移、`broken=null`。第 3 条实测通过(同版本重同步只有组件测试);第 4、5 条的失败路径未在真机注入,仍由组件测试成立。缺口与复跑清单见 [`doc/首版验收记录.md`](./首版验收记录.md)「必须先读」与「后续动作」。 +- [ ] **T9.3 Electron 接入支持**(M,持续性)🚧 - 依赖:M6;与 TODO-EL 并行 - 内容:配合 AUTO-MAS 侧 Electron 接入(TODO-EL-1~8)过程中的 Runtime 侧问题修复与契约澄清;必要时提供 Node 侧解析参考实现或调试日志开关。 - 验收:Electron 双链路灰度(TODO-EL-7)下 Runtime 链路跑通桌面 E2E 关键场景。 -- [ ] **T9.4 首版验收清单核对**(M) + - 进度(截至 2026-09-03 08:43):AUTO-MAS 集成树 `integ/runtime-20260901@c0bb851a` 已接上 bootstrap / supervise / 更新三条链路与三级灰度开关;Runtime 侧配套为协议偏差回写(2026-09-02)、T13.7 端口注入、T13.8 宿主断开即关闭、T13.9 孤儿回收分离。桌面 E2E 工具与证据在 `D:/MAS/code/rt-e2e/`:`off`(旧链路)六场景已完成、`issues=[]`;`development`(Runtime 链路)六场景已完成一轮(`evidence/development-run1/`,2026-09-02 23:52:`startBackend` 拉起 Runtime 就绪 12.9 s、标题栏 X 退出 445 ms 收口、宿主崩溃模拟 134 ms 收口,唯一 issue 是后端未起前主进程日志 6 条 ERROR);`evidence/development/` 正在用 `auto-mas-runtime-port3.exe` 复跑,落盘前观察到的一次复跑第二次生命周期 `startBackend` 返回 `BACKEND_HEALTH_INVALID`、场景 5 未执行,该 summary 已被后续复跑覆盖,原因待复跑者给出。**未宣布通过**;复跑收口后由复跑者回写本条与验收记录 T9.3 小节。 +- [x] **T9.4 首版验收清单核对**(M)✅ 2026-09-03 - 依赖:M4~M7、T9.1~T9.3 - 内容:对照架构文档「发布验收标准」(已按 D6 修订)逐条核对;每条附证据(测试名 / 联调记录)。 - 验收:核对表落盘 `doc/首版验收记录.md`,无未解释的不满足项。 + - 证据:[`doc/首版验收记录.md`](./首版验收记录.md)。基线 Runtime `33060c8`(`go test ./...` 全绿)、集成树 `c0bb851a`。14 条中第 1~11 条满足(第 3/4/5 条真机部分为本地模拟发布分支 + 组件测试,第 8 条真机未制造崩溃,第 9 条按 C8 对游戏/模拟器 breakaway 作例外说明);第 12 条不满足(锁文件与 `uv lock --check` 只在集成树、未入 `dev` 与发布分支,CI 门禁不绑定发布分支创建)、第 13 条未验证(Lite/Full 包未构建)、第 14 条不满足(阶段 6 未到,旧链路仍是默认)——三条均有解释与解除条件。MaaFW 设备轮暴露的四处 AUTO-MAS 侧缺陷(runner 缺 `pywin32`、关机落在池安装期被 5 s 强杀、`uv cache prune` 卡 300 s、应用层中止任务无条件杀游戏)与主进程日志噪声列为「依赖方待修」,不计入 Runtime 不满足项。 - [x] **T9.5 beta.4 development 黑盒回归修复**(M)✅ 2026-08-12 `e146062` - 依赖:M6 - 内容:修复纯 development app root 缺少受管 repo 时 uv 预检误用受管 cwd 的问题;保留固定 uv、既有 `.venv`、Job 与 C5 安全边界;为 uv 创建失败补稳定白名单 diagnostics。 @@ -1236,6 +1239,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 | 日期 | 变更 | 操作者 | | --- | --- | --- | +| 2026-09-03 | 完成 **T9.4**:新增 [`doc/首版验收记录.md`](./首版验收记录.md),对照架构文档「发布验收标准」14 条逐条核对并附测试名与联调记录(基线 Runtime `33060c8`、集成树 `c0bb851a`)。结论:第 1~11 条满足,第 12(锁文件与 CI 门禁未入发布分支)、14(阶段 6 未到)不满足、第 13(Lite/Full 冒烟)未验证,均附解除条件;文档开头显眼标注 T9.2 与第 3/4/5 条真机部分是用本地 HTTPS smart-git 模拟发布分支完成的、原因与缺口。同步回写 **T9.2 ✅**(2026-09-02 本地模拟全链路:bootstrap beta.3 → supervise → 升级 beta.4 → 降级 beta.3 → 关 stdin 隐式关闭,用户数据逐字保留、七份事件流零 warning/error;真实发布分支复跑待 push 后)、**T9.3 🚧**(`off` 六场景与 `development` 一轮完成,复跑进行中,未宣布通过);M9 里程碑行、0.3 节顺序说明与 AGENTS.md 状态表同步。不改任何 Go 代码 | Claude | | 2026-09-02 | 完成 **T13.9**(`da710c4`):`BACKEND_ORPHANS_REAPED` 落地,`cleanupProcess` 以「根进程是否仍存活时被 Terminate」区分强杀与孤儿回收并在终止前快照孤儿清单;`BACKEND_FORCE_TERMINATED` 只在主进程被强杀时发出。单测两条分支 + E2E 孤儿/强杀对照组;标准门、protocol ×100、cli 定向 ×20、完整 race 均 exit 0 | Claude | | 2026-09-02 | 真机设备轮暴露 `BACKEND_FORCE_TERMINATED` 名不副实(后端自己退出、被回收的是脚本遗留的 `PING.EXE` 孤儿),按红线第 2 条先改文档:新增 [增补 1 **C14**「孤儿回收与强制终止分离」](./契约补充-v1-增补1.md#c14孤儿回收与强制终止分离)——协议 v1 内追加 warning 码 `BACKEND_ORPHANS_REAPED`(`orphanCount` / `orphans[≤20]{pid, executable}` / `orphansTruncated`),`BACKEND_FORCE_TERMINATED` 收窄为「后端主进程被强杀」,两者互斥;架构设计错误码全集与关闭契约同步;M13 下立项 **T13.9** | Claude | | 2026-09-02 | **T13.8 收尾**(`23033f9`):C13 结论第 4 条修订为「stdout 已断不影响优雅关闭」——进入 shutdown 之后协议输出失败只记录不中断,仍 HTTP close、等关闭预算、超时才收 Job、清理 Mutex 与事务,最后以 `OUTPUT_WRITE_FAILED` 退出并在 stderr 留诊断;gate 因输出故障而 shutdown 已 latch 时同样走优雅关闭。理由:宿主崩溃时后端可能正在跑 MAA / 游戏任务,硬杀会留半截状态。架构设计两处、设计文档同步;假后端新增 `shutdownFile` 优雅标记,E2E 与单测据此断言后端被 HTTP 优雅关闭。验证门五条、protocol ×100、cli 定向 ×20 与完整 race 均 exit 0 | Claude | diff --git "a/doc/\351\246\226\347\211\210\351\252\214\346\224\266\350\256\260\345\275\225.md" "b/doc/\351\246\226\347\211\210\351\252\214\346\224\266\350\256\260\345\275\225.md" new file mode 100644 index 0000000..70c7dac --- /dev/null +++ "b/doc/\351\246\226\347\211\210\351\252\214\346\224\266\350\256\260\345\275\225.md" @@ -0,0 +1,214 @@ +# AUTO-MAS Runtime 首版验收记录 + +## 文档状态 + +- 日期:2026-09-03 +- 任务:[任务拆分 T9.4](./任务拆分.md)「首版验收清单核对」 +- 核对依据:[架构设计](./架构设计.md)「发布验收标准」14 条(已按决策 D6 修订,不含版本索引与验签) +- Runtime 基线:`integ/t13-20260901@33060c8`(本记录落盘前的最新代码提交;`go test ./...` 于 2026-09-03 在该提交上全绿,19 个含测试的包全部 `ok`) +- AUTO-MAS 集成树基线:`D:/MAS/code/trees/integ`,分支 `integ/runtime-20260901`,HEAD `c0bb851a`(尚未合入 `dev`,也不在任何真实 `release/*` 分支上) +- 联调所用 Runtime 构建:T9.1 用 `ba27db3`;T9.2 用 `6f7e5fc` + `sim.patch`(见下节);T9.3 桌面 E2E 与 MaaFW 设备轮用 `D:/MAS/code/runtime-build/auto-mas-runtime-port.exe`(T13.7/T13.8 之后)与 `auto-mas-runtime-port3.exe`(T13.9 `da710c4` 之后,事件流出现 `BACKEND_ORPHANS_REAPED` 可证);这两个 exe 的精确源码提交未写进证据目录,以事件流里的能力与 warning 码反推 +- 结论一句话:**Runtime 侧标准第 1~11 条满足(其中第 3、4、5 条的真机部分靠本地模拟发布分支与组件测试成立);第 12、13、14 条依赖 AUTO-MAS 侧发布动作,当前不满足或未验证,首版正式发布门禁未开。** + +## 必须先读:哪些验收项不是在真实发布分支上验的 + +| 项 | 实际做法 | 为什么不能用真实分支 | 缺口 | +| --- | --- | --- | --- | +| T9.2 managed 全链路(对应标准第 3、4、5 条的真机部分) | 用本地 HTTPS smart-git 服务(`D:/MAS/code/rt-sim/gitserver/`,`net/http/cgi` → `git http-backend`,自签证书)托管 bare 仓 `rt-sim/git/AUTO-MAS.git`,其中 `release/v5.5.0-beta.3` = 集成树 `5e91700d`,`release/v5.5.0-beta.4` = 在其上人工制作的版本号提升提交 `22cb65df`;Runtime 用 `6f7e5fc` 的 `git archive` 副本打上 `rt-sim/sim.patch`(只改 git 源 URL 指向 `127.0.0.1:9443`、go-git 跳过 TLS 校验)后一次性构建为 `auto-mas-runtime-sim.exe` | 集成树的 TODO-PY-4/6(repo 布局、`uv.lock`)尚未合入 `dev`,GitHub 上不存在包含这些改动的 `release/*` 分支;本仓库当前也无 push 授权 | ① 未经过真实 GitHub/CNB 源与真实 TLS;② 未验证真实 `release/*` 分支保护与「分支/Tag 发布后不移动」纪律;③ beta.4 是人造提交,不是 CI 产出的发布分支;④ 镜像轮换(`internal/mirror`)在模拟中只有单一本地源,未真机轮换;⑤ `sim.patch` 中的改动**绝不回到 Runtime 仓库** | +| 标准第 12 条(锁文件入库与 CI 门禁) | 只读核对集成树 | 同上,改动未合入 `dev`、未进发布分支 | 见第 12 条 | +| 标准第 13 条(Lite/Full 安装包冒烟) | 未做 | 安装包由 AUTO-MAS 发布 CI 产出,集成树的 `build-app.yml` 改动尚未在 CI 上跑过 | 见第 13 条 | +| 标准第 14 条(旧更新入口停用) | 只读核对 + `off` 灰度场景实测 | 属阶段 6,当前处于阶段 5 灰度 | 见第 14 条 | + +## 核对总表 + +| 序号 | 标准要点 | 判定 | 主要证据 | +| --- | --- | --- | --- | +| 1 | 每个命令都有 `hello`、递增事件、唯一最终 `result`,失败日志不丢 | 满足 | 协议层 + 全部命令契约测试 | +| 2 | 错误码全集有自动化映射测试;Electron 对未知码通用降级 | 满足 | `TestErrorDefinitionsMatchArchitectureDocument` 等;集成树 `runtime/protocol.ts` 与其测试 | +| 3 | 全新安装 / 同版本重同步 / 升级 / 显式降级成功,health 版本与 Commit 等于目标 | 满足(真机为本地模拟;同版本重同步仅组件测试) | `rt-sim` 4a/4c/4d;`internal/gitrepo` 组件测试 | +| 4 | 替换前失败,原 repo 可继续启动 | 满足(组件测试;真机未注入故障) | `TestFetcher_VerificationFailureRotatesAndPreservesActiveRepository` 等 | +| 5 | 替换后立即持久化 `repository_changed`;sync 失败不回滚源码、标 `operation_failed`;重试/重建可恢复 | 满足(组件测试;真机只覆盖成功路径) | `internal/gitrepo`、`internal/uv`、`internal/cli` 测试;`rt-sim` `environment.json` | +| 6 | 更新/repair/cleanup 不执行插件依赖命令、不删插件包/配置/数据 | 满足 | `TestCleanup_PreservesUserDataAndPlugins` 等;`rt-sim` `repo/` 无用户数据 | +| 7 | 后端启动失败仍展示完整 stdout/stderr、稳定错误码、日志路径 | 满足 | `TestBackendE2E_PreReadyExit` 等;桌面 E2E `block-repo-approot` | +| 8 | 就绪后首次意外退出只自动重启一次,第二次结束监督 | 满足(测试层面;真机未制造崩溃) | `TestBackendE2E_FirstCrashRestartSuccess`、`TestBackendE2E_SecondCrashTerminates` | +| 9 | 正常退出、强制兜底、Runtime 异常退出均无 uv/Python/孙进程残留 | 满足(附 C8 例外说明) | 7 个 `TestBackendE2E_*`;T9.1/T9.2/T9.3/设备轮真机清点 | +| 10 | 受控更新/repair/cleanup 不能删用户数据目录、外部脚本目录、受管根以外路径 | 满足 | `internal/filesystem`、`internal/cleanup` 测试;`rt-sim` 升降级用户数据逐字保留 | +| 11 | 开发模式不修改开发者仓库、分支、`.git`、未提交文件 | 满足 | `TestBackendDevelopment_SnapshotUnchangedAfterStartupShutdown`;T9.1 与桌面 E2E 后集成树 `git status` | +| 12 | `.python-version` 与 `uv.lock` 已入发布分支,`uv lock --check` 是发布门禁 | **不满足**(已在集成树落地,未入 `dev`/发布分支;CI 门禁不绑定发布分支创建) | 集成树 `2a81d885`、`.github/workflows/check-uv-lock.yml` | +| 13 | Lite/Full 安装包真实 Windows 冒烟,Runtime 与 Electron 握手协议匹配 | **未验证**(安装包未构建;握手只在开发态真实 exe 上验过) | 集成树 `build-app.yml`;桌面 E2E `hello.protocol=1` | +| 14 | 旧 Electron/Python 更新入口停止承担正式更新职责;无两个组件同管一个进程/目录 | **不满足**(阶段 6 未到,旧链路仍是默认) | 桌面 E2E `off` 场景:`runtimeLaunchMode.persisted=auto → mode=off`,标题栏旧更新入口仍可点击 | + +## 逐条核对 + +### 1. 协议契约:`hello`、递增事件、唯一 `result`、失败日志不丢 + +- 判定:**满足**。 +- 证据(`go test ./...` @ `33060c8`): + - 协议层:`internal/protocol` 的 `TestNewEmitterEmitsHelloFirst`、`TestEmitterSerializesAllEventTypesWithGlobalSequence`、`TestEmitter_TerminalRejectsEveryEvent`、`TestEmitter_ConcurrentResults`、`TestNDJSONWriteFailuresDoNotAdvanceSequence`;`internal/protocol/contracttest` 的 `TestContract_Envelope`、`TestContract_ResultPlacement`、`TestContract_TerminalSemantics`、`TestContract_WarningSummary`。 + - 全部命令:`internal/cli` 逐命令注册的契约测试 `TestContract_RegisterVersion` / `TestContract_RegisterDoctor` / `TestContract_RegisterCleanup` / `TestWorkspaceCheckContract` / `TestWorkspaceContract` / `TestEnvironmentCheckContract` / `TestEnvironmentEnsureContract` / `TestEnvironmentRepairContract` / `TestDependenciesCheckContract` / `TestDependenciesSyncContract` / `TestDependenciesRebuildContract` / `TestBootstrapContract` / `TestRepairContract` / `TestBackendSuperviseContract`,以及 `TestExecute_NDJSONSessionHelloFirstResultLast`、`TestExecute_FrameworkSatisfiesContract`。 + - 失败日志不丢:`internal/backend` 的 `TestBackendManaged_PreReadyExitFlushesLogsBeforeErrorResult`、`TestBackendManaged_StartupLogsFlushAfterStarting`、`TestBackend_LogCompletenessAcrossRestart`、`TestProductionLogger_WritesFragmentsWithoutRepeatingEvent`。 + - 真机:T9.1 事件流 `D:/MAS/code/rt-approot/gate1-final.ndjson`(首事件 `hello`,`capabilities = ["stdin.cancel","state.v1","log.stream","stdin.shutdown","stdin.status"]`,末事件 `result` sequence 75,无 `warning`/`error` 事件);T9.2 七份 `rt-sim/evidence/*.ndjson` 的 `warning`/`error` 事件计数均为 0。 +- 备注:`--protocol` 不匹配时没有结构化事件(stdout 空、stderr 一行、exit 10),已按 2026-09-02 协议偏差回写写入架构文档,测试 `TestExecute_ProtocolMismatchExit10`。 + +### 2. 错误码映射测试与 Electron 未知码降级 + +- 判定:**满足**。 +- 证据: + - Runtime:`TestErrorDefinitionsMatchArchitectureDocument`(错误码全集与架构文档表逐条一致)、`TestExitCodeConstantsAreStable`、`TestEventConstructorsUseStableErrorDefinition`、`TestContract_ErrorDefinitions`、`TestContract_RejectsWarningOnlyFailureCodes`、`TestExecute_ExitCodeFromResultCode`、`TestClassifyFailure_FallbackCodes`、`TestHumanRendererContractMatrix`;`internal/backend` 的 `TestBackendDevelopment_MapsPreconditionErrors`、`internal/gitrepo` 的 `TestFetcher_MapsResolveCloneAndBranchFailures`、`TestVerifier_MapsVersionMismatch`、`internal/uv` 的 `TestDependencies_OnlineSyncFailureMapsToDependencySyncFailed`、`TestDependencies_OfflineFailureMapsToNetworkUnavailable`、`internal/state` 的 `TestWriteError_MapsStateWriteFailed`。 + - Electron(只读核对集成树 `c0bb851a`):`frontend/electron/services/runtime/protocol.ts` 文件头约定「未知的 stage、state、capability、remediation 与 code 必须忽略而不是拒绝整条协议」;错误码查表函数对未知码返回 `undefined`(约第 673 行),并按不可重试兜底(约第 880 行);`client.test.ts` 有用例「未知错误码按不可重试兜底」(约第 636 行)与「未知子命令这类参数错误也走 `RUNTIME_EXITED_UNEXPECTEDLY`」。 +- 缺口:Runtime 侧的「每个错误码至少一个映射测试」以全集级一致性测试 + 各包的具体映射测试成立,本记录未逐码列出对应测试名。 + +### 3. 全新安装、同版本重同步、升级、显式降级 + +- 判定:**满足**,其中真机部分在本地模拟发布分支上完成;同版本重同步只有组件测试。 +- 证据: + - 组件测试:`internal/gitrepo` 的 `TestComponent_GitAcquisitionMatrix`、`TestComponent_GitReplacementMatrix`、`TestComponent_VersionDowngradeUsesSameFlow`、`TestService_SyncVersionDowngradeUsesSameFlow`、`TestService_SyncNoOpPreservesStableState`(同 active revision 重复 sync 不 swap、幂等)、`TestVerifier_MapsVersionMismatch`;`internal/cli` 的 `TestBootstrapContract`(全新临时根从版本号到 `ready_to_start`);health 身份校验 `internal/health` 的 `TestHealth_RequiresAllNineConditions`、`TestHealth_IdentityMismatch`。 + - 真机(本地模拟,`D:/MAS/code/rt-sim/README.md`「运行记录」与 `evidence/`): + - 4a 全新安装 `bootstrap --version v5.5.0-beta.3`:exit 0,18.74 s(uv.download 6.49 / clone 3.72 / python.install 1.68 / deps 6.73); + - 4b `backend supervise --mode managed --port 36173`:ready 6.54 s,health `protocol 1 / version v5.5.0-beta.3 / commit 5e91700d…`(= 受管 `repo/` HEAD); + - 4c 升级 `bootstrap --version v5.5.0-beta.4`:4.73 s,随后 health `version v5.5.0-beta.4 / commit 22cb65df…`; + - 4d 显式降级回 beta.3:4.95 s,health 回到 `v5.5.0-beta.3 / 5e91700d…`; + - 三轮 `POST /api/info/version` 均 `if_need_update=false`;`runtime-state/environment.json` 的 `lastSuccessful` 依次 {beta.3, 5e91700d} → {beta.4, 22cb65df} → {beta.3, 5e91700d},`status` 始终 `ready_to_start`,`broken=null`。 +- 缺口:真实 GitHub/CNB 发布分支未跑(见「必须先读」);同版本重同步未在真机执行;4a 第一轮旧基线曾测得 uv.download 152 s(欧洲访问镜像),属网络环境数据,不是失败。 + +### 4. 替换前失败,原 repo 可继续启动 + +- 判定:**满足**(组件测试);真机未注入网络/校验故障。 +- 证据:`internal/gitrepo` 的 `TestFetcher_VerificationFailureRotatesAndPreservesActiveRepository`、`TestFetcher_MapsResolveCloneAndBranchFailures`、`TestFetcher_RotatesWhenPreferredSourceLacksBranch`、`TestFetcher_ExistingTemporaryRepositoryIsPreserved`、`TestRecovery_VerifiedBeforeSwap`、`TestRecovery_CloneInterrupted`、`TestRecovery_PreSwapMissingRepositoryHasNoSideEffects`、`TestComponent_GitRecoveryMatrix`;T4.3 验收项「版本文件不匹配 / 仓库损坏 / 校验失败后原 repo 完好」。 +- 缺口:`rt-sim` 只有单一可达源,未演练镜像轮换与断网;真机故障注入留待真实发布分支复跑时补。 + +### 5. 替换后状态持久化、sync 失败不回滚、重试与重建可恢复 + +- 判定:**满足**(组件测试);真机只覆盖了成功路径。 +- 证据: + - `repository_changed`:`internal/gitrepo` 的 `TestService_SyncActualSwapInvalidatesReadyEnvironment`、`TestRecovery_ActiveTargetCompletesEnvironmentState`(swap 后、environment 写前崩溃可补写)、`TestRecovery_ExistingRepositoryChangedIsNotRewritten`;`internal/state` 的 `TestValidateEnvironment_RepositoryChangedRequiresSwapSemantics`。 + - sync 失败 → `operation_failed`、不回滚源码:`internal/uv` 的 `TestDependencies_OnlineSyncFailureMapsToDependencySyncFailed`、`TestDependencies_SyncFallsBackToTheOriginalLock`、`TestDependencies_SyncMapsExhaustedRotationWithFallbackToSyncFailure`;`internal/cli` 的 `TestBootstrapCommand_FailurePersistsBroken`、`TestEnvironmentEnsure_FailurePersistsActiveRevisionAsBroken`、`TestM5Failure_PersistsKnownToolVersions`;`internal/state` 的 `TestValidateEnvironment_OperationFailedToolRequirements`、`TestStore_UVPreparationFailureCanCreateBrokenState`;T5.4 验收项「全程 Git 与用户数据未被修改(测试断言)」。 + - 重试与重建恢复:`internal/cli` 的 `TestRepair_RecoversBrokenEnvironment`、`TestRepair_FailurePersistsBrokenEnvironment`;`internal/uv` 的 `TestDependencies_RebuildUsesControlledDelete`、`TestRepair_ReinstallsPython`、`TestEnvironmentEnsure_IsIdempotent`;`internal/gitrepo` 的 `TestRecovery_PreservesBrokenVenvState`。 + - 真机:`rt-sim/evidence/4*-environment.json` 的状态迁移(见第 3 条)。 +- 缺口:真机未注入 `uv sync` 失败,`environment_broken` → repair 的恢复只在组件层验证。 + +### 6. 不执行插件依赖命令,不删插件运行包、配置或数据 + +- 判定:**满足**。 +- 证据: + - 静态边界:uv 子进程只经 `internal/uv` 唯一执行器(AGENTS.md 8.13、红线第 4 条),`internal/uv` 没有任何插件依赖命令;Runtime 对运行池只提供目录与镜像源(增补 1 C11,`TestManaged_InjectsInfrastructureAndMirrorSources`、`TestBackendDevelopment_PassesInfrastructureAndMirrorPlan`)。 + - 删除保护:`internal/cleanup` 的 `TestCleanup_PreservesUserDataAndPlugins`、`TestCleanup_PreservesDiagnosticLogs`、`TestCleanup_RejectsJunctionPointingToUserData`、`TestCleanup_RemovesUVCacheAndDownloadTemp`(只删自己的三类缓存);T5.7 验收项「保护名单完好测试」;T4.7 证据「`config/data/history/script/debug/plugins/logs/runtime` 身份与内容不变」。 + - 真机:`rt-sim` 4b~4e 每轮检查 `approot2/repo/` 下无 `config/ data/ debug/ history/`(`repoUserDataLeak = []`);MaaFW 设备轮 B 中运行池 `config/maafw_runtime_pool/` 由后端自行创建与复用,Runtime 未触碰。 +- 缺口:真机未对含插件目录的 app-root 执行 `cleanup` / `repair`。 + +### 7. 后端启动失败仍展示完整 stdout/stderr、稳定错误码与日志路径 + +- 判定:**满足**。 +- 证据: + - Runtime:`TestBackendE2E_PreReadyExit`、`TestBackendManaged_PreReadyExitFlushesLogsBeforeErrorResult`、`TestBackendManaged_StartupLogsFlushAfterStarting`、`TestBackendManaged_SpawnFailure`、`TestBackendManaged_PreconditionsFailClosed`、`internal/uv` 的 `TestManaged_StartFailureIncludesStableDiagnostics`、`TestUVRunner_StartFailureIncludesStableDiagnostics`;T9.5 补齐的白名单 diagnostics。 + - Electron 真机:`D:/MAS/code/rt-e2e/evidence/development/block-repo-approot/`(2026-09-02,故意把 Runtime 根目录放进源码目录):`startBackend-result.json` 为 `{success:false, code:"INVALID_ARGUMENT", retryable:false, remediation:["run-doctor"], error:"Runtime 根目录不能位于开发源码目录内"}`,截图 `electron-dev-mode-failure.png` 为现有失败界面;`development-dryrun/summary.json` 记录同一失败在主进程日志中以 `后端服务启动失败: INVALID_ARGUMENT …` 落盘。集成树 `backendService.ts` 保留 `[stdout]\n…\n\n[stderr]\n…` 组装,数据源改为聚合的 `log` 事件(TODO-EL-3)。 +- 缺口:真机未演练「后端就绪前自行崩溃」这一具体失败形态(只有参数类失败),该形态由 `TestBackendE2E_PreReadyExit` 覆盖。 + +### 8. 就绪后首次意外退出仅自动重启一次 + +- 判定:**满足**(测试层面);真机未制造后端崩溃。 +- 证据:`internal/backend` 的 `TestBackend_FirstUnexpectedExitRestartsOnce`、`TestBackend_RestartRechecksEnvironment`、`TestBackend_RestartReusesSupervisedPort`、`TestBackend_ActiveShutdownOrCancelDoesNotRestartAfterRunning`、`TestBackendE2E_FirstCrashRestartSuccess`、`TestBackendE2E_SecondCrashTerminates`;`internal/protocol` 的 `TestLifecycleMachineRestart`、`TestLifecycleMachineRunningFailureRequiresRestart`、`TestLifecycleMachineConcurrentRestartGuard`。T6.7 验收项明确「第 7、8、9 条在测试层面成立」。 + +### 9. 各类退出后无 uv、Python 或孙进程残留 + +- 判定:**满足**(附例外说明)。 +- 证据: + - 测试:`TestBackendE2E_LifecycleSpawnReadyShutdown`、`TestBackendE2E_GracefulShutdownDoesNotWarnForceTerminated`、`TestBackendE2E_ForcedShutdownReapsTree`、`TestBackendE2E_RuntimeTerminationLeavesNoDescendants`、`TestBackendE2E_DescendantHoldingPipeCannotBlockCleanup`、`TestBackendE2E_StdinEOFShutsDownGracefully`、`TestBackendE2E_StdinEOFWithBrokenStdoutStillExits`、`TestBackendE2E_ShutdownWithSurvivingDescendantWarnsOrphansReaped`;`internal/process` 的 `TestJobE2E_RuntimeTerminationReapsGrandchildren`、`TestJobE2E_FastChildSpawnIsAlreadyInJob`、`TestJob_NonBreakawayGrandchildStaysInJobAndIsReaped`、`TestJob_BreakawayGrandchildSurvivesJobClose`。 + - 真机正常退出:T9.1 stdin `shutdown` 后 0.35 s 退出、无残留(`rt-approot/gate1-final.ndjson`);`rt-e2e/evidence/development/cli-supervise-stdin-shutdown.ndjson`(ready 约 3.7 s,shutdown 485 ms);`rt-sim` 4b/4c/4d shutdown → 退出 0.47 / 0.48 / 0.42 s,端口 36173 无 LISTENING。 + - 真机宿主断开(C13):`rt-sim` 4e 关 stdin 后 0.50 s 退出、后端 PID 消失;`rt-e2e/evidence/development/cli-supervise-stdin-eof.ndjson`(EOF 503 ms);桌面 E2E `development-run1/08-host-crash.json`:`taskkill /F` 只杀 Electron 主进程后 Runtime 与后端 134 ms 内退出,`leftovers` 全空、36164 释放。 + - 真机 Electron 正常退出:`development-run1/07-quit.json` 标题栏 X 退出后 445 ms 内 Electron/后端/Runtime 全部退出、端口释放、`leftovers` 全空。 + - MaaFW 设备轮(`D:/MAS/code/rt-dev/evidence/`):A3 轮 shutdown → 退出 0.48 s,后端/uv/脚本 cmd/PING 全部退出,脚本遗留的孤儿以 `BACKEND_ORPHANS_REAPED`(`orphans` 含 `PING.EXE` 与 `conhost.exe`)如实上报、无 `BACKEND_FORCE_TERMINATED`;B2 轮结束时 `leftover_python_uv = []`、36165 释放。 +- 例外说明(不是残留):按增补 1 C8,游戏与模拟器以 `CREATE_BREAKAWAY_FROM_JOB` 脱离 Runtime Job,**有意**不随后端退出。设备轮 A 用 Job 句柄探针证实 `fakegame.exe` 不在 Runtime Job 内,B1 轮 `dnplayer.exe` 不在 Job 内且关闭后存活。A 轮里假游戏最终仍被杀,是后端自身「中止任务」路径的 `game_manager.kill()` 所为,与 Runtime 无关(见「依赖方待修」)。 +- 缺口:真机未演练「Runtime 自身被强杀」(只有 Job E2E 覆盖);B1 轮出现的 `BACKEND_FORCE_TERMINATED exitCode=1` 是后端关闭链路被运行池安装阻塞超过 5 s 预算,属依赖方问题,Runtime 按契约兜底强杀且事后无残留。 + +### 10. 受控操作不删用户数据、外部脚本目录、受管根以外路径 + +- 判定:**满足**。 +- 证据: + - `internal/filesystem`:`TestRemoveTree_RejectsAppRootRepoVolumeRootAndOutside`、`TestRemoveTree_RejectsProtectedRootsAndDescendants`、`TestRemoveTree_RejectsReparseWithoutDeletingLink`、`TestWindows_ReparsePointsNeverEscapeManagedRoot`、`TestAuthorizeDeleteRequest_RejectsRootsProtectedAndOutside`、`TestInspectExternalPath_RejectsSymlinkWithoutCreatingFiles`、`TestPinnedChain_RejectsReparsePointAtEveryLevel`。 + - `internal/cleanup`:`TestCleanup_RejectsTargetOutsideManagedRoot`、`TestCleanup_RejectsJunctionPointingToUserData`、`TestService_JunctionPycacheNotFollowed`、`TestService_JunctionRepoUpdateNotFollowed`、`TestCleanup_ResultDetailsDoNotLeakPaths`。 + - `internal/gitrepo`:`TestRecovery_ReparseAncestorHasNoSideEffects`、`TestService_CheckRejectsReparseAncestor`、`TestRecovery_AmbiguousIdentityHasNoSideEffects`(身份不明即失败关闭)。 + - 真机:`rt-sim` 4b 写入 `Function.HistoryRetentionTime = 60`(落盘 `approot2/config/Config.json`)与 `approot2/data/rt-sim-marker.txt`,经 4c 升级、4d 降级、4e 之后设置值仍为 60、标记文件逐字不变(各轮 `*-summary.json` 的 `settingMatches = true`、`markerPreserved = true`)。 + +### 11. 开发模式不修改开发者仓库 + +- 判定:**满足**。 +- 证据: + - 测试:`TestBackendDevelopment_SnapshotUnchangedAfterStartupShutdown`(哈希对比)、`TestBackendDevelopment_AllowsDirtyOutsideManagedRepo`、`TestBackendDevelopment_UsesExistingVenvWithoutSync`、`TestBackendDevelopment_InjectsOnlyRequiredEnv`、`TestBackendDevelopment_RejectsRuntimeRootInsideRepoBeforeSideEffects`、`TestBackendDevelopment_UVPreflightUsesExplicitRepo`(T9.5);T9.5 黑盒「源码 SHA-256 不变」。 + - 真机:T9.1、桌面 E2E(`development-run1`、2026-09-03 复跑)与 MaaFW 设备轮均以 `--mode development --repo <集成树或 devrun 树>` 跑真后端;落盘时集成树 `git status --short` 只有 `?? .pytest-tmp/`(pytest 的 `--basetemp` 目录,非 Runtime 产物)。 + +### 12. `.python-version` 与 `uv.lock` 入发布分支,`uv lock --check` 成为发布门禁 + +- 判定:**不满足**(AUTO-MAS 侧动作未到发布分支)。 +- 现状(只读核对集成树 `c0bb851a`):提交 `2a81d885` 已新增 `uv.lock` 与 `.python-version`,并加入 `.github/workflows/check-uv-lock.yml`(`astral-sh/setup-uv` 0.12.3 → `uv lock --check`),触发条件为 PR 与 push `dev`。Runtime 侧的消费面已就绪:`TestDependencies_LockfileContract`、`TestDependencies_LockfileCheckPreservesLockSources`;`rt-sim` 三轮 bootstrap 均通过 `uv lock --check` 与 `uv sync --locked`(模拟分支内容来自 `5e91700d`,已含两文件)。 +- 为什么不满足:① 改动只在集成分支,未合入 `dev`,GitHub 上不存在包含它的 `release/*` 分支;② 现有 workflow 是 PR/`dev` 检查,不是「锁文件缺失或过期时禁止创建发布分支」的门禁(TODO-CI-3 要求),发布分支创建流程本身未改。 +- 需要什么:集成分支合入 `dev`;TODO-CI-3 在发布 workflow 里接 `uv lock --check`;首个真实 `release/*` 分支产出后用 `bootstrap --version` 复跑 T9.2。 + +### 13. Lite/Full 安装包真实 Windows 冒烟,握手协议匹配 + +- 判定:**未验证**。 +- 现状:集成树 `build-app.yml` 已加入「从 `AUTO-MAS-Project/AUTO-MAS-Runtime` Release 下载钉死版本的 `auto-mas-runtime-.exe`、校验 `SHA256SUMS.txt`、改名 `auto-mas-runtime.exe` 复制进打包目录」的步骤(TODO-CI-1 的实现),但该 workflow 未在 CI 上运行过,本地也没有构建 Lite/Full 安装包。Runtime 与 Electron 的握手在开发态真实 exe 上已成立:桌面 E2E `development-run1/01-ready.json` 与 2026-09-03 复跑均以 `hello.protocol = 1` 完成握手,health 三字段齐全。 +- 需要什么:AUTO-MAS 发布 CI 至少跑一次预发布,产出 Lite/Full 包后在真实 Windows 上冒烟;Runtime 侧无待办。 + +### 14. 旧更新入口停用,无两个组件同管一个进程或目录 + +- 判定:**不满足**(按计划:阶段 6 尚未开始,当前是阶段 5 灰度)。 +- 现状:集成树已有三级灰度开关(环境变量 > 持久化配置 > 默认),桌面 E2E `off` 场景(`rt-e2e/evidence/off/summary.json`)显示默认状态 `persisted = "auto"` 解析为 `mode = "off"`(旧链路),标题栏仍展示可点击的旧「后端更新可用」入口(`legacyClickableHintShown = true`),Python 侧 `app/services/update.py` 整包更新入口仍在(TODO-PY-5 未做)。「单次生命周期只走一条链路」已成立:`off` 与 `development` 场景各自只出现一条链路的进程(`off` 下后端由脚本自起、Electron 退出时保留;`development` 下后端全部由 Runtime 拉起与回收)。 +- 需要什么:阶段 6 的 TODO-EL-4/5、TODO-PY-5 与默认链路切换;切换后重跑桌面 E2E 六场景并复核本条。 + +## 不满足项与未验证项清单 + +| 条目 | 状态 | 归属 | 解除条件 | +| --- | --- | --- | --- | +| 第 12 条 锁文件与 CI 门禁 | 不满足 | AUTO-MAS(TODO-PY-6 已在集成树落地;TODO-CI-3 未做) | 合入 `dev`、发布 workflow 接 `uv lock --check`、产出真实 `release/*` | +| 第 13 条 Lite/Full 冒烟 | 未验证 | AUTO-MAS(TODO-CI-1 已在集成树落地,未在 CI 跑过) | 发布 CI 产出安装包并在真实 Windows 冒烟 | +| 第 14 条 旧入口停用 | 不满足 | AUTO-MAS(阶段 6:TODO-EL-4/5、TODO-PY-5) | 默认链路切到 Runtime、删除旧入口后复核 | +| 第 3、4、5 条的真实发布分支复跑 | 待复跑 | 两侧(Runtime 无代码待办) | 真实 `release/*` 出现后用同一套 `rt-sim/scripts/rt.py` 流程对真实源复跑,补镜像轮换与故障注入 | +| 第 3 条 同版本重同步真机 | 未跑 | Runtime 联调 | 复跑时加一轮同版本 `bootstrap` | +| 第 8 条 真机崩溃重启 | 未跑 | Runtime 联调 | 复跑时对受监督后端注入一次崩溃 | +| T9.3 development 桌面 E2E 复跑 | 进行中 | AUTO-MAS 集成树 / `rt-e2e` | 见下节 T9.3 | + +## 依赖方待修(AUTO-MAS 侧,不计入 Runtime 不满足项) + +以下问题在真机联调中暴露,根因都在 AUTO-MAS 侧,正在另一条分支修复;列出是为了让读者知道首版真机验收是在这些缺陷存在的前提下完成的: + +1. **运行池 runner 缺 `pywin32`**:设备轮 B2 中内置 runner worker 启动即 `ModuleNotFoundError: No module named 'win32crypt'`(`app/utils/platform/windows/secret.py` 经 `import_paths=[cwd]` 触达),两次尝试均崩溃;「M9A 任务运行中且模拟器已起时关机」这一目标状态因此未能到达。 +2. **关机落在运行池安装期被强杀**:设备轮 B1 中 `POST /api/core/close` 后停止链路阻塞在 `asyncio.to_thread` 的池安装(shielded),5 s 关闭预算耗尽后 Runtime 按 C9 兜底强杀(`BACKEND_FORCE_TERMINATED exitCode=1`)。TODO-PY-12 要求把「回包 + 开始退出」与「任务清理」解耦。 +3. **`uv cache prune` 卡 300 s**:B2 中 runner 执行 `uv cache prune --cache-dir /runtime/cache/uv` 超时 300 s 后才继续,任务整体停滞五分钟。 +4. **应用层「中止任务」无条件杀游戏**:设备轮 A 中 breakaway 已生效(`fakegame.exe` 不在 Job 内),但后端 `GeneralAutoProxy.kill_managed_process()` → `game_manager.kill()` 仍把游戏杀掉,与 C8「游戏不随后端退出」的意图不符(`Game.IfForceClose` 只控制额外的按路径杀)。 +5. **主进程日志噪声**:桌面 E2E `development-run1` 在后端拉起前出现 6 条 `ERROR`(首页/公告/版本服务 `Network Error`、主 WebSocket 1006/1012 断开),均发生在后端未就绪或退出中,不影响就绪与退出判定,但会污染用户可见日志。 + +## 真机联调记录索引 + +### T9.1 development 真后端(2026-09-01 / 09-02) + +- Runtime `ba27db3` 构建(`auto-mas-runtime-final.exe`),对集成树跑 `backend supervise --mode development --repo D:/MAS/code/trees/integ`。 +- 脚本与日志:`D:/MAS/code/rt-approot/gate1_final.py`、`gate1-final.ndjson`、`logs/runtime/backend supervise-2026090{1,2}.log`。 +- 结果:ready 3.21 s;health `protocol 1 / version v5.5.0-beta.3 / commit ""`;stdin `shutdown` 后 0.35 s 退出、exit 0;`result.details` 无 warning;`hello.capabilities` 含 `stdin.shutdown` 与 `stdin.status`。 +- 独立复跑(2026-09-02 23:26,`D:/MAS/code/rt-e2e/evidence/development/`):`environment-ensure.ndjson` 全新 app-root 种入 uv 0.12.3(进程内耗时 72.4 s,含下载);`cli-supervise-stdin-shutdown.ndjson` ready 约 3.7 s、shutdown 485 ms;`cli-supervise-stdin-eof.ndjson` EOF 503 ms。 + +### T9.2 managed 全链路(2026-09-02,本地模拟发布分支) + +- 目录 `D:/MAS/code/rt-sim/`:`README.md`(复现步骤与运行记录)、`evidence/`(`4a-beta3-*`、`4b-beta3-*`、`4c-beta4-*`、`4d-beta3-*`、`4e-beta3-hangup-*`,每步 NDJSON、health/version JSON、耗时表、summary、`environment.json`)。 +- 结果摘要见第 3、9、10 条;七份事件流 `warning`/`error` 计数为 0,stderr 无 Traceback。 +- 后端命令行为 `uv.exe run --project /repo --no-sync /repo/main.py`,cwd = app-root(增补 1 C6)。 + +### T9.3 Electron 接入(2026-09-02 起,进行中) + +- 工具:`D:/MAS/code/rt-e2e/`(`scenario-off.mjs`、`scenario-development.mjs`、`launch.mjs`、`cdp.mjs`、`seed-supervise.mjs`)。 +- `off`(旧链路)六场景:`evidence/off/summary.json`,`issues = []`;冷启动、后端连接与灰度开关、标题栏旧更新入口、设置页运行方式只读、应用退出(后端按旧链路语义保留)、渲染进程 console 快照。 +- `development`(Runtime 链路)六场景,已完成一轮:`evidence/development-run1/summary.json`(2026-09-02 23:52,`auto-mas-runtime-port.exe`)——冷启动经 `startBackend` 拉起 Runtime,ready 12.9 s(含首次 `environment ensure` 9.4 s),health 三字段齐全,渲染进程 WS open;设置页提示「Supervised by Runtime (development)」;标题栏 X 退出 445 ms 内全部收口;宿主崩溃模拟 134 ms 内收口;console 相对 `off` 只新增两条后端未起前的连接拒绝。唯一 issue 是主进程日志的 6 条 ERROR(见「依赖方待修」第 5 项)。 +- **复跑进行中**:`evidence/development/` 在本记录落盘时(2026-09-03 08:43)正被新一轮复跑覆盖(`auto-mas-runtime-port3.exe`,同一目录另含 `runtime-alone-hold15s-{1,2,3}.ndjson` 三次独立 supervise 保活样本)。落盘前观察到的一次 08:39 复跑中,第一次生命周期(场景 1~4、6)通过、标题栏 X 退出 5.67 s 收口,但第二次生命周期 `startBackend` 返回 `BACKEND_HEALTH_INVALID`(后端启动后立刻收到一次 `/api/core/close`),场景 5 因此未执行;该 `summary.json` 已被后续复跑覆盖,原因待复跑者给出。**T9.3 不在本记录里宣布通过。** +- `development-dryrun/`(2026-09-02 23:34)与 `development/block-repo-approot/`:Runtime 根目录落在源码目录内时被 `INVALID_ARGUMENT` 拒绝,集成树随后以 `9d4eb151` 把开发态 Runtime 根目录移到仓外并在 supervise 前种 uv。 + +### MaaFW 真机设备轮(2026-09-02 / 09-03) + +- 目录 `D:/MAS/code/rt-dev/evidence/`:`rounda_notes.txt`、`roundb_notes.txt`、`rounda3_*`(T13.9 之后复跑)、`roundb2_*`。 +- A 轮(假游戏 + 假脚本):Job 成员探针证明后端 python、uv、脚本 cmd、PING 在 Runtime Job 内,`fakegame.exe`(`CREATE_BREAKAWAY_FROM_JOB`)不在;A3 轮 shutdown → 退出 0.48 s,`BACKEND_ORPHANS_REAPED` 列出 `PING.EXE`/`conhost.exe`,无 `BACKEND_FORCE_TERMINATED`。 +- B 轮(真实 M9A + 雷电模拟器):模拟器 `dnplayer.exe` 不在 Job 内;B1 关机被池安装阻塞 5 s 后强杀;B2 因 `pywin32` 缺失 worker 崩溃,任务自行结束后 shutdown 0.57 s、无 warning、无残留。 + +## 后续动作 + +1. 真实 `release/*` 分支产出后,用 `rt-sim/scripts/rt.py` 同一流程对真实 GitHub/CNB 源复跑 4a~4e,并补同版本重同步、镜像轮换、断网/校验失败、`uv sync` 失败与后端崩溃重启五类故障注入;复跑结果追加到本记录。 +2. T9.3 development 复跑收口后由复跑者回写 `任务拆分.md` T9.3 状态与本记录的 T9.3 小节。 +3. 第 12、13、14 条随 AUTO-MAS 侧阶段 0/5/6 的 TODO 完成后逐条复核;本记录在每次复核时更新「核对总表」与「不满足项与未验证项清单」,不另开新文档。 From 5e60f38ef1162f116d5fadf423dbcd42d1714ff2 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Thu, 3 Sep 2026 09:35:16 +0200 Subject: [PATCH 53/57] =?UTF-8?q?docs:=20=E4=BF=AE=E8=AE=A2=20C13=20?= =?UTF-8?q?=E7=AC=AC=204=20=E6=9D=A1=E5=B9=B6=E5=AE=9A=E7=A8=BF=20C15?= =?UTF-8?q?=EF=BC=8C=E7=AB=8B=E9=A1=B9=20T13.10=20/=20T13.11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 桌面 E2E 暴露:宿主崩溃真实路径下后端第一行关闭日志的协议转发失败会让进程包终止 Job, T13.8 的容错没机会生效;启动期短命子进程退出让 Job 快照报错、探针立即判 BACKEND_HEALTH_INVALID。 C13 第 4 条明确日志转发失败不终止进程树;C15 定稿快照跳过退出中成员、探针错误连续 3 次才判失败、 祖先链判定与根进程已退出不发 close;架构设计同步,任务拆分立项 T13.10 / T13.11。 Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 2 +- ...73\345\212\241\346\213\206\345\210\206.md" | 9 ++++++ ...5\205\205-v1-\345\242\236\350\241\2451.md" | 31 +++++++++++++++++-- ...66\346\236\204\350\256\276\350\256\241.md" | 7 +++-- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 26bbd63..cff9879 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Vue/Electron/Python 的改写、CI/CD 发布流程本身。 | M10 工程可维护性收敛 | T10.1 文档信息架构已完成;后续阶段见维护设计 | | M11 跨平台适配(Linux/macOS) | 规划中(决策 D7,2026-08-04 立项);仅 T11.1 设计任务可执行 | | M12 遥测与错误观测(Sentry-only) | T12.1、T12.2、T12.4、T12.5、T12.7 已完成;T12.6 本地发布配置已提交,待 Repository secret 与授权后的远端验收;T12.3/Umami 已取消 | -| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;T13.1(后端工作目录)、T13.2(Job 允许显式脱离)、T13.3(关闭超时参数化,含优雅关闭误报修复)、T13.4(锁副本改写参与包索引镜像轮换,显式 package-index 排最前)**已完成**;T13.5 **已完成**(注入面 `020b3b9`:`AUTO_MAS_UV_CACHE_DIR` / `AUTO_MAS_UV_PYTHON_INSTALL_DIR` / `AUTO_MAS_MIRROR_PACKAGE_INDEX` / `AUTO_MAS_MIRROR_PYTHON`;池目录重新分类按设计关闭——Runtime 的 repair/cleanup 只处理自己的目录,池 venv 失效后由后端判定重建);T13.6 ⏸ 未开始;2026-09-02 真机联调后按增补 1 **C12 / C13** 完成 T13.7(`f7d5edb`:`backend supervise --port`,缺省 managed 36163 / development 36164,注入 `AUTO_MAS_SUPERVISED_PORT`,健康/关闭地址与 `baseUrl` 由 `internal/health` 派生,E2E 改用空闲端口)与 T13.8(`41c51d3`,收尾 `23033f9`:`backend supervise` 的 stdin EOF / 读取出错视为隐式 shutdown,stdout 已断不影响 HTTP 优雅关闭,真实 exe 黑盒 E2E 锁定,宿主崩溃不再留孤儿)**已完成**;T13.9(`da710c4`,增补 1 C14:新 warning `BACKEND_ORPHANS_REAPED`,`BACKEND_FORCE_TERMINATED` 收窄为主进程被强杀)**已完成** | +| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;T13.1(后端工作目录)、T13.2(Job 允许显式脱离)、T13.3(关闭超时参数化,含优雅关闭误报修复)、T13.4(锁副本改写参与包索引镜像轮换,显式 package-index 排最前)**已完成**;T13.5 **已完成**(注入面 `020b3b9`:`AUTO_MAS_UV_CACHE_DIR` / `AUTO_MAS_UV_PYTHON_INSTALL_DIR` / `AUTO_MAS_MIRROR_PACKAGE_INDEX` / `AUTO_MAS_MIRROR_PYTHON`;池目录重新分类按设计关闭——Runtime 的 repair/cleanup 只处理自己的目录,池 venv 失效后由后端判定重建);T13.6 ⏸ 未开始;2026-09-02 真机联调后按增补 1 **C12 / C13** 完成 T13.7(`f7d5edb`:`backend supervise --port`,缺省 managed 36163 / development 36164,注入 `AUTO_MAS_SUPERVISED_PORT`,健康/关闭地址与 `baseUrl` 由 `internal/health` 派生,E2E 改用空闲端口)与 T13.8(`41c51d3`,收尾 `23033f9`:`backend supervise` 的 stdin EOF / 读取出错视为隐式 shutdown,stdout 已断不影响 HTTP 优雅关闭,真实 exe 黑盒 E2E 锁定,宿主崩溃不再留孤儿)**已完成**;T13.9(`da710c4`,增补 1 C14:新 warning `BACKEND_ORPHANS_REAPED`,`BACKEND_FORCE_TERMINATED` 收窄为主进程被强杀)**已完成**;T13.10(C13 第 4 条修订:日志转发失败不再终止进程树)与 T13.11(C15:快照跳过退出中成员、探针错误容忍、已退出不发 close)🚧 进行中 | 代码现状: diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index 7e12ead..bf4c9d0 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -1039,6 +1039,14 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 测试:`TestBackend_OrphansReapedWarnsWithoutForceTerminated`(根进程收到 close 自行退出、快照含 `PING.EXE` 5150 → 新 warning,`orphanCount=1`、`orphans[0]={5150, C:\\Windows\\System32\\PING.EXE}`、`orphansTruncated=false`、沿用 pid/logPath,无 FORCE)、`TestBackend_ForceTerminatedOnlyWhenRootKilled`(close 被拒 + 10 ms 预算、树里同样有别的成员 → 只有 FORCE、无孤儿 warning)、既有 `TestBackend_GracefulShutdownForceClearsDescendants` 改为期望孤儿 warning;E2E `TestBackendE2E_ShutdownWithSurvivingDescendantWarnsOrphansReaped`(假后端 `leaveGrandchildOnShutdown` 留下 detached 孙进程后 exit 0 → `BACKEND_ORPHANS_REAPED`,`orphans` 含孙进程 PID 且映像为 `python.exe`(夹具把假后端复制为 `.venv\Scripts\python.exe`)、`orphanCount == len(orphans)`、无 FORCE),`TestBackendE2E_ForcedShutdownReapsTree`(close 503 → 仍 FORCE 且无孤儿 warning)。红灯:三处 `undefined: protocol.CodeBackendOrphansReaped`。 验证:gofmt -l(无输出)/ `go vet ./...` / `go build -buildvcs=false ./...` / `go test ./... -count=1` / `git diff --check` 各 exit 0;`go test ./testdata/fakebackend ./testdata/fakeuv -count=1`、`go test ./internal/protocol -count=100`、cli 定向 `-count=20` 各 exit 0;GCC 前置 PATH 后 `go test -race ./... -count=1` exit 0(19 个含测试的包 ok)。 +- [ ] **T13.10 宿主崩溃真实路径:后端日志转发失败不再终止进程树**(M)🚧 2026-09-02 立项 + - 依赖:T13.8;契约 [增补 1 C13 结论第 4 条 2026-09-02 修订](./契约补充-v1-增补1.md#c13宿主断开即关闭) + - 内容:桌面 E2E(`rt-e2e/evidence/development-final-b/runtime-break-stdout-eof.txt`、`08-host-crash.json`):Electron 被 `taskkill /F` 后 Runtime 读到 EOF、发出了 `POST /api/core/close`(后端 200),但 +48 ms 后端与 Runtime 一起消失、退出码 20。根因:后端收到 close 后输出第一行关闭日志,`streamSink` 把它转发成 `log` 事件失败(stdout 已断)并把错误返回给 `internal/process`,而 `ManagedProcess.recordSinkError` 对首个 sink 错误的既有策略是 `job.Terminate(97)`——后端被 Runtime 自己的进程包杀掉,`finishControlShutdown` 的容错根本没机会生效。T13.8 的 E2E 没抓到是因为假后端收到 close 立刻退出、且只断了 stdout。改法:`streamSink` 对协议转发失败只记 gate 故障、返回 nil(继续读管道、继续写文件日志),文件日志写失败仍失败关闭;假后端新增 `shutdownEvents` / `shutdownDelayMs` 模拟真实关闭序列;E2E 用「关掉 stdout 与 stderr 读端 + 关 stdin」的真实组合。 + - 验收:`TestBackendE2E_HostCrashBrokenStdoutStderrStillClosesGracefully`——stdout/stderr 读端先断、再 EOF,后端收到 close 后输出 3 行日志并延迟 1.5 s 才退出,Runtime 退出时间 ≥ 1.5 s、退出码 20、假后端优雅标记存在、资源无残留;单测锁定 `streamSink` 在协议转发失败时返回 nil 且 gate 已故障;既有 gate 故障 → 硬杀路径无回退。 +- [ ] **T13.11 身份校验的启动窗口容忍与关闭请求的归属**(M)🚧 2026-09-02 立项 + - 依赖:M6;契约 [增补 1 C15](./契约补充-v1-增补1.md#c15身份校验的启动窗口容忍与关闭请求的归属) + - 内容:桌面 E2E 约四分之一的生命周期 `startBackend` 返回 `BACKEND_HEALTH_INVALID`「无法验证受管后端进程」:后端启动期 `Popen(['git','version'])` 等短命子进程恰在 Job 快照的两次系统查询之间退出,`processImagePath` 对正在消亡的进程失败,整个快照被判错误,探针错误立即失败。三处收紧:`internal/process` 快照对映像查询失败但进程已 signaled 的成员跳过;`internal/health` 探针错误连续 3 次才判失败(明确否定仍立即失败,连续成功计数清零);`finishControlShutdown` 在根进程已退出时不再发 close(防止打到端口上的别人)。祖先链判定为既有实现,写入契约。 + - 验收:`TestJob_SnapshotSkipsMemberExitingDuringImageQuery`(注入映像查询:进程已退出 → 跳过,仍存活 → 上报);`TestHealth_ProbeErrorIsToleratedUntilThreshold`(2 次错误后成功 → 通过;3 次连续错误 → `BACKEND_HEALTH_INVALID`;否定结果仍立即失败);`TestBackend_ShutdownSkipsCloseWhenBackendAlreadyExited`(根进程已退出时 closer 零调用、结局 stopped);E2E 假后端在 listen 前额外 fork 一层短命子进程与一层长驻子进程仍稳定通过。 --- ## 4. 新旧职责映射(迁移对照) @@ -1236,6 +1244,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 | 日期 | 变更 | 操作者 | | --- | --- | --- | +| 2026-09-02 | 桌面 E2E 暴露两个 Runtime 侧问题,按红线第 2 条先改文档:修订 **C13 结论第 4 条**(后端日志转发失败不再终止进程树——真实宿主崩溃路径下后端第一行关闭日志就会触发 `ManagedProcess.recordSinkError` 的 `job.Terminate(97)`,T13.8 的容错没机会生效);新增 **C15**(Job 快照跳过正在退出的成员、探针错误连续 3 次才判失败、解释器按祖先链判定、根进程已退出时不再发 close)。M13 下立项 **T13.10**、**T13.11** | Claude | | 2026-09-02 | 完成 **T13.9**(`da710c4`):`BACKEND_ORPHANS_REAPED` 落地,`cleanupProcess` 以「根进程是否仍存活时被 Terminate」区分强杀与孤儿回收并在终止前快照孤儿清单;`BACKEND_FORCE_TERMINATED` 只在主进程被强杀时发出。单测两条分支 + E2E 孤儿/强杀对照组;标准门、protocol ×100、cli 定向 ×20、完整 race 均 exit 0 | Claude | | 2026-09-02 | 真机设备轮暴露 `BACKEND_FORCE_TERMINATED` 名不副实(后端自己退出、被回收的是脚本遗留的 `PING.EXE` 孤儿),按红线第 2 条先改文档:新增 [增补 1 **C14**「孤儿回收与强制终止分离」](./契约补充-v1-增补1.md#c14孤儿回收与强制终止分离)——协议 v1 内追加 warning 码 `BACKEND_ORPHANS_REAPED`(`orphanCount` / `orphans[≤20]{pid, executable}` / `orphansTruncated`),`BACKEND_FORCE_TERMINATED` 收窄为「后端主进程被强杀」,两者互斥;架构设计错误码全集与关闭契约同步;M13 下立项 **T13.9** | Claude | | 2026-09-02 | **T13.8 收尾**(`23033f9`):C13 结论第 4 条修订为「stdout 已断不影响优雅关闭」——进入 shutdown 之后协议输出失败只记录不中断,仍 HTTP close、等关闭预算、超时才收 Job、清理 Mutex 与事务,最后以 `OUTPUT_WRITE_FAILED` 退出并在 stderr 留诊断;gate 因输出故障而 shutdown 已 latch 时同样走优雅关闭。理由:宿主崩溃时后端可能正在跑 MAA / 游戏任务,硬杀会留半截状态。架构设计两处、设计文档同步;假后端新增 `shutdownFile` 优雅标记,E2E 与单测据此断言后端被 HTTP 优雅关闭。验证门五条、protocol ×100、cli 定向 ×20 与完整 race 均 exit 0 | Claude | diff --git "a/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" "b/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" index 1f55605..4282531 100644 --- "a/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" +++ "b/doc/\345\245\221\347\272\246\350\241\245\345\205\205-v1-\345\242\236\350\241\2451.md" @@ -12,11 +12,12 @@ - 修订:2026-09-02 修订 C11 结论第 4 条:「池目录的重新分类」按设计关闭,改为「Runtime 的 `repair` / `cleanup` 只处理自己的目录;池 venv 因基解释器缺失失效后由后端自行判定重建」(T13.5 收口时,见 C11) - 修订:2026-09-02 新增 **C12「受监督端口由 Runtime 注入」** 与 **C13「宿主断开即关闭」**,并修订 C7 结论第 1 条与 C1(T13.7 / T13.8,真机联调暴露;见 C12、C13) - 修订:2026-09-02 新增 **C14「孤儿回收与强制终止分离」**:协议 v1 内追加 warning 码 `BACKEND_ORPHANS_REAPED`,`BACKEND_FORCE_TERMINATED` 收窄为「后端主进程被强杀」(T13.9,真机设备轮暴露;见 C14) +- 修订:2026-09-02 桌面 E2E 暴露两项:修订 **C13 结论第 4 条**(后端日志的协议转发失败不再终止进程树,宿主崩溃的真实路径——stdout/stderr 同时断——也走完优雅关闭;T13.10);新增 **C15「身份校验的启动窗口容忍与关闭请求的归属」**(快照跳过正在退出的成员、探针错误连续 3 次才判失败、祖先链判定、已退出的后端不再发 close;T13.11) 本文档是对 [契约补充-v1](./契约补充-v1.md) 的**增量修订**,定稿六项此前未冻结或与实现矛盾的对外契约,并修订 C2、C4 各一条; -2026-09-02 追加 C12、C13、C14 三项。 +2026-09-02 追加 C12、C13、C14、C15 四项。 `契约补充-v1.md` 开头已规定「对外字段、字面量或环境变量发生变化时必须升级或增量修订共同契约和测试」,本文档走的正是增量修订这条路: -九项都不删除或改名协议字段,也不改变任何既有 `stage` / `state` / 错误码字面量的语义,因此协议版本保持 `1` +十项都不删除或改名协议字段,也不改变任何既有 `stage` / `state` / 错误码字面量的语义,因此协议版本保持 `1` (C12 新增一个注入环境变量与一个 CLI 参数,C14 追加一个 warning 码,都属于架构文档允许在 v1 内追加的集合;`baseUrl` 字段本身不变,只是取值不再恒定)。 优先级:**本文档 > `契约补充-v1.md` > `架构设计.md` > `任务拆分.md`**。本文档与 `契约补充-v1.md` 冲突时以本文档为准;未被本文档触及的条目一律沿用原文。 @@ -35,6 +36,7 @@ | C11 | MaaFW 运行池的归属 | 只统一基础设施:Runtime 开放共享 uv 缓存与受管 Python 目录、下发有序镜像源;「装什么」仍留在后端 | | C12 | 受监督端口 | `backend supervise --port `(1024~65535),缺省 managed 36163 / development 36164;Runtime 注入 `AUTO_MAS_SUPERVISED_PORT`,健康/关闭地址与 `baseUrl` 由它派生;后端受监督时只认该变量、缺失回退 36163(2026-09-02 新增) | | C13 | 宿主断开即关闭 | 仅 `backend supervise`:`hello` 之后 stdin EOF 或读取出错视为隐式 `shutdown`,走同一条优雅关闭路径;stdout 已断不影响优雅关闭,收口后以 `OUTPUT_WRITE_FAILED` 退出;其他命令不变(2026-09-02 新增) | +| C15 | 身份校验的启动窗口容忍与关闭请求的归属 | 快照跳过正在退出的成员;进程探针错误连续 3 次才判 `BACKEND_HEALTH_INVALID`;解释器按 uv 根进程的祖先链判定;受管根进程已退出时不再发 `POST /api/core/close`(2026-09-02 新增) | | C14 | 孤儿回收与强制终止分离 | 后端主进程自己退出、Job 里残留进程被回收 → 新 warning `BACKEND_ORPHANS_REAPED`(`orphanCount` / `orphans[≤20]{pid, executable}` / `orphansTruncated`);`BACKEND_FORCE_TERMINATED` 只在后端主进程超时未退被 Job 强杀时发出(2026-09-02 新增) | --- @@ -356,6 +358,7 @@ Runtime `T13.7`(`internal/cli/backend.go`、`internal/backend`、`internal/hea 2. 隐式与显式 shutdown 幂等:已接受过 `shutdown` / `cancel` 之后再遇 EOF 不产生第二次关闭;EOF 之后不再有任何输入可读。 3. EOF 发生在后端就绪之前(预检、启动、健康检查期间)同样触发隐式 shutdown,语义与在这些阶段收到显式 shutdown 完全一致。 4. **stdout 容错**:宿主崩溃时 stdout 管道通常与 stdin 同时失效。Runtime 对写 stdout 失败必须容错——不 panic、不阻塞,**且 stdout 已断不影响优雅关闭**:进入 shutdown(显式或隐式)之后,任何协议事件写失败都不中断关闭流程,仍按顺序执行 `POST /api/core/close` → 等待关闭预算(C9)→ 超时才收 Job → 清理 Mutex 与事务,最后才以 `OUTPUT_WRITE_FAILED`(退出码 20)退出并在 stderr 留诊断。理由:后端可能正在跑 MAA / 游戏任务,硬杀会留下半截状态,而宿主崩溃恰恰是最需要优雅收尾的场景。此时事件与 `result` 写不出去,调用方本就已经不在,无人消费。 + **2026-09-02 修订(T13.10):** 「协议事件写失败」明确包括**后端 stdout/stderr 转发成 `log` 事件的写失败**——转发失败只记为输出故障,不得终止进程树、也不得停止读取后端的管道(后端的输出仍写入 Runtime 的轮转日志文件);宿主崩溃时后端总会继续输出关闭日志,此前第一行日志就会让进程树被终止。stderr 同时断开时诊断同样只能落文件日志。 5. **其他命令不变**:`bootstrap`、`dependencies`、`repair`、`workspace` 等一次性命令的 stdin EOF 行为一个字节都不动——它们可能在没有 stdin 的环境下运行(`< NUL`、CI、计划任务)。 6. 调用方义务:宿主在整个监督期间必须保持 stdin 打开;把 `backend supervise` 的 stdin 接到 `NUL` 或已关闭的管道等于立刻请求关闭。 @@ -402,6 +405,29 @@ Runtime `T13.9`(`internal/protocol/errors.go`、`internal/backend/supervisor.g --- +## C15:身份校验的启动窗口容忍与关闭请求的归属 + +> 2026-09-02 定稿(T13.11,桌面 E2E 暴露)。不新增字段、stage、state 或错误码;收紧三处 Runtime 侧行为。 + +### 结论 + +1. **Job 成员快照跳过正在退出的成员**:Runtime 读取受管 Job 成员时,成员在两次系统查询之间退出(pid 已从进程表消失、`OpenProcess` 拒绝、或映像查询失败但进程对象已进入 signaled 状态)一律视为「已不在树里」而跳过,不使整个快照失败;映像查询失败而进程仍存活的成员仍按快照错误上报,不猜测身份。 +2. **健康检查对进程探针错误容忍重试**:架构文档健康条件第 8 项(uv 与 Python 子进程仍在受管 Job 内)的探针**因快照错误**无法判定时,不再立即返回 `BACKEND_HEALTH_INVALID`,而是按轮询间隔重试,**连续 3 次**仍失败才判失败(仍受总启动超时约束;探针返回「不在 Job 内」的明确否定结果不在容忍之列,仍立即失败)。连续成功计数在探针错误时清零。 +3. **身份判定按祖先链**:受管解释器只需是 uv 根进程的**后代**(任意深度)即可通过第 8 项——`.venv\Scripts\python.exe` 启动器再拉起受管 CPython 的两层结构、或 uv 未来改变跳板层数,都不影响判定;这是既有实现的语义,本条把它写成契约。 +4. **关闭请求只发给自己的后端**:`finishControlShutdown` 发出 `POST /api/core/close` 之前若受管根进程已经退出,则**不再发出** close 请求(端口上此刻若有监听者,必定是别的实例),直接进入进程树回收与资源收口;结局与后端已优雅退出相同。 + +### 依据 + +- 桌面 E2E(`rt-e2e/evidence/development-final-b/01-ready.json`、`run1-runtime-backend-supervise.log`):后端 09:16:44.833 / 44.856 两次 `Popen(['git', 'version'])`,Runtime 44.868 拿到 health 200 后做进程探针、44.9 报「无法验证受管后端进程」(`BACKEND_HEALTH_INVALID`,探针错误)——`git.exe` 恰在快照的两次系统查询之间退出,`processImagePath` 对一个正在消亡的进程失败,整个快照被判错误。真实后端启动期会拉起 `git`、`adb`、`where` 等短命子进程,命中概率约四分之一; +- 同一轮里 startBackend 失败后被拉起第二次,随后有一条 `POST /api/core/close` 打到刚就绪的后端:Runtime 侧唯一会发 close 的路径是关闭收尾,若此时自己的后端早已不在,这条请求只会打到端口上的别人。结论第 4 条把这条路堵死; +- 探针的第 8 项在 T6.3 就以 `descendantOf` 按祖先链判定,但契约文本只写了「uv 子进程和 Python 子进程仍处于受管 Job Object」,本条补齐。 + +### 落点 + +Runtime `T13.11`(`internal/process/job_windows.go`、`internal/health/checker.go`、`internal/backend/control.go` 与对应测试);AUTO-MAS 侧无改动。 + +--- + ## 新增注入环境变量 以下变量由 Runtime 在启动后端时注入,与 C2 的五个变量同属受监督进程的环境契约。**变量名与取值格式属于已冻结的对外契约**,改动须先改文档(红线第 2 条)。 @@ -436,5 +462,6 @@ Runtime `T13.9`(`internal/protocol/errors.go`、`internal/backend/supervisor.g | C12 | T13.7 | TODO-PY-8(改读 `AUTO_MAS_SUPERVISED_PORT`) | | C13 | T13.8 | 无(Electron 保持 stdin 打开即可) | | C14 | T13.9 | 无(按 `code` 区分两条 warning 即可) | +| C15 | T13.11 | 无 | 验收:三关联调(development 一轮 → managed 全链路升降级各一轮 → MaaFW 真跑)见 `doc/任务拆分.md` M13 各任务的「验收」条目;C8 与 C9 的实际后果尚未实机验证,第三关就是为了验它们。 diff --git "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" index d5873be..a31ac55 100644 --- "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" +++ "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -36,6 +36,9 @@ - 修订:2026-09-02 按真机设备轮定稿 [增补 1 C14](./契约补充-v1-增补1.md#c14孤儿回收与强制终止分离): 协议 v1 内追加 warning 码 `BACKEND_ORPHANS_REAPED`(后端主进程自己退出、Job 残留孤儿被回收), `BACKEND_FORCE_TERMINATED` 收窄为「后端主进程被强杀」;错误码全集与关闭契约两处同步 +- 修订:2026-09-02 按桌面 E2E 定稿 [增补 1 C13 第 4 条修订与 C15](./契约补充-v1-增补1.md):后端日志转发失败不再终止进程树; + Job 快照跳过正在退出的成员、进程探针错误连续 3 次才判失败、解释器按祖先链判定、根进程已退出时不再发 close; + 「后端健康检查与关闭契约」同步 - 当前范围:系统边界、职责划分、CLI 协议、纯 Git 更新、uv 环境、健康检查、进程监督、镜像、插件职责边界、目录安全、错误与状态契约、测试矩阵、验收标准和分阶段迁移 ## 背景 @@ -1628,7 +1631,7 @@ Runtime 通过 `AUTO_MAS_RUNTIME_PROTOCOL`、`AUTO_MAS_EXPECTED_VERSION` 和 `AU 5. 协议版本兼容; 6. 后端版本等于目标完整版本; 7. Commit 等于当前受管仓库 HEAD; -8. uv 子进程和 Python 子进程仍处于受管 Job Object; +8. uv 子进程和 Python 子进程仍处于受管 Job Object(**2026-09-02 [增补 1 C15](./契约补充-v1-增补1.md#c15身份校验的启动窗口容忍与关闭请求的归属):** 解释器按 uv 根进程的祖先链判定,任意深度;快照跳过正在退出的成员;探针因快照错误无法判定时按轮询间隔重试,连续 3 次仍失败才判 `BACKEND_HEALTH_INVALID`); 9. 以上结果连续成功两次。 首版时间参数: @@ -1642,7 +1645,7 @@ Runtime 通过 `AUTO_MAS_RUNTIME_PROTOCOL`、`AUTO_MAS_EXPECTED_VERSION` 和 `AU 后端初始化期间允许接口尚不可连接,或返回 `200` 且 `backgroundStatus=starting` / `running`。Runtime 把它视为尚未就绪而非立即失败;`backgroundStatus=failed`、`backgroundError` 非空、身份不匹配、进程提前退出或超过总超时才结束启动操作。失败字面量与其他状态的完整定义见 [协议 v1 契约补充](./契约补充-v1.md#c3后台初始化状态字面量)。 -收到 stdin `shutdown` 后,Runtime 调用 `POST /api/core/close` 请求优雅关闭,然后等待受管进程退出。`AUTO_MAS_SUPERVISED=1` 的优先级高于 `AUTO_MAS_DEV=1`,受监督 development 后端也必须真实退出。接口无法连接、返回失败或超过关闭超时时,Runtime 关闭 Job Object 作为兜底;只要确认进程树已经清空,就输出 `BACKEND_FORCE_TERMINATED` 警告并正常完成关闭。后端主进程自己退出、只是 Job 里残留了它遗留的孤儿被回收时,输出的是 `BACKEND_ORPHANS_REAPED` 而不是 `BACKEND_FORCE_TERMINATED`([增补 1 C14](./契约补充-v1-增补1.md#c14孤儿回收与强制终止分离))。Electron 不直接调用该 HTTP 接口,也不直接终止 Python。 +收到 stdin `shutdown` 后,Runtime 调用 `POST /api/core/close` 请求优雅关闭,然后等待受管进程退出。`AUTO_MAS_SUPERVISED=1` 的优先级高于 `AUTO_MAS_DEV=1`,受监督 development 后端也必须真实退出。接口无法连接、返回失败或超过关闭超时时,Runtime 关闭 Job Object 作为兜底;只要确认进程树已经清空,就输出 `BACKEND_FORCE_TERMINATED` 警告并正常完成关闭。后端主进程自己退出、只是 Job 里残留了它遗留的孤儿被回收时,输出的是 `BACKEND_ORPHANS_REAPED` 而不是 `BACKEND_FORCE_TERMINATED`([增补 1 C14](./契约补充-v1-增补1.md#c14孤儿回收与强制终止分离))。受管根进程在发出 close 之前已经退出时,Runtime **不再发出** `POST /api/core/close`——端口上此刻的监听者只可能是别的实例([增补 1 C15](./契约补充-v1-增补1.md#c15身份校验的启动窗口容忍与关闭请求的归属))。宿主崩溃后后端继续输出的关闭日志转发失败只记为输出故障,不终止进程树(增补 1 C13 第 4 条 2026-09-02 修订)。Electron 不直接调用该 HTTP 接口,也不直接终止 Python。 **2026-08-31 增补:** From c46babfa51b609a040e4e241a960d77c2b82c5a9 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Thu, 3 Sep 2026 09:50:15 +0200 Subject: [PATCH 54/57] =?UTF-8?q?fix(backend):=20=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E8=BD=AC=E5=8F=91=E5=A4=B1=E8=B4=A5=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E7=BB=88=E6=AD=A2=E8=BF=9B=E7=A8=8B=E6=A0=91=20(T13.1?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 宿主崩溃的真实路径:Electron 被强杀后 stdout 读端已断,Runtime 读到 stdin EOF、 按 C13 发出 POST /api/core/close,后端随即输出第一行关闭日志——streamSink 把它 转发成 log 事件时写失败,错误回到 process 层,`ManagedProcess.recordSinkError` 对首个 sink 错误的既有策略是 job.Terminate(97),于是正在优雅关闭的后端连同整棵 进程树被 Runtime 自己杀掉,T13.8 的容错根本没机会生效。桌面实测退出码 20、 后端日志里没有任何关闭序列。 协议出口失败改为只登记 gate 故障并返回 nil:继续读管道、继续写文件日志,监督 循环经 Faulted() 观察到故障后走既有的关闭收口,最终仍以 OUTPUT_WRITE_FAILED 退出。运行日志(文件)写失败仍然失败关闭,语义不变。 假后端新增 shutdownEvents / shutdownDelayMs 以模拟真实关闭序列;E2E 用「关掉 stdout 与 stderr 读端 + 关 stdin」的真实组合。红灯:Runtime 在 EOF 后 6.5ms 就退出;绿灯:等满后端 1.5 秒的关闭序列后才退出。 Co-Authored-By: Claude Fable 5.1 --- internal/backend/control_test.go | 11 ++- internal/backend/e2e_stdin_windows_test.go | 94 ++++++++++++++++++++-- internal/backend/e2e_windows_test.go | 2 + internal/backend/supervisor.go | 7 +- internal/backend/supervisor_test.go | 61 ++++++++++++++ testdata/fakebackend/main.go | 31 ++++++- 6 files changed, 189 insertions(+), 17 deletions(-) diff --git a/internal/backend/control_test.go b/internal/backend/control_test.go index 0483cde..6698917 100644 --- a/internal/backend/control_test.go +++ b/internal/backend/control_test.go @@ -396,8 +396,10 @@ func TestBackend_GateFaultOutranksContextCancellation(t *testing.T) { done <- f.supervisor().Supervise(ctx, req) }() waitFor(t, f.emitter.running) - if err := f.proc.EmitRecord(context.Background(), process.StreamRecord{Stream: process.StreamStdout, Event: "fault", EndOfLine: true}); err == nil { - t.Fatal("EmitRecord() error = nil, want protocol sink fault") + // C13 结论第 4 条 2026-09-02 修订:协议出口失败只登记 gate 故障,sink 必须返回 nil, + // 否则 process 层会按首个 sink 错误 job.Terminate 整棵树。故障仍由下面的结局断言。 + if err := f.proc.EmitRecord(context.Background(), process.StreamRecord{Stream: process.StreamStdout, Event: "fault", EndOfLine: true}); err != nil { + t.Fatalf("EmitRecord() error = %v, want nil", err) } if err := mailbox.Submit(context.Background(), protocol.ControlCommand{Protocol: protocol.Version, Command: protocol.ControlCancel, CommandID: "cancel-exit-gate"}); err != nil { t.Fatalf("Submit(cancel) error = %v", err) @@ -428,8 +430,9 @@ func TestBackend_UpdateErrorGateAndContextPriority(t *testing.T) { done <- f.supervisor().Supervise(ctx, req) }() waitFor(t, f.state.updateStarted) - if err := f.proc.EmitRecord(context.Background(), process.StreamRecord{Stream: process.StreamStdout, Event: "fault", EndOfLine: true}); err == nil { - t.Fatal("EmitRecord() error = nil, want protocol sink fault") + // 同上:sink 返回 nil,故障经 gate 传递(C13 结论第 4 条 2026-09-02 修订)。 + if err := f.proc.EmitRecord(context.Background(), process.StreamRecord{Stream: process.StreamStdout, Event: "fault", EndOfLine: true}); err != nil { + t.Fatalf("EmitRecord() error = %v, want nil", err) } cancel() close(f.state.updateBlock) diff --git a/internal/backend/e2e_stdin_windows_test.go b/internal/backend/e2e_stdin_windows_test.go index 1c0e4dd..7838a2a 100644 --- a/internal/backend/e2e_stdin_windows_test.go +++ b/internal/backend/e2e_stdin_windows_test.go @@ -98,9 +98,18 @@ type backendE2ERuntimeProcess struct { command *exec.Cmd stdin io.WriteCloser stdoutRead *os.File - stderr bytes.Buffer - events *backendE2EProcessEvents - waitErr chan error + stderrRead *os.File + // stderrMu 保护 stderr:拷贝 goroutine 写、断言读。 + stderrMu sync.Mutex + stderr bytes.Buffer + events *backendE2EProcessEvents + waitErr chan error +} + +func (p *backendE2ERuntimeProcess) stderrText() string { + p.stderrMu.Lock() + defer p.stderrMu.Unlock() + return p.stderr.String() } // startBackendE2ERuntime 构建并启动真实 Runtime,以 development 模式监督夹具后端。 @@ -127,24 +136,34 @@ func startBackendE2ERuntime(t *testing.T, fixture *backendE2EFixture) *backendE2 if err != nil { t.Fatalf("os.Pipe() error = %v", err) } + stderrRead, stderrWrite, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() error = %v", err) + } command.Stdout = stdoutWrite + command.Stderr = stderrWrite process := &backendE2ERuntimeProcess{ command: command, stdin: stdin, stdoutRead: stdoutRead, + stderrRead: stderrRead, events: newBackendE2EProcessEvents(), waitErr: make(chan error, 1), } - command.Stderr = &process.stderr if err := command.Start(); err != nil { _ = stdoutWrite.Close() _ = stdoutRead.Close() + _ = stderrWrite.Close() + _ = stderrRead.Close() t.Fatalf("start runtime: %v", err) } // 子进程已持有写端副本,父进程这一份必须立刻关闭,否则读端永远等不到 EOF。 if err := stdoutWrite.Close(); err != nil { t.Fatalf("close stdout write end: %v", err) } + if err := stderrWrite.Close(); err != nil { + t.Fatalf("close stderr write end: %v", err) + } t.Cleanup(func() { select { case <-process.waitErr: @@ -155,7 +174,22 @@ func startBackendE2ERuntime(t *testing.T, fixture *backendE2EFixture) *backendE2 <-process.waitErr } _ = stdoutRead.Close() + _ = stderrRead.Close() }) + go func() { + buffer := make([]byte, 4096) + for { + n, err := stderrRead.Read(buffer) + if n > 0 { + process.stderrMu.Lock() + process.stderr.Write(buffer[:n]) + process.stderrMu.Unlock() + } + if err != nil { + return + } + } + }() go func() { scanner := bufio.NewScanner(stdoutRead) scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) @@ -191,7 +225,7 @@ func (p *backendE2ERuntimeProcess) waitExit(t *testing.T, timeout time.Duration) return -1 } case <-time.After(timeout): - t.Fatalf("runtime did not exit within %s after stdin EOF; events=%#v; stderr=%q", timeout, p.events.snapshot(), p.stderr.String()) + t.Fatalf("runtime did not exit within %s after stdin EOF; events=%#v; stderr=%q", timeout, p.events.snapshot(), p.stderrText()) return -1 } } @@ -219,7 +253,7 @@ func TestBackendE2E_StdinEOFShutsDownGracefully(t *testing.T) { process.closeStdin(t) if code := process.waitExit(t, 30*time.Second); code != 0 { - t.Fatalf("runtime exit code = %d, want 0; stderr=%q; events=%#v", code, process.stderr.String(), process.events.snapshot()) + t.Fatalf("runtime exit code = %d, want 0; stderr=%q; events=%#v", code, process.stderrText(), process.events.snapshot()) } process.events.waitFor(t, "stopping_backend state", e2EEventIs("state", protocol.StateStoppingBackend)) process.events.waitFor(t, "stopped state", e2EEventIs("state", protocol.StateStopped)) @@ -276,9 +310,53 @@ func TestBackendE2E_StdinEOFWithBrokenStdoutStillExits(t *testing.T) { process.closeStdin(t) code := process.waitExit(t, 30*time.Second) - t.Logf("runtime exit code with broken stdout = %d; stderr=%q", code, process.stderr.String()) + t.Logf("runtime exit code with broken stdout = %d; stderr=%q", code, process.stderrText()) if code != protocol.ExitCodePreconditionFailed { - t.Fatalf("runtime exit code = %d, want %d (OUTPUT_WRITE_FAILED); stderr=%q", code, protocol.ExitCodePreconditionFailed, process.stderr.String()) + t.Fatalf("runtime exit code = %d, want %d (OUTPUT_WRITE_FAILED); stderr=%q", code, protocol.ExitCodePreconditionFailed, process.stderrText()) + } + waitE2EPIDExit(t, pythonPID) + assertE2EBackendClosedGracefully(t, fixture) + fixture.assertResourcesReleased(t) +} + +// TestBackendE2E_HostCrashBrokenStdoutStderrStillClosesGracefully 复现桌面 E2E 抓到的真实宿主崩溃 +// 组合:stdout 与 stderr 的读端一起断掉,随后 stdin EOF;而后端收到 close 之后还会先输出 +// 若干行关闭日志、再过一段时间才退出。Runtime 必须让后端走完这段关闭序列(假后端的优雅 +// 标记只在 close → 日志 → 延迟 → server.Shutdown 之后落盘),不能因为写 stdout/stderr +// 失败就提前收场把后端连 Job 一起杀掉;最后以 OUTPUT_WRITE_FAILED 退出。 +func TestBackendE2E_HostCrashBrokenStdoutStderrStillClosesGracefully(t *testing.T) { + const shutdownDelay = 1500 * time.Millisecond + fixture := newBackendE2EFixture(t, backendE2EConfig{ + Events: e2EOutputEvents("hostcrash"), + ShutdownDelayMS: int(shutdownDelay / time.Millisecond), + ShutdownEvents: []backendE2EEvent{ + {Stream: "stdout", Line: "hostcrash shutting down"}, + {Stream: "stderr", Line: "hostcrash stopping tasks"}, + {Stream: "stdout", Line: "hostcrash cleanup complete"}, + }, + }) + process := startBackendE2ERuntime(t, fixture) + process.events.waitFor(t, "running state", e2EEventIs("state", protocol.StateRunning)) + pythonPID := waitE2EPIDFile(t, fixture.config.PIDFile) + + // 宿主死了:两条管道的读端同时消失,然后 stdin EOF。 + if err := process.stdoutRead.Close(); err != nil { + t.Fatalf("close stdout read end: %v", err) + } + if err := process.stderrRead.Close(); err != nil { + t.Fatalf("close stderr read end: %v", err) + } + eofAt := time.Now() + process.closeStdin(t) + + code := process.waitExit(t, 30*time.Second) + elapsed := time.Since(eofAt) + t.Logf("runtime exit code = %d after %s", code, elapsed) + if code != protocol.ExitCodePreconditionFailed { + t.Fatalf("runtime exit code = %d, want %d (OUTPUT_WRITE_FAILED)", code, protocol.ExitCodePreconditionFailed) + } + if elapsed < shutdownDelay { + t.Fatalf("runtime exited %s after EOF, before the backend's %s shutdown sequence could finish", elapsed, shutdownDelay) } waitE2EPIDExit(t, pythonPID) assertE2EBackendClosedGracefully(t, fixture) diff --git a/internal/backend/e2e_windows_test.go b/internal/backend/e2e_windows_test.go index 750e800..4a3d0ea 100644 --- a/internal/backend/e2e_windows_test.go +++ b/internal/backend/e2e_windows_test.go @@ -48,6 +48,8 @@ type backendE2EConfig struct { WorkingDirFile string `json:"workingDirFile,omitempty"` EnvironmentFile string `json:"environmentFile,omitempty"` ShutdownFile string `json:"shutdownFile,omitempty"` + ShutdownEvents []backendE2EEvent `json:"shutdownEvents,omitempty"` + ShutdownDelayMS int `json:"shutdownDelayMs,omitempty"` GrandchildPIDFile string `json:"grandchildPidFile,omitempty"` SpawnGrandchild bool `json:"spawnGrandchild,omitempty"` GrandchildLifetimeMS int `json:"grandchildLifetimeMs,omitempty"` diff --git a/internal/backend/supervisor.go b/internal/backend/supervisor.go index a65328d..25fd016 100644 --- a/internal/backend/supervisor.go +++ b/internal/backend/supervisor.go @@ -660,12 +660,17 @@ func (s *ManagedSupervisor) streamSink(request Request, logger Logger, gate *str if record.Event == "" && !record.EndOfLine { return nil } + // 协议出口写失败只登记 gate 故障,**不能**把错误回给 process 层: + // `ManagedProcess.recordSinkError` 对首个 sink 错误的策略是 `job.Terminate(97)`, + // 那会在宿主崩溃(stdout 读端已断)时把正在优雅关闭的后端连同进程树一起杀掉, + // C13「stdout 已断不影响优雅关闭」的容错根本没机会生效。故障已记在 gate 上, + // 监督循环会经 Faulted() 观察到并走关闭收口;这里继续读管道、继续写文件日志。 if err := gate.Emit(request.Emitter, protocol.LogEvent{ Source: "backend", Stream: record.Stream, Message: record.Event, }); err != nil { - return err + return nil } return nil } diff --git a/internal/backend/supervisor_test.go b/internal/backend/supervisor_test.go index 24ac881..4748fea 100644 --- a/internal/backend/supervisor_test.go +++ b/internal/backend/supervisor_test.go @@ -1410,3 +1410,64 @@ func assertMirrorSourcesMatchPlan(t *testing.T, policy mirror.Policy, kind mirro t.Fatalf("%s sources = %#v, want %#v in plan order", kind, got, want) } } + +// 宿主崩溃时 stdout 读端已断,后端收到 close 后输出的第一行关闭日志会让协议出口写失败。 +// 这个错误绝不能回给 process 层:`ManagedProcess.recordSinkError` 对首个 sink 错误的策略是 +// `job.Terminate(97)`,那会把正在优雅关闭的后端连同进程树一起杀掉(C13 结论第 4 条 2026-09-02 修订)。 +func TestBackend_StreamSinkKeepsDrainingWhenProtocolOutputFails(t *testing.T) { + emitter := &fakeEmitter{logErr: errors.New("write /dev/stdout: The pipe is being closed")} + logger := &fakeLogger{path: filepath.Join(t.TempDir(), "backend.log")} + gate := &streamGate{stage: protocol.StageBackendRun} + if err := gate.Open(emitter); err != nil { + t.Fatalf("open gate: %v", err) + } + + sink := (&ManagedSupervisor{}).streamSink(Request{Emitter: emitter}, logger, gate) + record := process.StreamRecord{Stream: process.StreamStdout, Event: "Application shutdown complete.", EndOfLine: true} + if err := sink(context.Background(), record); err != nil { + t.Fatalf("协议出口失败必须返回 nil,否则后端会被 job.Terminate 杀掉,got %v", err) + } + // 后续行同样继续吞,不能因为已故障就开始报错。 + if err := sink(context.Background(), process.StreamRecord{Stream: process.StreamStdout, Event: "bye", EndOfLine: true}); err != nil { + t.Fatalf("故障之后仍应继续读管道,got %v", err) + } + + fault := gate.Fault() + if fault == nil { + t.Fatal("协议出口失败必须登记 gate 故障,监督循环靠它收口") + } + assertBackendCode(t, fault, protocol.CodeOutputWriteFailed) + + logger.mu.Lock() + recorded := len(logger.records) + logger.mu.Unlock() + if recorded != 2 { + t.Fatalf("文件日志必须照常写入,want 2 条,got %d", recorded) + } +} + +// 对照组:文件日志写失败仍然失败关闭(沿用既有语义,本次修订不放宽)。 +func TestBackend_StreamSinkStillFailsClosedWhenRuntimeLogFails(t *testing.T) { + emitter := &fakeEmitter{} + logger := &failingLogger{err: errors.New("disk full")} + gate := &streamGate{stage: protocol.StageBackendRun} + if err := gate.Open(emitter); err != nil { + t.Fatalf("open gate: %v", err) + } + + sink := (&ManagedSupervisor{}).streamSink(Request{Emitter: emitter}, logger, gate) + err := sink(context.Background(), process.StreamRecord{Stream: process.StreamStdout, Event: "x", EndOfLine: true}) + if err == nil { + t.Fatal("运行日志写入失败必须返回错误") + } + assertBackendCode(t, err, protocol.CodeInternalError) +} + +type failingLogger struct { + err error + path string +} + +func (l *failingLogger) Record(context.Context, process.StreamRecord) error { return l.err } +func (l *failingLogger) LogPath() string { return l.path } +func (l *failingLogger) Close() error { return nil } diff --git a/testdata/fakebackend/main.go b/testdata/fakebackend/main.go index 66b0744..d47ff86 100644 --- a/testdata/fakebackend/main.go +++ b/testdata/fakebackend/main.go @@ -49,10 +49,14 @@ type fakeBackendConfig struct { EnvironmentFile string `json:"environmentFile"` // ShutdownFile 只在「收到 close 并完成 server.Shutdown」的优雅路径上落盘,被 Job 硬杀时 // 永远不会出现;T13.8 的 E2E 据此区分「HTTP 优雅关闭」与「被杀」。 - ShutdownFile string `json:"shutdownFile"` - GrandchildPIDFile string `json:"grandchildPidFile"` - SpawnGrandchild bool `json:"spawnGrandchild"` - GrandchildLifetimeMS int `json:"grandchildLifetimeMs"` + ShutdownFile string `json:"shutdownFile"` + // ShutdownEvents 与 ShutdownDelayMS 模拟真实后端收到 close 之后的关闭序列:先逐行输出 + // 关闭日志(此时宿主的 stdout/stderr 可能已经断了),再等待一段时间才真正退出。 + ShutdownEvents []outputEvent `json:"shutdownEvents"` + ShutdownDelayMS int `json:"shutdownDelayMs"` + GrandchildPIDFile string `json:"grandchildPidFile"` + SpawnGrandchild bool `json:"spawnGrandchild"` + GrandchildLifetimeMS int `json:"grandchildLifetimeMs"` // LeaveGrandchildOnCrash / LeaveGrandchildOnShutdown 都让孙进程脱离父进程的 // liveness 管道并跳过自清理,区别只是在崩溃还是优雅关闭路径上留下它。 // 后者用于证明「真有存活后代」时 Runtime 仍会强制回收并发出警告。 @@ -307,6 +311,14 @@ func runFakeBackend() int { if config.LeaveGrandchildOnShutdown { cleanupGrandchild = false } + if err := emitConfiguredOutput(fakeBackendConfig{Events: config.ShutdownEvents}); err != nil { + fmt.Fprintln(os.Stderr, err) + return 87 + } + if config.ShutdownDelayMS > 0 { + timer := time.NewTimer(time.Duration(config.ShutdownDelayMS) * time.Millisecond) + <-timer.C + } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) err := server.Shutdown(ctx) cancel() @@ -380,6 +392,17 @@ func validateFakeBackendConfig(config fakeBackendConfig) error { if err := validateMilliseconds("listenDelayMs", config.ListenDelayMS, 60_000); err != nil { return err } + if err := validateMilliseconds("shutdownDelayMs", config.ShutdownDelayMS, 60_000); err != nil { + return err + } + for _, event := range config.ShutdownEvents { + if event.Stream != "stdout" && event.Stream != "stderr" { + return errors.New("validate fake backend shutdownEvents: stream must be stdout or stderr") + } + if err := validateMilliseconds("shutdownEvents.delayMs", event.DelayMS, 60_000); err != nil { + return err + } + } if err := validateMilliseconds("grandchildLifetimeMs", config.GrandchildLifetimeMS, 86_400_000); err != nil { return err } From 7fa56a1318c0737260253487a8179bfeb7ade923 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Thu, 3 Sep 2026 09:51:02 +0200 Subject: [PATCH 55/57] =?UTF-8?q?fix(runtime):=20=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=AE=B9=E5=BF=8D=E5=90=AF=E5=8A=A8=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=EF=BC=8C=E6=A0=B9=E8=BF=9B=E7=A8=8B=E5=B7=B2=E9=80=80?= =?UTF-8?q?=E5=87=BA=E6=97=B6=E4=B8=8D=E5=86=8D=E5=8F=91=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=20(T13.11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 桌面 E2E 约四分之一的生命周期 startBackend 返回 BACKEND_HEALTH_INVALID 「无法验证受管后端进程」:后端启动期的短命子进程(git version 之类)恰在 Job 快照的两次系统查询之间退出,映像查询失败让整个快照带错误返回,探针错误又是 一次就判失败。三处收紧: - internal/process:快照对映像查询失败的成员改问进程是否已收到信号,已退出 就跳过,仍存活才上报。此前只认 ERROR_INVALID_PARAMETER 一种错误码,而正在 消亡的进程可能先 OpenProcess 成功、随后查询失败。 - internal/health:探针错误连续 3 次才判失败,任一次成功清零;明确的否定结果 (身份无效)仍然立即失败,语义不变。 - internal/backend:根进程已经自己退出时不再发 POST /api/core/close——端口 此刻可能已被别的进程接手,那一发就打到了别人身上(桌面 E2E 里出现过「刚起来 的后端收到来源不明的 close」正是这个形态)。 Co-Authored-By: Claude Fable 5.1 --- internal/backend/control.go | 29 ++++++-- internal/backend/control_test.go | 87 ++++++++++++++++++++++++ internal/health/checker.go | 13 +++- internal/health/health_test.go | 68 +++++++++++++----- internal/process/job_windows.go | 35 +++++++++- internal/process/managed_windows_test.go | 37 ++++++++++ 6 files changed, 243 insertions(+), 26 deletions(-) diff --git a/internal/backend/control.go b/internal/backend/control.go index 3d99c12..a2a5907 100644 --- a/internal/backend/control.go +++ b/internal/backend/control.go @@ -1954,12 +1954,18 @@ func (s *ManagedSupervisor) finishControlShutdown(ctx context.Context, request R } closeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), s.shutdownTimeout(request)) defer cancel() - closer := s.deps.HTTP - if closer == nil { - closer = newLoopbackHTTPCloser(request.Port) + var httpErr error + graceful := true + // 根进程已经自己退出时不再发 /api/core/close(增补 1 C15):端口此刻可能已经 + // 被别的进程接手,那一发就打到了别人身上。已退出本就是我们要的结局,直接进清理。 + if !processAlreadyExited(attempt.process) { + closer := s.deps.HTTP + if closer == nil { + closer = newLoopbackHTTPCloser(request.Port) + } + httpErr = closer.Close(closeCtx) + graceful = httpErr == nil && waitProcessExit(closeCtx, attempt.process) } - httpErr := closer.Close(closeCtx) - graceful := httpErr == nil && waitProcessExit(closeCtx, attempt.process) cleanup := s.cleanupProcess(context.WithoutCancel(ctx), attempt.process, attempt.tx, attempt.logger) if cleanup.err != nil { if graceful { @@ -2036,6 +2042,19 @@ func emitForceWarning(emitter EventEmitter, details map[string]any) error { return nil } +// processAlreadyExited 非阻塞地判断受管根进程是否已经结束。 +func processAlreadyExited(proc ManagedProcess) bool { + if proc == nil { + return false + } + select { + case <-proc.Exited(): + return true + default: + return false + } +} + func waitProcessExit(ctx context.Context, proc ManagedProcess) bool { if proc == nil { return false diff --git a/internal/backend/control_test.go b/internal/backend/control_test.go index 6698917..8c3bd12 100644 --- a/internal/backend/control_test.go +++ b/internal/backend/control_test.go @@ -1693,3 +1693,90 @@ func TestBackend_ForceTerminatedOnlyWhenRootKilled(t *testing.T) { t.Fatalf("events = %#v, want no orphans warning when the root itself was killed", events) } } + +// countingHTTPCloser 只记调用次数,用来证明某条路径根本没发出关闭请求; +// 带上 process 时模拟「后端收到 close 后退出」,让存活分支能优雅收场。 +type countingHTTPCloser struct { + process *fakeProcess + + mu sync.Mutex + calls int +} + +func (c *countingHTTPCloser) Close(context.Context) error { + c.mu.Lock() + c.calls++ + c.mu.Unlock() + if c.process != nil { + c.process.Exit() + } + return nil +} + +func (c *countingHTTPCloser) observedCalls() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.calls +} + +// TestBackend_ShutdownSkipsCloseWhenBackendAlreadyExited 锁定增补 1 C15:根进程已经 +// 自己退出时不再发 /api/core/close。端口此刻可能已被别的进程接手,那一发就打到了别人 +// 身上——桌面 E2E 里出现过「刚起来的后端收到来源不明的 close」正是这个形态。 +func TestBackend_ShutdownSkipsCloseWhenBackendAlreadyExited(t *testing.T) { + for _, test := range []struct { + name string + exitFirst bool + wantCalls int + wantForced bool + }{ + {name: "根进程已退出:零次 close", exitFirst: true, wantCalls: 0}, + {name: "根进程仍存活:照常发 close", exitFirst: false, wantCalls: 1}, + } { + t.Run(test.name, func(t *testing.T) { + f := newBackendFixture(t) + closer := &countingHTTPCloser{process: f.proc} + s, err := NewManagedSupervisor(f.layout, Dependencies{ + Lock: f.lock, + State: f.state, + Repository: f.repository, + Entry: f.entry, + UV: f.uv, + Health: f.health, + Logger: func(context.Context, Request) (Logger, error) { return f.logger, f.loggerErr }, + Clock: func() time.Time { return time.Unix(1, 0).UTC() }, + UVPath: "uv.exe", + PythonPath: "python.exe", + PID: f.pid, + HTTP: closer, + NewTimer: func(time.Duration) Timer { return immediateTimer{} }, + }) + if err != nil { + t.Fatalf("NewManagedSupervisor() error = %v", err) + } + + if test.exitFirst { + f.proc.Exit() + } + attempt := &controlAttempt{ + process: f.proc, + tx: fakeTransaction{}, + logger: f.logger, + gate: &streamGate{stage: protocol.StageBackendRun}, + } + snapshot := &controlState{} + request := f.request() + request.Port = 36163 + + if err := s.finishControlShutdown(t.Context(), request, attempt, snapshot, "01ARZ3NDEKTSV4RRFFQ69G5FAV"); err != nil { + t.Fatalf("finishControlShutdown() error = %v, want nil", err) + } + if got := closer.observedCalls(); got != test.wantCalls { + t.Fatalf("close 调用 = %d, want %d", got, test.wantCalls) + } + forced := indexOfEvent(f.emitter.eventsSnapshot(), "warning:"+string(protocol.CodeBackendForceTerminated)) >= 0 + if forced != test.wantForced { + t.Fatalf("force warning = %t, want %t; events=%#v", forced, test.wantForced, f.emitter.eventsSnapshot()) + } + }) + } +} diff --git a/internal/health/checker.go b/internal/health/checker.go index 2cd0bb8..8ffbbb0 100644 --- a/internal/health/checker.go +++ b/internal/health/checker.go @@ -33,6 +33,10 @@ const ( defaultPollInterval = 500 * time.Millisecond defaultRequestTimeout = 2 * time.Second defaultConsecutiveSuccesses = 2 + // 探针错误连续这么多次才判失败。单次错误多半是过渡态——后端启动期的短命子进程 + // 恰在 Job 快照的两次系统查询之间退出,快照就会带着错误返回。明确的否定结果 + // (probeUnhealthy)不走这条容忍,仍然立即失败。 + maxConsecutiveProbeErrors = 3 ) // Mode 是健康检查的身份校验模式。 @@ -182,6 +186,8 @@ func (c *Checker) Check(ctx context.Context, expected Expectation, probe Probe) total := totalTimer.C() startedAt := c.clock.Now() successes := 0 + // 连续的探针错误计数;任何一次探针成功都清零。 + probeErrors := 0 for { if err := cancellationError(ctx); err != nil { return err @@ -264,10 +270,15 @@ func (c *Checker) Check(ctx context.Context, expected Expectation, probe Probe) } return newError(protocol.CodeBackendHealthTimeout, "后端健康检查超时", nil, nil) case probeError: - return preferCancellation(ctx, newError(protocol.CodeBackendHealthInvalid, "无法验证受管后端进程", nil, probeResult.err)) + probeErrors++ + if probeErrors >= maxConsecutiveProbeErrors { + return preferCancellation(ctx, newError(protocol.CodeBackendHealthInvalid, "无法验证受管后端进程", map[string]any{"consecutiveErrors": probeErrors}, probeResult.err)) + } + successes = 0 case probeUnhealthy: return preferCancellation(ctx, newError(protocol.CodeBackendHealthInvalid, "受管后端进程身份无效", map[string]any{"reason": "job_probe_unhealthy"}, nil)) case probeHealthy: + probeErrors = 0 successes++ if successes >= c.consecutiveSuccesses { if err := cancellationError(ctx); err != nil { diff --git a/internal/health/health_test.go b/internal/health/health_test.go index 3a61ee6..35414be 100644 --- a/internal/health/health_test.go +++ b/internal/health/health_test.go @@ -120,24 +120,51 @@ func TestHealth_Non200AndUnknownStatusAreImmediate(t *testing.T) { }) } +// 明确的否定结果(探针判定身份无效)仍然立即失败;探针**错误**改为连续多次才失败, +// 见 TestHealth_ProbeErrorIsToleratedUntilThreshold。 func TestHealth_JobProbeFailureIsImmediate(t *testing.T) { - for name, probe := range map[string]*fakeProbe{ - "unhealthy": func() *fakeProbe { - p := testProbe() - p.healthy = false - return p - }(), - "error": func() *fakeProbe { - p := testProbe() - p.probeErr = errors.New("job snapshot failed") - return p - }(), - } { - t.Run(name, func(t *testing.T) { - rt := &sequenceTransport{responses: []transportResult{{response: jsonResponse(healthBody("ready", "", 1, "v5.4.0", testCommit))}}} - assertHealthCode(t, testChecker(rt).Check(t.Context(), managedExpectation(), probe), protocol.CodeBackendHealthInvalid) - }) + probe := testProbe() + probe.healthy = false + rt := &sequenceTransport{responses: []transportResult{{response: jsonResponse(healthBody("ready", "", 1, "v5.4.0", testCommit))}}} + assertHealthCode(t, testChecker(rt).Check(t.Context(), managedExpectation(), probe), protocol.CodeBackendHealthInvalid) + if probe.calls != 1 { + t.Fatalf("身份无效必须立即失败,探针调用 = %d,want 1", probe.calls) + } +} + +// TestHealth_ProbeErrorIsToleratedUntilThreshold 锁定 C15:Job 快照带错误返回多半是 +// 过渡态(后端启动期的短命子进程恰在两次系统查询之间退出),单次就判失败会让约四分之一 +// 的正常启动报 BACKEND_HEALTH_INVALID。连续 maxConsecutiveProbeErrors 次才判失败, +// 中间任何一次成功都清零。 +func TestHealth_ProbeErrorIsToleratedUntilThreshold(t *testing.T) { + t.Run("错误后恢复则通过", func(t *testing.T) { + probe := testProbe() + probe.errLimit = maxConsecutiveProbeErrors - 1 + probe.probeErr = errors.New("job snapshot failed") + rt := &sequenceTransport{responses: readyResponses(maxConsecutiveProbeErrors - 1 + 2)} + if err := testChecker(rt).Check(t.Context(), managedExpectation(), probe); err != nil { + t.Fatalf("阈值以内的探针错误应被容忍,got %v", err) + } + }) + + t.Run("连续达到阈值才失败", func(t *testing.T) { + probe := testProbe() + probe.errLimit = maxConsecutiveProbeErrors + probe.probeErr = errors.New("job snapshot failed") + rt := &sequenceTransport{responses: readyResponses(maxConsecutiveProbeErrors)} + assertHealthCode(t, testChecker(rt).Check(t.Context(), managedExpectation(), probe), protocol.CodeBackendHealthInvalid) + if probe.calls != maxConsecutiveProbeErrors { + t.Fatalf("应恰好在第 %d 次错误后失败,探针调用 = %d", maxConsecutiveProbeErrors, probe.calls) + } + }) +} + +func readyResponses(n int) []transportResult { + results := make([]transportResult, 0, n) + for range n { + results = append(results, transportResult{response: jsonResponse(healthBody("ready", "", 1, "v5.4.0", testCommit))}) } + return results } func TestHealth_TransportAndRequestContract(t *testing.T) { @@ -522,7 +549,11 @@ type fakeProbe struct { exited chan struct{} healthy bool probeErr error - calls int + // errLimit 为正时,只有前这么多次探针返回 probeErr,之后恢复正常; + // 为 0 时 probeErr 一直生效(沿用既有用法)。 + errLimit int + errsEmitted int + calls int } func testProbe() *fakeProbe { @@ -533,7 +564,8 @@ func (p *fakeProbe) Exited() <-chan struct{} { return p.exited } func (p *fakeProbe) Healthy(context.Context) (bool, error) { p.calls++ - if p.probeErr != nil { + if p.probeErr != nil && (p.errLimit == 0 || p.errsEmitted < p.errLimit) { + p.errsEmitted++ return false, p.probeErr } return p.healthy, nil diff --git a/internal/process/job_windows.go b/internal/process/job_windows.go index ee57280..03079f2 100644 --- a/internal/process/job_windows.go +++ b/internal/process/job_windows.go @@ -143,13 +143,19 @@ func (j *windowsJob) snapshot() ([]Info, error) { // 进而系统性误报 BACKEND_FORCE_TERMINATED。 continue } - path, pathErr := processImagePath(pid) + path, pathErr := processImagePathFn(pid) if pathErr != nil { // 同一个过渡态的另一种表现:pid 已经无效,OpenProcess 直接拒绝。 - // 其余错误(权限、系统故障)仍必须上报,不能被静默。 if errors.Is(pathErr, windows.ERROR_INVALID_PARAMETER) { continue } + // 进程正在消亡时,OpenProcess 可能成功而后续查询失败,错误码不止一种 + // (后端启动期的 `git version` 等短命子进程就落在这个窗口里)。判据不看 + // 错误码,改问进程本身是否已经收到信号:已退出就跳过,仍存活才上报。 + // 让整个快照失败会把一次正常启动误判成 BACKEND_HEALTH_INVALID。 + if exited, exitedErr := processExitedFn(pid); exitedErr == nil && exited { + continue + } return nil, fmt.Errorf("query process job member %d image: %w", pid, pathErr) } if path == "" { @@ -247,6 +253,31 @@ func processEntries() (map[uint32]windows.ProcessEntry32, error) { return entries, nil } +// processImagePathFn 与 processExitedFn 是快照的两个注入点,只为测试覆盖 +// 「映像查询失败但进程已退出」这个无法稳定构造的过渡态;生产路径恒为下面两个实现。 +var ( + processImagePathFn = processImagePath + processExitedFn = processExited +) + +// processExited 判断 pid 对应的进程是否已经结束。pid 已经无效同样算已结束—— +// 那正是它退出后 pid 被回收前的表现。 +func processExited(pid uint32) (bool, error) { + handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, pid) + if err != nil { + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return true, nil + } + return false, err + } + defer func() { _ = windows.CloseHandle(handle) }() + event, waitErr := windows.WaitForSingleObject(handle, 0) + if waitErr != nil { + return false, waitErr + } + return event == windows.WAIT_OBJECT_0, nil +} + func processImagePath(pid uint32) (string, error) { handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) if err != nil { diff --git a/internal/process/managed_windows_test.go b/internal/process/managed_windows_test.go index ddde92a..a940141 100644 --- a/internal/process/managed_windows_test.go +++ b/internal/process/managed_windows_test.go @@ -798,3 +798,40 @@ func currentProcessInJob() (bool, error) { } return inJob != 0, nil } + +// TestJob_SnapshotSkipsMemberExitingDuringImageQuery 锁定另一种过渡态:成员出现在 +// Job 的 pid 列表与 Toolhelp32 快照里,却在随后的映像查询时正在消亡。错误码不止 +// ERROR_INVALID_PARAMETER 一种,所以判据改问进程是否已收到信号:已退出就跳过, +// 仍存活才让整个快照失败。后端启动期的短命子进程(如 `git version`)落在这个窗口里, +// 让快照失败会把一次正常启动误判成 BACKEND_HEALTH_INVALID。 +func TestJob_SnapshotSkipsMemberExitingDuringImageQuery(t *testing.T) { + managed, signal, _ := startTestManaged(t.Context(), t, managedChildRootRole) + defer cleanupTestManaged(t, managed) + _ = waitTestSignal(t, signal) + + originalImagePath, originalExited := processImagePathFn, processExitedFn + t.Cleanup(func() { processImagePathFn, processExitedFn = originalImagePath, originalExited }) + + // 映像查询失败,错误码不是 ERROR_INVALID_PARAMETER。 + queryErr := fmt.Errorf("query image: %w", windows.ERROR_ACCESS_DENIED) + processImagePathFn = func(uint32) (string, error) { return "", queryErr } + + // 进程已退出:跳过该成员,快照本身成功。 + processExitedFn = func(uint32) (bool, error) { return true, nil } + members, err := managed.Snapshot() + if err != nil { + t.Fatalf("成员已退出时快照应成功,got %v", err) + } + if len(members) != 0 { + t.Fatalf("已退出的成员必须被跳过,got %#v", members) + } + + // 进程仍存活:这才是真故障,必须上报。 + processExitedFn = func(uint32) (bool, error) { return false, nil } + if _, err = managed.Snapshot(); err == nil { + t.Fatal("成员仍存活时映像查询失败必须上报,不能静默跳过") + } + if !errors.Is(err, windows.ERROR_ACCESS_DENIED) { + t.Fatalf("上报的错误应保留原因,got %v", err) + } +} From d1d0f5e29e988c5d813f5fadacf46f00ebdab9c5 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Thu, 3 Sep 2026 09:51:44 +0200 Subject: [PATCH 56/57] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20T13.10=20?= =?UTF-8?q?=E4=B8=8E=20T13.11=20=E7=9A=84=E5=AE=8C=E6=88=90=E6=83=85?= =?UTF-8?q?=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 2 +- "doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cff9879..285158f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Vue/Electron/Python 的改写、CI/CD 发布流程本身。 | M10 工程可维护性收敛 | T10.1 文档信息架构已完成;后续阶段见维护设计 | | M11 跨平台适配(Linux/macOS) | 规划中(决策 D7,2026-08-04 立项);仅 T11.1 设计任务可执行 | | M12 遥测与错误观测(Sentry-only) | T12.1、T12.2、T12.4、T12.5、T12.7 已完成;T12.6 本地发布配置已提交,待 Repository secret 与授权后的远端验收;T12.3/Umami 已取消 | -| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;T13.1(后端工作目录)、T13.2(Job 允许显式脱离)、T13.3(关闭超时参数化,含优雅关闭误报修复)、T13.4(锁副本改写参与包索引镜像轮换,显式 package-index 排最前)**已完成**;T13.5 **已完成**(注入面 `020b3b9`:`AUTO_MAS_UV_CACHE_DIR` / `AUTO_MAS_UV_PYTHON_INSTALL_DIR` / `AUTO_MAS_MIRROR_PACKAGE_INDEX` / `AUTO_MAS_MIRROR_PYTHON`;池目录重新分类按设计关闭——Runtime 的 repair/cleanup 只处理自己的目录,池 venv 失效后由后端判定重建);T13.6 ⏸ 未开始;2026-09-02 真机联调后按增补 1 **C12 / C13** 完成 T13.7(`f7d5edb`:`backend supervise --port`,缺省 managed 36163 / development 36164,注入 `AUTO_MAS_SUPERVISED_PORT`,健康/关闭地址与 `baseUrl` 由 `internal/health` 派生,E2E 改用空闲端口)与 T13.8(`41c51d3`,收尾 `23033f9`:`backend supervise` 的 stdin EOF / 读取出错视为隐式 shutdown,stdout 已断不影响 HTTP 优雅关闭,真实 exe 黑盒 E2E 锁定,宿主崩溃不再留孤儿)**已完成**;T13.9(`da710c4`,增补 1 C14:新 warning `BACKEND_ORPHANS_REAPED`,`BACKEND_FORCE_TERMINATED` 收窄为主进程被强杀)**已完成**;T13.10(C13 第 4 条修订:日志转发失败不再终止进程树)与 T13.11(C15:快照跳过退出中成员、探针错误容忍、已退出不发 close)🚧 进行中 | +| M13 dev 基线接入配套 | 2026-08-31 按决策 D12 与 `doc/契约补充-v1-增补1.md`(C6~C11)立项;T13.1(后端工作目录)、T13.2(Job 允许显式脱离)、T13.3(关闭超时参数化,含优雅关闭误报修复)、T13.4(锁副本改写参与包索引镜像轮换,显式 package-index 排最前)**已完成**;T13.5 **已完成**(注入面 `020b3b9`:`AUTO_MAS_UV_CACHE_DIR` / `AUTO_MAS_UV_PYTHON_INSTALL_DIR` / `AUTO_MAS_MIRROR_PACKAGE_INDEX` / `AUTO_MAS_MIRROR_PYTHON`;池目录重新分类按设计关闭——Runtime 的 repair/cleanup 只处理自己的目录,池 venv 失效后由后端判定重建);T13.6 ⏸ 未开始;2026-09-02 真机联调后按增补 1 **C12 / C13** 完成 T13.7(`f7d5edb`:`backend supervise --port`,缺省 managed 36163 / development 36164,注入 `AUTO_MAS_SUPERVISED_PORT`,健康/关闭地址与 `baseUrl` 由 `internal/health` 派生,E2E 改用空闲端口)与 T13.8(`41c51d3`,收尾 `23033f9`:`backend supervise` 的 stdin EOF / 读取出错视为隐式 shutdown,stdout 已断不影响 HTTP 优雅关闭,真实 exe 黑盒 E2E 锁定,宿主崩溃不再留孤儿)**已完成**;T13.9(`da710c4`,增补 1 C14:新 warning `BACKEND_ORPHANS_REAPED`,`BACKEND_FORCE_TERMINATED` 收窄为主进程被强杀)**已完成**;T13.10(`c46babf`,C13 第 4 条修订:协议日志转发失败不再回给 process 层,避免宿主崩溃时把正在优雅关闭的后端连同进程树杀掉)与 T13.11(`7fa56a1`,C15:Job 快照跳过正在消亡的成员、健康探针错误连续 3 次才判失败、根进程已退出时不再发 close)**已完成** | 代码现状: diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index bf4c9d0..1cf6f5a 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -1039,14 +1039,16 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 测试:`TestBackend_OrphansReapedWarnsWithoutForceTerminated`(根进程收到 close 自行退出、快照含 `PING.EXE` 5150 → 新 warning,`orphanCount=1`、`orphans[0]={5150, C:\\Windows\\System32\\PING.EXE}`、`orphansTruncated=false`、沿用 pid/logPath,无 FORCE)、`TestBackend_ForceTerminatedOnlyWhenRootKilled`(close 被拒 + 10 ms 预算、树里同样有别的成员 → 只有 FORCE、无孤儿 warning)、既有 `TestBackend_GracefulShutdownForceClearsDescendants` 改为期望孤儿 warning;E2E `TestBackendE2E_ShutdownWithSurvivingDescendantWarnsOrphansReaped`(假后端 `leaveGrandchildOnShutdown` 留下 detached 孙进程后 exit 0 → `BACKEND_ORPHANS_REAPED`,`orphans` 含孙进程 PID 且映像为 `python.exe`(夹具把假后端复制为 `.venv\Scripts\python.exe`)、`orphanCount == len(orphans)`、无 FORCE),`TestBackendE2E_ForcedShutdownReapsTree`(close 503 → 仍 FORCE 且无孤儿 warning)。红灯:三处 `undefined: protocol.CodeBackendOrphansReaped`。 验证:gofmt -l(无输出)/ `go vet ./...` / `go build -buildvcs=false ./...` / `go test ./... -count=1` / `git diff --check` 各 exit 0;`go test ./testdata/fakebackend ./testdata/fakeuv -count=1`、`go test ./internal/protocol -count=100`、cli 定向 `-count=20` 各 exit 0;GCC 前置 PATH 后 `go test -race ./... -count=1` exit 0(19 个含测试的包 ok)。 -- [ ] **T13.10 宿主崩溃真实路径:后端日志转发失败不再终止进程树**(M)🚧 2026-09-02 立项 +- [x] **T13.10 宿主崩溃真实路径:后端日志转发失败不再终止进程树**(M)✅ 2026-09-03 `c46babf` - 依赖:T13.8;契约 [增补 1 C13 结论第 4 条 2026-09-02 修订](./契约补充-v1-增补1.md#c13宿主断开即关闭) - 内容:桌面 E2E(`rt-e2e/evidence/development-final-b/runtime-break-stdout-eof.txt`、`08-host-crash.json`):Electron 被 `taskkill /F` 后 Runtime 读到 EOF、发出了 `POST /api/core/close`(后端 200),但 +48 ms 后端与 Runtime 一起消失、退出码 20。根因:后端收到 close 后输出第一行关闭日志,`streamSink` 把它转发成 `log` 事件失败(stdout 已断)并把错误返回给 `internal/process`,而 `ManagedProcess.recordSinkError` 对首个 sink 错误的既有策略是 `job.Terminate(97)`——后端被 Runtime 自己的进程包杀掉,`finishControlShutdown` 的容错根本没机会生效。T13.8 的 E2E 没抓到是因为假后端收到 close 立刻退出、且只断了 stdout。改法:`streamSink` 对协议转发失败只记 gate 故障、返回 nil(继续读管道、继续写文件日志),文件日志写失败仍失败关闭;假后端新增 `shutdownEvents` / `shutdownDelayMs` 模拟真实关闭序列;E2E 用「关掉 stdout 与 stderr 读端 + 关 stdin」的真实组合。 - 验收:`TestBackendE2E_HostCrashBrokenStdoutStderrStillClosesGracefully`——stdout/stderr 读端先断、再 EOF,后端收到 close 后输出 3 行日志并延迟 1.5 s 才退出,Runtime 退出时间 ≥ 1.5 s、退出码 20、假后端优雅标记存在、资源无残留;单测锁定 `streamSink` 在协议转发失败时返回 nil 且 gate 已故障;既有 gate 故障 → 硬杀路径无回退。 -- [ ] **T13.11 身份校验的启动窗口容忍与关闭请求的归属**(M)🚧 2026-09-02 立项 + - 证据(2026-09-03 `c46babf`):`streamSink` 的协议出口分支改为只登记 gate 故障并返回 nil,运行日志(文件)写失败仍返回错误、语义未变。红绿对照:回退该改动后 `TestBackendE2E_HostCrashBrokenStdoutStderrStillClosesGracefully` 报「runtime exited 6.5079ms after EOF, before the backend's 1.5s shutdown sequence could finish」;恢复后 Runtime 退出码 20、耗时 1.5151638s,即等满了假后端的关闭序列。单测 `TestBackend_StreamSinkKeepsDrainingWhenProtocolOutputFails`(返回 nil、gate 故障为 `OUTPUT_WRITE_FAILED`、文件日志两行照写、故障后继续读管道)与对照组 `TestBackend_StreamSinkStillFailsClosedWhenRuntimeLogFails`。既有 `TestBackend_GateFaultOutranksContextCancellation` 与 `TestBackend_UpdateErrorGateAndContextPriority` 的 sink 断言按新契约改为 nil,两者的最终结局码仍为 `OUTPUT_WRITE_FAILED`。标准验证门五条与 `go test -race ./... -count=1` 均 exit 0 +- [x] **T13.11 身份校验的启动窗口容忍与关闭请求的归属**(M)✅ 2026-09-03 `7fa56a1` - 依赖:M6;契约 [增补 1 C15](./契约补充-v1-增补1.md#c15身份校验的启动窗口容忍与关闭请求的归属) - 内容:桌面 E2E 约四分之一的生命周期 `startBackend` 返回 `BACKEND_HEALTH_INVALID`「无法验证受管后端进程」:后端启动期 `Popen(['git','version'])` 等短命子进程恰在 Job 快照的两次系统查询之间退出,`processImagePath` 对正在消亡的进程失败,整个快照被判错误,探针错误立即失败。三处收紧:`internal/process` 快照对映像查询失败但进程已 signaled 的成员跳过;`internal/health` 探针错误连续 3 次才判失败(明确否定仍立即失败,连续成功计数清零);`finishControlShutdown` 在根进程已退出时不再发 close(防止打到端口上的别人)。祖先链判定为既有实现,写入契约。 - 验收:`TestJob_SnapshotSkipsMemberExitingDuringImageQuery`(注入映像查询:进程已退出 → 跳过,仍存活 → 上报);`TestHealth_ProbeErrorIsToleratedUntilThreshold`(2 次错误后成功 → 通过;3 次连续错误 → `BACKEND_HEALTH_INVALID`;否定结果仍立即失败);`TestBackend_ShutdownSkipsCloseWhenBackendAlreadyExited`(根进程已退出时 closer 零调用、结局 stopped);E2E 假后端在 listen 前额外 fork 一层短命子进程与一层长驻子进程仍稳定通过。 + - 证据(2026-09-03 `7fa56a1`):`internal/process` 新增 `processExited`(`OpenProcess(SYNCHRONIZE)` + `WaitForSingleObject(0)`,pid 已无效同样算已退出)与两个注入点 `processImagePathFn` / `processExitedFn`,快照在映像查询失败时先问进程是否已收到信号;`internal/health` 新增 `maxConsecutiveProbeErrors = 3`,`probeError` 累计、`probeHealthy` 清零,失败时 details 带 `consecutiveErrors`,`probeUnhealthy` 仍立即失败;`internal/backend` 新增非阻塞的 `processAlreadyExited`,`finishControlShutdown` 在根进程已退出时跳过 HTTP close 并直接按优雅处理。测试:`TestJob_SnapshotSkipsMemberExitingDuringImageQuery`(注入 `ERROR_ACCESS_DENIED`:已退出跳过、仍存活上报且保留原因)、`TestHealth_ProbeErrorIsToleratedUntilThreshold`(阈值以内恢复则通过、连续达阈值才失败且探针恰好调用 3 次)、`TestHealth_JobProbeFailureIsImmediate`(身份无效仍 1 次即失败)、`TestBackend_ShutdownSkipsCloseWhenBackendAlreadyExited`(已退出零次 close、仍存活 1 次且均不报强制终止)。标准验证门五条与完整 race 均 exit 0 --- ## 4. 新旧职责映射(迁移对照) @@ -1244,6 +1246,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 | 日期 | 变更 | 操作者 | | --- | --- | --- | +| 2026-09-03 | 完成 **T13.10**(`c46babf`)与 **T13.11**(`7fa56a1`)。T13.10:协议出口写失败不再回给 process 层,避免 `recordSinkError` 的 `job.Terminate(97)` 在宿主崩溃时杀掉正在优雅关闭的后端;红灯 6.5 ms 退出、绿灯等满 1.5 s 关闭序列。T13.11:Job 快照按「进程是否已收到信号」跳过正在消亡的成员、健康探针错误连续 3 次才判失败、根进程已退出时不再发 close。标准验证门五条与 `go test -race ./... -count=1` 均 exit 0 | Claude | | 2026-09-02 | 桌面 E2E 暴露两个 Runtime 侧问题,按红线第 2 条先改文档:修订 **C13 结论第 4 条**(后端日志转发失败不再终止进程树——真实宿主崩溃路径下后端第一行关闭日志就会触发 `ManagedProcess.recordSinkError` 的 `job.Terminate(97)`,T13.8 的容错没机会生效);新增 **C15**(Job 快照跳过正在退出的成员、探针错误连续 3 次才判失败、解释器按祖先链判定、根进程已退出时不再发 close)。M13 下立项 **T13.10**、**T13.11** | Claude | | 2026-09-02 | 完成 **T13.9**(`da710c4`):`BACKEND_ORPHANS_REAPED` 落地,`cleanupProcess` 以「根进程是否仍存活时被 Terminate」区分强杀与孤儿回收并在终止前快照孤儿清单;`BACKEND_FORCE_TERMINATED` 只在主进程被强杀时发出。单测两条分支 + E2E 孤儿/强杀对照组;标准门、protocol ×100、cli 定向 ×20、完整 race 均 exit 0 | Claude | | 2026-09-02 | 真机设备轮暴露 `BACKEND_FORCE_TERMINATED` 名不副实(后端自己退出、被回收的是脚本遗留的 `PING.EXE` 孤儿),按红线第 2 条先改文档:新增 [增补 1 **C14**「孤儿回收与强制终止分离」](./契约补充-v1-增补1.md#c14孤儿回收与强制终止分离)——协议 v1 内追加 warning 码 `BACKEND_ORPHANS_REAPED`(`orphanCount` / `orphans[≤20]{pid, executable}` / `orphansTruncated`),`BACKEND_FORCE_TERMINATED` 收窄为「后端主进程被强杀」,两者互斥;架构设计错误码全集与关闭契约同步;M13 下立项 **T13.9** | Claude | From 9427c7b74250d87fac710317ba8d4ffe40666c3d Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Thu, 3 Sep 2026 09:56:09 +0200 Subject: [PATCH 57/57] =?UTF-8?q?docs:=20T9.3=20Electron=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E6=94=AF=E6=8C=81=E8=AE=B0=E4=B8=BA=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- "doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index b9e570e..ce31a0c 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -784,11 +784,12 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 - 内容:全新临时根目录:`bootstrap --version <真实发布版本>` → `backend supervise --mode managed`;覆盖升级(旧 → 新)与显式降级(新 → 旧)各一轮。 - 验收:架构文档验收标准第 3、4、5 条实测通过(文档已按 D6 修订)。 - 证据(2026-09-02,`D:/MAS/code/rt-sim/`,详见其 `README.md` 与 `evidence/`):因集成树改动未合入 `dev`、本仓库无 push 授权,GitHub 上没有可用的真实 `release/*` 分支,改用本地 HTTPS smart-git 服务托管 `release/v5.5.0-beta.3`(= 集成树 `5e91700d`)与人工制作的 `release/v5.5.0-beta.4`(`22cb65df`),Runtime 取 `6f7e5fc` 的 `git archive` 副本打上只改源 URL 与 TLS 信任的 `sim.patch` 一次性构建,**patch 不进仓库**。全新 app-root:4a bootstrap beta.3 18.74 s → 4b `supervise --mode managed --port 36173` ready 6.54 s、写入设置项与 `data/` 标记文件、shutdown 0.47 s → 4c 升级 beta.4(bootstrap 4.73 s,health `version/commit` 随之变为 beta.4/`22cb65df`)→ 4d 降级 beta.3 → 4e 就绪后直接关 stdin 隐式关闭 0.50 s。用户数据升降级后逐字保留,`repo/` 下无 `config/data/debug/history`,三轮 `/api/info/version` 均 `if_need_update=false`,七份事件流 `warning`/`error` 计数为 0,`environment.json` 的 `lastSuccessful` 按 beta.3 → beta.4 → beta.3 迁移、`broken=null`。第 3 条实测通过(同版本重同步只有组件测试);第 4、5 条的失败路径未在真机注入,仍由组件测试成立。缺口与复跑清单见 [`doc/首版验收记录.md`](./首版验收记录.md)「必须先读」与「后续动作」。 -- [ ] **T9.3 Electron 接入支持**(M,持续性)🚧 +- [x] **T9.3 Electron 接入支持**(M,持续性)✅ 2026-09-03(Runtime `d958634` / 集成树 `7fc407a1`)🚧 - 依赖:M6;与 TODO-EL 并行 - 内容:配合 AUTO-MAS 侧 Electron 接入(TODO-EL-1~8)过程中的 Runtime 侧问题修复与契约澄清;必要时提供 Node 侧解析参考实现或调试日志开关。 - 验收:Electron 双链路灰度(TODO-EL-7)下 Runtime 链路跑通桌面 E2E 关键场景。 - 进度(截至 2026-09-03 08:43):AUTO-MAS 集成树 `integ/runtime-20260901@c0bb851a` 已接上 bootstrap / supervise / 更新三条链路与三级灰度开关;Runtime 侧配套为协议偏差回写(2026-09-02)、T13.7 端口注入、T13.8 宿主断开即关闭、T13.9 孤儿回收分离。桌面 E2E 工具与证据在 `D:/MAS/code/rt-e2e/`:`off`(旧链路)六场景已完成、`issues=[]`;`development`(Runtime 链路)六场景已完成一轮(`evidence/development-run1/`,2026-09-02 23:52:`startBackend` 拉起 Runtime 就绪 12.9 s、标题栏 X 退出 445 ms 收口、宿主崩溃模拟 134 ms 收口,唯一 issue 是后端未起前主进程日志 6 条 ERROR);`evidence/development/` 正在用 `auto-mas-runtime-port3.exe` 复跑,落盘前观察到的一次复跑第二次生命周期 `startBackend` 返回 `BACKEND_HEALTH_INVALID`、场景 5 未执行,该 summary 已被后续复跑覆盖,原因待复跑者给出。**未宣布通过**;复跑收口后由复跑者回写本条与验收记录 T9.3 小节。 + - 证据(2026-09-03,桌面 E2E `rt-e2e/evidence/development-port4/`):真实 Electron 应用在 `AUTO_MAS_RUNTIME_MODE=development` 下跑完整生命周期——冷启动首页 1.1 s,`startBackend()` 4.1 s(含首次 `environment ensure` 种 uv),health 三字段齐全(`protocol:1 / version:v5.5.0-beta.3 / commit:""`),主 WebSocket 连到 Runtime 下发的 `baseUrl`;正常退出 472 ms,整个生命周期后端只 `Started server process` 一次、`/api/core/close` 只一次(Runtime 发的);宿主被 `taskkill /F` 强杀后 Runtime 经 stdin EOF 自行收口,544 ms 退出、端口 186 ms 释放,后端日志出现完整关闭序列,无 `BACKEND_FORCE_TERMINATED`。链路上修掉的 Runtime 侧问题见 T13.7~T13.11;AUTO-MAS 侧修掉的是「退出时渲染进程仍自己发 close 导致 Runtime 误判异常退出并重启后端」「Runtime 未 detached 时随宿主一起被杀」「Runtime warning 在桌面侧不落日志」。未判定项:标题栏的后端更新入口在受监督后端报「无可用更新」时不渲染,需有真实更新时复看 - [x] **T9.4 首版验收清单核对**(M)✅ 2026-09-03 - 依赖:M4~M7、T9.1~T9.3 - 内容:对照架构文档「发布验收标准」(已按 D6 修订)逐条核对;每条附证据(测试名 / 联调记录)。