diff --git a/AGENTS.md b/AGENTS.md index 18b76e7..a5fae8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ Vue/Electron/Python 的改写、CI/CD 发布流程本身。 --- -## 2. 当前状态(截至 2026-08-13) +## 2. 当前状态(截至 2026-09-02) | 里程碑 | 状态 | | --- | --- | @@ -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.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 未开始 | 代码现状: @@ -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/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/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/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/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/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..5631c71 --- /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,338 @@ +# 设计与计划: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` +- 状态:**已实现**(2026-09-01,收口于 `bba97c9`);实现提交见第 9 节 + +--- + +## 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` 受控删除移除该目录。 + +子进程的**工作目录保持 `repo` 不变**(`RunOptions.ProjectDir` 不改):`--project` 已经 +明确指定了项目根,cwd 不参与解析;让 cwd 留在 repo 既避免了「刚跑完就要删掉的目录正被 +某个进程当作 cwd」这类 Windows 删除失败,也把这次改动对既有执行环境的影响压到零。 + +`--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 提交)。 + +--- + +## 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/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`。 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 66fb85a..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" @@ -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 规模标记 @@ -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` 已同步修订(见其「分支信任模型」章节)。 @@ -72,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 | --- @@ -810,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) @@ -904,10 +910,114 @@ 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-*` 成对,单侧上线通常观察不到预期行为,验收以跨仓联合为准。 + +- [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)才能端到端证明。 +- [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 单侧开这一位不产生任何可观察的行为变化。 +- [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 的实测数据。 +- [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`,推导会得到不存在的地址)。 + - 验收:单元覆盖两处前缀改写与镜像 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 条)。 + - 验收: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 在这件事上没有区别。 + - 验收:删除预置缓存后仍能通过 T13.4 的轮换正常装上;预置缓存存在时不被无条件删除;不改变 T13.4 的任何路径与错误映射。 + --- ## 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 +1033,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 +1209,14 @@ 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 | +| 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 | | 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 | 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..7cd6432 --- /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,326 @@ +# 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) +- 修订:2026-09-01 修订 C10 对显式 `--mirror package-index=` 的处理,并把镜像改写前缀由推导改为显式声明(T13.4 实施期间,见 C10) + +本文档是对 [契约补充-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` 改写锁内下载地址前缀参与镜像轮换,**不覆盖包索引**;显式首选源只改变尝试顺序(2026-09-01 修订) | +| 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:主项目依赖的镜像轮换 + +> **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` 目录中的源;显式 + `--mirror package-index=` 只改变尝试顺序(该源排最前),不改变机制本身。每个源执行: + 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 的显式声明与新增源的准入 + +**每个 `KindPackageIndex` 源在目录中显式声明 `simple` 与 `packages` 两个改写前缀,不从 `baseURL` 推导。** +官方源就是推导规则的反例:它的索引在 `https://pypi.org/simple`,artifact 却在 +`https://files.pythonhosted.org/packages/`,「去掉结尾 `simple/`」会得到根本不存在的 +`https://pypi.org/packages/`。三家镜像恰好索引与 artifact 同 host 只是巧合,不能把巧合写成规则。 + +**新增 package-index 源必须先实测同时提供 `/<包名>/` 与 `` +两种布局**,否则不得进入轮换目录——只有 simple 索引可用的源无法参与本机制。 + +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/` | 即被改写的**源**侧前缀;官方源不作为改写目标 | + +`ustc` 的 artifact 实际由清华提供(302 跳转),与 `tsinghua` 源冗余但不冲突,两者都保留,顺序由目录决定。 +目录中当前**没有**腾讯云源;新增它需要按上一段先实测再入目录,不在 T13.4 范围内。 + +### 安全性由 uv 自身保证 + +改写只动**下载位置**,锁内每个 artifact 的 `sha256` 原样保留;uv 在 `--frozen` 安装时对每个 artifact 校验 hash,镜像若返回了不同的字节就会直接拒装。因此「换源」在本机制下不可能改变实际安装的内容,这与轮换规则第 7 条「切换源不能改变目标版本、uv 版本、Python 版本或锁文件」在**语义上**一致——该条对本机制的准确表述是:**不改变锁文件所固定的包集合、版本与哈希**。 + +### 依据 + +- `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」,而是怎么让受限网络下的用户装得上。 + +### 实验依据(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#跨仓库落实点)。 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..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" @@ -12,6 +12,18 @@ - 修订: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 与错误码 +- 修订: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 环境、健康检查、进程监督、镜像、插件职责边界、目录安全、错误与状态契约、测试矩阵、验收标准和分阶段迁移 ## 背景 @@ -283,7 +295,7 @@ Runtime 随后: 1. 停止接受新的控制命令; 2. 请求 Python 后端优雅退出; -3. 等待配置的关闭超时; +3. 等待配置的关闭超时(默认 5 秒,可由 `backend supervise` 选项调整,见[增补 1 C9](./契约补充-v1-增补1.md#c9关闭预算参数化)); 4. 仅在超时后终止自己持有的 Python 进程树; 5. 输出最终状态事件; 6. 清理 PID、锁和临时状态; @@ -332,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` 事件传递,以保持机器协议可解析。 @@ -452,6 +466,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; @@ -529,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 契约。 事件类型固定为: @@ -691,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)`。 + ### 标准输入控制 耗时操作可以接收取消命令: @@ -708,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,但不允许因此遗留正在运行的后端或破坏当前更新事务。 ### 退出码 @@ -727,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 的稳定全集。新增错误码允许在协议版本不变时追加;删除、改名或改变既有语义必须升级协议版本。 @@ -872,6 +924,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 +1325,39 @@ 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` 目录中的源(显式 +`--mirror package-index=` 把该源排在最前,不改变机制),每个源执行: + +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` +安装时逐个校验,镜像返回不同字节即拒装。 + +**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` 失败时: @@ -1317,9 +1399,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 +1422,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 +1489,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 +1504,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 +1523,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 +1553,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 +1612,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 +1779,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 +1897,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/` 只能由日志保留策略删除经过重新验明身份的单个旧日志文件; 创建、轮转、列举和删除都必须固定应用根到日志目录的祖先句柄并拒绝 diff --git a/internal/backend/control.go b/internal/backend/control.go index b9aef8e..9db16de 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,9 +1304,10 @@ 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}, - Identity: identity, + 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, + Infrastructure: s.infrastructure, }, s.streamSink(request, logger, gate)) if err != nil || proc == nil { fault := gate.Fault() @@ -1921,11 +1931,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 { @@ -1964,6 +1970,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 e7d3437..ce2751f 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" @@ -925,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 @@ -948,6 +1050,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 @@ -1313,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/development_test.go b/internal/backend/development_test.go index 39c8c60..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" ) @@ -62,6 +63,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) } @@ -401,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 b67ffb5..fa7fe0a 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" @@ -39,17 +40,20 @@ const ( ) type backendE2EConfig struct { - ListenAddress string `json:"listenAddress,omitempty"` - PIDFile string `json:"pidFile,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"` + EnvironmentFile string `json:"environmentFile,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 { @@ -238,6 +242,8 @@ type backendE2EFixture struct { repo string configPath string rootPID string + workingDir string + environment string grandchildPID string uvExecReady string uvExecRelease string @@ -289,6 +295,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 { @@ -380,6 +414,8 @@ 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 := "" @@ -406,7 +442,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) } @@ -434,6 +474,8 @@ func newBackendE2EFixture(t *testing.T, configValue backendE2EConfig) *backendE2 repo: repo, configPath: backendConfigPath, rootPID: rootPIDPath, + workingDir: configValue.WorkingDirFile, + environment: configValue.EnvironmentFile, grandchildPID: configValue.GrandchildPIDFile, uvExecReady: uvExecReadyPath, uvExecRelease: uvExecReleasePath, @@ -550,6 +592,14 @@ func TestBackendE2E_LifecycleSpawnReadyShutdown(t *testing.T) { t.Fatalf("development tree changed: got %#v, want %#v", got, repositorySnapshot) } assertE2EDevelopmentUVEnvironment(t, fixture) + assertE2EDevelopmentWorkingDir(t, fixture) + assertE2EBackendInfrastructureEnvironment(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 ") @@ -578,6 +628,83 @@ 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) + } +} + +// 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) @@ -626,6 +753,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}}, @@ -888,7 +1070,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 2893753..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 根进程退出。 @@ -201,14 +271,18 @@ 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, }, - 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 { @@ -699,10 +773,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))) } @@ -767,6 +838,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/backend/supervisor_test.go b/internal/backend/supervisor_test.go index 87c17b8..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" @@ -110,9 +111,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) } @@ -602,6 +606,7 @@ type backendFixture struct { pid *fakePID depsHTTP HTTPCloser shutdownTimeout time.Duration + mirrorPolicy mirror.Policy } func newBackendFixture(t *testing.T) *backendFixture { @@ -631,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) @@ -1229,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 d1ac11f..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" @@ -20,10 +21,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) @@ -64,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 93d5d70..bfdbad8 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,11 +82,16 @@ 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, deps.io.Err, deps.options.clock, + deps.global.mirrorPolicy, ) if err != nil { return sessionSuccess{}, err @@ -107,6 +120,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 +140,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, @@ -160,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/backend_test.go b/internal/cli/backend_test.go index f11ca6a..7d52c4f 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 }), @@ -63,6 +64,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, 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.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, 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"] != "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 @@ -72,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 @@ -90,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 { @@ -107,7 +253,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) @@ -144,7 +290,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 { @@ -188,7 +334,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) @@ -220,7 +366,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/bootstrap.go b/internal/cli/bootstrap.go index 97b2d3f..ebe0124 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) @@ -324,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) @@ -357,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/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..b21053f 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" ) @@ -18,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() @@ -70,7 +127,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/contract_m5_test.go b/internal/cli/contract_m5_test.go index dc0a135..418e3ec 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,57 @@ func m5ContractInitialState(command string) state.EnvironmentState { } } +// assertM5ContractMirrorDetails 锁定 C10 第 7 条:凡是执行了 uv sync 的命令, +// 成功 result.details 都必须报告包索引源与尝试次数,字段名与类型不得漂移。 +// bootstrap 与 repair 同样跑 SyncDependencies,调用方没有理由在这两条路径上 +// 看不到本次实际使用的镜像源。 +func assertM5ContractMirrorDetails( + t *testing.T, + terminal contracttest.Terminal, + command string, + output string, +) { + t.Helper() + if terminal != contracttest.TerminalSuccess || !m5CommandReportsMirrorSource(command) { + 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") +} + +// 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/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/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/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() diff --git a/internal/cli/m5_test.go b/internal/cli/m5_test.go index e42828a..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" @@ -164,21 +165,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 +198,7 @@ func TestM5CommandsRejectPackageIndexOverrideBeforeSideEffects(t *testing.T) { []string{ "--app-root", root, "--output", "ndjson", - "--mirror", "package-index=pypi", + "--mirror", "package-index=aliyun", }, test.args..., ) @@ -220,38 +219,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) } }) } @@ -1097,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/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/repair.go b/internal/cli/repair.go index b706e34..a131c43 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) @@ -244,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 @@ -318,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 } 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 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 } 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 } 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..ee57280 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, @@ -132,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 415ac92..ddde92a 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) { @@ -252,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) @@ -329,6 +367,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 +510,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() 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 } diff --git a/internal/uv/dependencies.go b/internal/uv/dependencies.go index 5f652c2..3c8bfc4 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 { @@ -231,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_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..bb305c1 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) @@ -200,66 +182,83 @@ 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) }) } } -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) +// 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) +} - _, err = service.Sync(t.Context(), dependencyTestRequest(layout)) +func TestDependencies_OnlineSyncFailureMapsToDependencySyncFailed(t *testing.T) { + fixture := newMirrorSyncFixture(t) + failure := fakeRunnerResponse{result: UVResult{ExitCode: 1}, err: errors.New("download failed")} + fixture.runner.responses = []fakeRunnerResponse{{}, failure, failure, failure, failure} + + _, 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 +342,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 +351,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 } 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") +} diff --git a/internal/uv/managed.go b/internal/uv/managed.go index f07b6ce..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, @@ -80,7 +109,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, }) @@ -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 e287564..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" @@ -75,6 +76,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, @@ -317,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/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"), diff --git a/internal/uv/runner.go b/internal/uv/runner.go index 68a01b5..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" @@ -55,7 +64,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 +164,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 +411,7 @@ func normalizeVersionOutput(output string) string { } type resolvedRunOptions struct { + WorkingDir string ProjectDir string PythonInstallDir string ProjectEnvDir string @@ -433,11 +447,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, @@ -528,6 +549,10 @@ func canonicalSupervisionEnvironmentKey(key string) (string, bool) { autoMASVersion, autoMASCommit, autoMASSupervised, + autoMASUVCacheDir, + autoMASUVPythonInstallDir, + autoMASMirrorPackageIndex, + autoMASMirrorPython, } { if strings.EqualFold(key, managed) { return managed, true 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..87bcb26 100644 --- a/testdata/fakebackend/main.go +++ b/testdata/fakebackend/main.go @@ -30,23 +30,34 @@ const ( ) type fakeBackendConfig struct { - ListenAddress string `json:"listenAddress"` - ListenDelayMS int `json:"listenDelayMs"` - ReadyFile string `json:"readyFile"` - PIDFile string `json:"pidFile"` - 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"` + 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"` + // EnvironmentFile 让假后端把自己进程里读到的受监督环境变量落盘,供 T13.5 + // 端到端断言增补 1 C11 的四个变量确实穿过 uv 到达了真实后端进程;父进程侧 + // 的 StartSpec 断言只能证明 Runtime 传了什么,证明不了后端收到了什么。 + EnvironmentFile string `json:"environmentFile"` + 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 { @@ -196,6 +207,23 @@ 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 + } + } + 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 { @@ -267,6 +295,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() @@ -421,7 +452,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() @@ -440,7 +472,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 @@ -466,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-*") 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