docs/plan/2026-10-31-container-runtime-build-drift-auto-restart-technical-design.md
Rama main · réplica de lectura del repositorio original.
# 容器运行时版本漂移自动收敛(闭包哈希 + 懒重启)技术方案
> 版本:v1.1(2026-10-31 评审回填;2026-11-01 实施记录见 §11)
> 状态:已评审(2026-10-31),已开发完成(2026-11-01,0.0.394,见 §11 实施记录),待发布
> 关联:[2026-10-14-user-runtime-container-isolation-design.md](2026-10-14-user-runtime-container-isolation-design.md)(挂载清单即安全边界、指纹机制先例)、[2026-10-31-container-hosts-injection.md](2026-10-31-container-hosts-injection.md)(`hostsFingerprint` 先例)、CHANGELOG 0.0.393(修复「容器内读会话技能被判符号链接逃逸」时暴露:容器内代码不随后端发布生效,必须人工重启容器)
> 一句话:给容器内运行时(`runtime-agent.js` 的 import 闭包)算一个**闭包指纹**,agent 握手时上报;宿主机在用户下次调工具时比对,漂移且容器空闲就**自动重启容器进程**(不重建、不打断在跑任务),把「发版后容器跑旧代码」从人工操作收敛成懒自愈。
## 0. 决策摘要
| # | 决策点 | 结论 |
|---|--------|------|
| 1 | 指纹口径 | **容器内运行时闭包**(`dist/harness/container/runtime-agent.js` 的静态相对 import 图)内容哈希;宿主机侧改动(API / agent loop / prompt / 通道等)不触发 |
| 2 | 指纹生成 | 构建产物生成:`npm run build` 后置步骤写 `dist/harness/container/runtime-build.json`;同源重建幂等(哈希不含时间戳) |
| 3 | 上报通道 | agent 启动时读取 manifest,`hello` 帧新增**可选** `build` 字段;协议 `v:1` 不变、字段级向后兼容 |
| 4 | 宿主机判定 | registry 启动时读同一份 manifest 并缓存(代表本实例加载的代码),`ensureRunning` 时与 agent 上报值比对 |
| 5 | 动作分级 | 代码漂移 → **重启容器进程**(stop + start,保留容器/挂载/env);挂载 / hosts 漂移 → 重建(沿用现状) |
| 6 | 触发策略 | **懒触发**(`ensureRunning`)+ **仅空闲**:busy 容器只记录,不打断在跑任务 |
| 7 | 防环 | 每「容器 × host build」只自动尝试一次;蓝绿 draining 实例跳过自动升级 |
| 8 | 兼容与退化 | 旧 agent 不报 build 视为 unknown → one-shot 自愈;manifest 缺失(LOCAL/tsx 开发)→ 检查关闭,不误伤 |
| 9 | 配置项 | 新增 kill switch:`system_setting.runtime.container.autoUpgrade`(V178 迁移,默认 true,后台「运行环境 → 容器管理」可切换);关闭 = 跳过全部自动升级动作。人工入口(后台「重建容器」/ `POST /v1/users/:id/container/recycle`)保留不变 |
| 10 | 管理后台 | **本期含前端**:「容器管理」列表新增「代码版本」(agent build)/「后台版本」(host build)列与漂移、待升级徽标;设置面板新增自动升级开关 |
| 11 | 用户通知 | 不通知(静默处理,靠日志与后台观察) |
| 12 | 非目标 | 不做热加载、不做容器镜像内容漂移检测(见 §9 P2)、不做「发版即全量重启」 |
## 1. 背景与问题
### 1.1 现状(代码事实)
- docker 模式下工具执行在每用户容器的常驻 Node 进程里:容器启动命令 `node {distDir}/harness/container/runtime-agent.js --socket ...`(`container-registry.ts:656`),`dist` 由宿主机目录 **ro 挂载**(`container-registry.ts:240`),容器内路径与宿主机一致。
- Node 无热加载:进程启动时加载模块,之后 dist 文件再变也不生效。容器内运行时闭包约 50 个模块:`container-tool-executor`、文件工具(read/write/edit/glob/grep)、`path-sandbox`、`shell-session-manager`、`pty-hub`、`container-mcp-bridge`、图片处理、git 写入服务等。
- 容器自动重建**只挂钩载指纹漂移**:`mountFingerprint`(挂载清单,`container-registry.ts:54`)、`hostsFingerprint`(`--add-host` 注入,`:61`),判定点在 `ensureRunningLocked`(`:307`);dist 内容与容器 env 都不在指纹里。
- idle 回收只 `docker stop` 不 rm(`stopContainer` `:798`),下次 `dockerStart`(`:762`)复用同一容器(挂载/env 保持创建时定型),进程重启会加载新 dist —— 但生效时机取决于用户何时再来,不可控。
- 现状操作是人工:发版后 `docker restart mao-user-*` 或后台「重建容器」;漏了就长期静默跑旧代码。
### 1.2 问题定义
「后端已发布 ≠ 容器内已生效」造成三类静默错位:
1. **修复不生效**:如 0.0.393 的符号链接修复,部署后存量容器仍报 `Path escape attempt (symlink)`,需人工重启才收敛;
2. **行为/协议漂移**:新宿主机 + 旧 agent 组合下的工具行为不一致;若未来引入不兼容协议变更,风险更高;
3. **人工步骤易漏**:没有系统性校验,靠发版人记忆。
### 1.3 目标
1. 发版后容器内代码版本**自动收敛**,无需人工操作;
2. 收敛动作尽量轻:代码漂移只重启进程,不重建容器(保挂载/env/容器名);
3. **不打断在跑任务**:busy 容器延后处理;
4. 判定口径锚定「容器内真正运行的代码」,宿主机侧改动不触发;
5. 失败可观测、可退化:缺 manifest / 旧 agent / 开发环境不误伤;防重复重启风暴。
### 1.4 非目标
- 不做热加载 / 进程内模块重载(Node 生态没有干净方案,重启进程是最小代价);
- 不做容器**镜像**内容漂移检测(镜像层变化,见 §9 P2);
- 不做「发版即全量主动重启」(只懒触发;需要立即收敛的场合走人工批量重建);
- 不替换后台「重建容器」按钮(保留强制入口,force 语义不变);
- 不改隔离边界、挂载清单与容器创建参数。
## 2. 方案总览
三个部件,一条链路:
```
构建期 容器启动 用户下次调工具(懒触发)
────── ──────── ──────────────────────
tsc 产出 dist agent 读 runtime-build.json ensureRunning 比对:
↓ 后置脚本 (闭包哈希 buildId) entry.runtimeBuild(hello 上报)
生成 runtime-build.json ↓ vs hostBuild(本实例 manifest)
(闭包遍历 + 哈希) hello 帧携带 build ──→ 宿主机记录 ├─ 一致 → 复用
├─ 漂移 + 空闲 → 自动重启(stop+start)
└─ 漂移 + 忙碌 → pending,下次再判
```
- 指纹只覆盖「容器内运行时闭包」:宿主机侧改动(NestJS 接口、agent loop、prompt、飞书通道、技能同步等)不触发;
- 收敛动作分两级:**代码漂移 → 重启进程**(轻);**挂载 / hosts 漂移 → 重建容器**(现状不变);
- 重启后新进程加载新 dist,`hello` 上报新 build,自然对齐,形成闭环。
## 3. 详细设计
### 3.1 构建期:闭包指纹 manifest
- 新增脚本 `backend-ts/scripts/emit-container-runtime-build.mjs`,接入 `package.json`:
`"build": "tsc -p tsconfig.build.json && node scripts/emit-container-runtime-build.mjs"`;
- 算法:
1. 从 `dist/harness/container/runtime-agent.js` 出发,沿静态相对 import 遍历(`from '...'` / `import('...')`),得到闭包文件集(仅 `.js`,排除 `.map` 与 manifest 自身);
2. 按相对路径排序,逐个取 `sha256(文件内容)`;
3. `buildId = sha256(「rel:hash」逐行 join('\n')).slice(0, 16)`;
- 输出 `dist/harness/container/runtime-build.json`:
```json
{ "schema": 1, "buildId": "3f9a1c0d2b7e4410", "fileCount": 50, "generatedAt": "2026-10-31T14:00:00Z" }
```
- **幂等要求**:`generatedAt` 不参与哈希 —— 无代码变化时重新构建/重新部署产生相同 `buildId`,不会触发无意义重启;`schema` 值纳入哈希输入 —— 遍历/哈希算法本身变更时 `buildId` 自然改变,不会把旧容器误判为同源;
- 失败策略:入口文件不存在 → 脚本非 0 退出(**构建失败**)。宁可构建失败,不可产出缺失 manifest 的部署;本地 `tsx`(LOCAL/dev,无 dist)不经过该步骤,宿主机侧检查自动关闭;
- 与 `container-import-constraints.spec.ts` 的关系:同一条 import 遍历思路,实现抽到 `runtime-build.ts` 共享,测试可复用。
### 3.2 容器侧:agent 读取与上报
- 新增 `src/harness/container/runtime-build.ts`:`loadRuntimeBuildId(moduleUrl)` 读取「与模块同目录」的 `runtime-build.json`;文件缺失 / 解析失败 → 返回 `null` 并 warn 一次(不阻塞启动);
- `runtime-agent.ts` 启动时读取,`hello` 帧携带(`runtime-agent.ts:154`):
```
{ v: 1, type: 'hello', userId, token, build?: '3f9a1c0d2b7e4410' }
```
- 协议层(`runtime-channel-protocol.ts:67`)只加**可选**字段;`validateFrame` 是松校验(`:136-148`),旧宿主机对多余字段无感 → 双向兼容;
- 为什么不放 `runtime_status`:`hello` 每次连接必发(含宿主机重启后的重连),一处即可;`runtime_status` 保持纯「容器内活动状态」语义(idle 判定唯一数据源)。
### 3.3 宿主机侧:版本认知
- `RuntimeChannelServerOptions` 增 `onAgentHello?(userId, info: { build: string | null })`(`runtime-channel-server.ts:40-50` 区域);hello 校验 token 通过后、回 `hello_result` 前后触发(不含 build 字段时回调 `build: null`)。
- `container-registry.ts`:
- `ContainerEntry` 增 `runtimeBuild?: string`(agent 上报值,权威来源)与 `upgradeTargetBuild?: string`(防环,见 §3.5);
- 新增 `reportAgentHello(userId, build)`,与 `reportRuntimeStatus`(`:503`)同构;
- 构造时读 `{config.container.distDir}/harness/container/runtime-build.json` → `hostRuntimeBuild`;读失败 → `null` + warn(此后跳过一切 build 检查);
- 以上均为内存条目字段,无 DB 迁移。
- 接线在 `app-container-runtime.ts`(`:77-84` 区):
```ts
onAgentHello: (userId, info) => containerChannelHolder.registry?.reportAgentHello(userId, info.build),
```
### 3.4 漂移判定与动作
- 判定:`hostRuntimeBuild != null && entry.runtimeBuild !== hostRuntimeBuild`(`undefined` = unknown,视为漂移,自愈一次)。
- 触发点:`ensureRunningLocked`(`:307`)running 且 `!connectionDead` 分支 —— 先判总开关(关闭则直接跳过,§3.7),再跑现有 `mountPlanDrifted` / `hostsInjectionDrifted`(重建优先,正确性 > 新鲜度),最后判 build 漂移:
| 情形 | 动作 |
|------|------|
| 空闲(`!isBusy`,`:92`) | `restartEntry(entry)` → 重启进程 |
| 忙碌 | 不改动容器,`harnessLog` 记录 pending;下次调用再判 |
- **stopped 分支不做额外动作**:`dockerStart` 本身就是进程重启,天然加载新 dist;start 后 `hello` 回填 `runtimeBuild` 自会对齐(避免「先 start 再立刻 restart」的一次无用启动);
- `connectionDead` / 状态异常分支已有「停止 + 重建」路径,覆盖此场景,无需改动;
- 重启实现 `restartEntry(entry)`:`stopContainer(entry, { force: false })`(优雅:先发 `shutdown` 帧,再 `docker stop -t 10`)+ `dockerStart(entry)`(`waitSocketReady` + 失败走 `cleanupFailedStart`)。**不用裸 `docker restart`**:要复用条目状态机、generation 校验与失败清理语义,避免留下「自称 running」的毒化条目;
- 动作语义对照(术语固定):
- **restart**:同容器、同挂载/env,只重启进程(本方案用于代码漂移);
- **rebuild**:停 → rm → 重新 `docker run`(现状用于挂载/hosts 漂移;env/镜像类漂移见 §9 P1);
- **recreate**:条目不存在时的首次创建(现有语义)。
### 3.5 防抖与防环(关键)
1. **one-shot per (容器, host build)**:`entry.upgradeTargetBuild` 记录「已尝试过的目标 build」。仅当 `upgradeTargetBuild !== hostRuntimeBuild` 时才尝试重启;尝试后置位。场景:蓝绿期间旧实例缓存旧 build,容器已被新实例升到新 build —— 不设上限会「每次调用都重启」形成风暴;one-shot 让每个容器对每个 host build 最多动作一次。
2. **draining 实例跳过自动升级**:复用 `isDrainingInstance(runtimeDir, selfPort)`(`deploy-lock.ts:93`),由承接流量的实例执行动作,旧实例只读不写(日志记录 `skipped-draining`)。
3. **host build 读不到** → 检查整体关闭(warn 一次),LOCAL/tsx/异常部署零副作用。
### 3.6 可观测性
- 日志(`harnessLog`):
- `[container] runtime build drift: user=7 agent=abc host=def action=restart`(已动作)
- `... action=deferred-busy`(忙碌延后)
- `... action=skipped-draining` / `action=skipped-no-host-build` / `action=skipped-disabled`(守卫 / 总开关跳过)
- **管理后台(本期实施)**:`ContainerEntrySummary`(容器列表数据源,`:75`)增 `runtimeBuild` / `hostRuntimeBuild` / `pendingUpgrade` 字段;「容器管理」面板(`admin/src/views/settings/components/ContainerRebuildPanel.vue`)新增「代码版本」「后台版本」两列,以及「待升级」徽标(running 且两者不一致时显示)。
- 不含任何敏感信息(buildId 是内容哈希)。
### 3.7 配置项:自动升级总开关(kill switch)
- 新增设置项 `system_setting.runtime.container.autoUpgrade`(**V178** 迁移插入,category「运行环境」,`true` / `false`,默认 `true`,editable=1),沿用 V176 的插入风格;
- 解析:新增 `container-auto-upgrade.ts`(key 常量 + 解析器),缓存口径对齐 `RuntimeModeResolver`(5s TTL;保存后由 `app-routes.ts` 的 `afterSave` 调 `invalidate()` 即时生效;缺行 / 值非法 / 读库失败一律回落 `true`,保持既有部署行为);
- 生效点:registry 的 build 漂移分支**最前**判定;关闭时不动容器,仅日志 `action=skipped-disabled`;
- 语义边界:只关「自动」,不关人工「重建容器」(force 语义不变);关闭期间积攒的漂移,在重新打开后由下一次 `ensureRunning` 照常收敛;
- 保存侧校验:`settings.service#validateValue` 对该 key 限定 `'true' | 'false'`(其余拒绝),避免写入非法值;
- 后台展示:「运行环境」的容器管理面板(`ContainerRebuildPanel.vue`)头部加 `el-switch`(与「包括正在运行任务的容器」同区)。
## 4. 行为矩阵
| # | entry 状态 | 漂移类型 | 空闲? | 动作 | 与现状对比 |
|---|-----------|---------|--------|------|-----------|
| 1 | running | 无 | - | 复用(现有) | 不变 |
| 2 | running | build | 是 | 自动 restart(一次/目标) | **新增** |
| 3 | running | build | 否 | pending,下次再判 | **新增** |
| 4 | running | mount / hosts | 是/否 | 重建(现有,不看 busy) | 不变(正确性优先,见 §5-6) |
| 5 | stopped | build | - | 仅 `dockerStart`(自然加载新 dist) | 不变 |
| 6 | stopped | mount / hosts | - | 重建(现有) | 不变 |
| 7 | stopped | 新通道工作区 | - | 重建(现有) | 不变 |
| 8 | connectionDead | 任意 | - | 重建(现有) | 不变 |
| 9 | running | build,但 draining 实例 | - | skip + 日志 | **新增** |
| 10 | running | build,hostRuntimeBuild=null | - | skip(检查关闭) | **新增** |
| 11 | running | build(任意) | - | 总开关关闭 → skip + 日志(`skipped-disabled`) | **新增** |
## 5. 故障与边界
1. **旧 agent 不报 build(升级过渡)**:`runtimeBuild=undefined` 视为漂移 → one-shot 自愈一次;重启后 agent 即来自新 dist,必然上报(同一份 dist,天然对齐)。
2. **manifest 缺失**:仅出现在 LOCAL/tsx 开发与「绕过 `npm run build` 的手工构建」;宿主检查关闭并 warn 一次。生产构建链路保证生成(§3.1 失败即构建失败)。
3. **重启失败**:`dockerStart` 失败走现有 `cleanupFailedStart`(摘条目 + `rm -f`),下次调用走完整 `docker run` 重建 —— 与现有失败语义同构,不引入新状态。
4. **蓝绿双实例**:draining 守卫(§3.5-2)保证只由承接方动作;one-shot 守卫兜底极端交错(新实例重启后、旧实例仍在排空窗口内比对)。
5. **忙碌持久**:busy 判定含活跃 shell 会话(idle 30 分钟、lifetime 2 小时上限),长会话会推迟升级 —— 这是有意保守(不打断用户终端);如需立即收敛,后台「重建容器」(force)人工兜底。
6. **与 mount 漂移的调度差异**:mount/hosts 漂移代表**能力缺失**(新挂载不生效即功能不可用),维持现状「不看 busy 直接重建」;build 漂移只是**新鲜度**,可以等 —— 差异是有意的,不在本方案里改现状。
7. **未来不兼容协议变更**:本期只加字段,无此问题;将来若必须 bump `PROTOCOL_VERSION`,旧容器会被 `validateFrame` 拒绝(现有行为),这类变更无法靠懒触发解决,需在发布流程中先重建在线容器(或做 §9 P3 的批量编排),本方案不覆盖。
8. **回滚**:回滚代码后,容器(新 agent)继续上报 build,旧宿主松校验忽略该字段,无副作用;已发生的自动重启也无需处理。
9. **总开关关闭期间**:容器继续跑旧代码(与现状人工模式一致);重新打开后由懒触发继续收敛,无需其他操作。
## 6. 测试计划
**单测(新增/扩充)**
| 文件 | 用例 |
|------|------|
| `runtime-build.spec.ts`(新增) | 闭包遍历正确(临时 dist 构造 import 图);排序无关确定性(同输入两次同 buildId);`generatedAt` 不影响 buildId;入口缺失 → 抛错非 0;manifest 自身不计入闭包 |
| `runtime-agent.spec.ts` | 有 manifest → `hello` 带 build;无 manifest → 不带且 warn 一次;真实 server 侧 `onAgentHello` 收到正确值(含「不含 build 字段 → `build:null`」与「token 失败不回调」两种) |
| `runtime-channel-protocol.spec.ts` | hello 帧带可选 `build` 仍通过校验(字段级兼容);缺该字段不报错 |
| `container-registry.spec.ts` | ① 漂移 + 空闲 → docker 操作序列为 `stop` + `start`(无 `rm`/`run`);② 漂移 + 忙 → 零 docker 操作,解除忙后下一次执行;③ 一致 → 零操作;④ `hostRuntimeBuild=null` → 零操作;⑤ draining 守卫 → 零操作;⑥ one-shot:尝试后 agent 仍报不同 build → 不再重启;⑦ stopped + 漂移 → 仅 `start`;⑧ mount 漂移优先 → 走重建序列;⑨ 总开关关闭 → 零操作,重新开启 → 收敛一次 |
| `container-auto-upgrade.spec.ts`(新增) | 缺行 / 非法值 / 读库失败 → 默认 `true`;`'false'` → 关闭;TTL 缓存与 `invalidate()` 即时生效 |
| `settings.service.spec.ts`(扩充) | `runtime.container.autoUpgrade` 值域校验:仅接受 `true` / `false`,非法拒绝 |
| `tests/admin-settings-cards.spec.ts`(扩充,参考既有容器管理面板用例) | 容器管理面板:自动升级开关渲染与保存、版本列 / 待升级徽标渲染 |
**手工验收**
1. 部署本功能(dist 含 manifest)→ 现状容器 agent 报 unknown → 空闲时自动 restart 一次 → 日志出现 `runtime build drift ... action=restart`;
2. 再改一行容器内代码重新发布 → 重复上述观察,确认容器在空闲后自动收敛,无需人工;
3. 忙碌容器(起一个 keep_session shell)→ 确认 `deferred-busy`,等会话结束后收敛;
4. 重启后 `read_file` 等工具行为与新代码一致;
5. 后台「容器管理」能看到「代码版本 / 后台版本」列与「待升级」徽标,开关保存后即时生效;
6. 关掉总开关再发布一次容器内代码改动 → 容器不自动重启(日志 `skipped-disabled`);重新打开 → 下次调用收敛。
**回归**:现有 container 域全部单测保持绿;不引入真实 docker 依赖(fake `DockerCli` 注入)。
## 7. 上线与运维
- **发布顺序**:常规 `deploy-mao.sh`(后端蓝绿)即可,无镜像重建;
- **总开关**:`runtime.container.autoUpgrade=false` 可随时停用自动重启(后台「运行环境 → 容器管理」,保存即生效);恢复 `true` 后由懒触发继续收敛;
- **上线首日预期**:所有存量容器 agent 报 unknown → 每个容器在「空闲 + 下次使用」时 lazy 重启一次(每容器一次性,不是发版风暴);此后收敛;
- **观察点**:日志关键字 `runtime build drift`;管理后台「容器管理」的代码版本 / 待升级列;
- **回滚**:直接回滚代码,无数据/状态残留(字段级兼容,见 §5-8);
- **文档同步**:CHANGELOG 记一条;`docs/agents/container-docker-mode.md` 补「发版后容器何时收敛」排查小节。
## 8. 影响文件与工作量
| 文件 | 改动 |
|------|------|
| `backend-ts/scripts/emit-container-runtime-build.mjs` | 新增:闭包遍历 + 哈希 + 写 manifest |
| `backend-ts/package.json` | `build` 串联后置步骤 |
| `backend-ts/db/migration/V178__container_runtime_auto_upgrade.sql` | 新增:插入 `runtime.container.autoUpgrade` 设置项(默认 true) |
| `backend-ts/src/harness/container/runtime-build.ts` | 新增:manifest 读取 + 闭包遍历(宿主/容器共享) |
| `backend-ts/src/common/container-auto-upgrade.ts` | 新增:总开关 key + 值解析 / 校验(settings 与 harness 共用) |
| `backend-ts/src/harness/container/container-auto-upgrade.ts` | 新增:开关解析器(TTL 缓存 + invalidate) |
| `backend-ts/src/harness/container/runtime-agent.ts` | 读 manifest;hello 携带 build |
| `backend-ts/src/harness/container/runtime-channel-protocol.ts` | hello 帧可选 `build` 字段 |
| `backend-ts/src/harness/container/runtime-channel-server.ts` | `onAgentHello` 钩子 |
| `backend-ts/src/harness/container/container-registry.ts` | entry 字段、`reportAgentHello`、漂移判定、`restartEntry`、守卫与开关判定 |
| `backend-ts/src/app-container-runtime.ts` | 接线 `onAgentHello` + draining 守卫 + 开关解析器 |
| `backend-ts/src/settings/settings.service.ts` | 扩充:开关值域校验(true/false) |
| `backend-ts/src/app-routes.ts` | 扩充:`afterSave` 失效开关缓存 |
| `admin/src/views/settings/components/ContainerRebuildPanel.vue` | 扩充:自动升级开关 + 代码版本 / 后台版本列 + 待升级徽标 |
| `admin/src/views/settings/SystemSettingsView.vue` | 如需注册新面板 / 分类(最小改动) |
| `runtime-build.spec.ts`(新增)、`container-auto-upgrade.spec.ts`(新增)、`runtime-agent.spec.ts` / `runtime-channel-protocol.spec.ts` / `container-registry.spec.ts` / `settings.service.spec.ts` / `tests/admin-settings-cards.spec.ts`(扩充) | §6 用例 |
| `CHANGELOG.md` + `docs/agents/container-docker-mode.md` | 记录与排查文档 |
预估:后端 ≈ 1 人日(含测试与迁移);前端 ≈ 0.5 人日(开关 + 版本列 / 徽标 + 门禁用例)。
## 9. 可选扩展(不在本期)
- **P1 创建参数指纹(env / entry 命令 / 镜像 id)→ rebuild**:与 0.0.393 的 `MAO_SKILLS_DIR` 同类问题(env 定型于 `docker run`,`docker start` 不更新)。做法:创建时记录 `sha256(排序后的 env 键值 + 启动命令 + docker image inspect 的 Id)`,漂移走 rebuild;复用同一「懒触发 + skip-busy」机制。
- **P2 镜像内容漂移**:镜像 tag 可被重建覆盖而不改名;记录 image digest 可检测,但 rebuild 容器并不重建镜像本身,价值有限,暂缓。
- **P3 批量收敛编排接口**:现「重建全部」由前端逐用户调用单用户接口;如需发布脚本集成(「必须立即收敛」场景),可加批量接口(复用 skip-busy 语义)。
- **P4 busy 细化**:shell 会话空闲超过 N 分钟时视为可重启(需要区分「用户挂着终端」与「僵尸会话」,风险偏高,暂缓)。
## 10. 评审结论(2026-10-31)
| # | 问题 | 结论 | 落地位置 |
|---|------|------|---------|
| 1 | 是否加 kill switch | **加**:`system_setting.runtime.container.autoUpgrade`,V178 迁移插入、默认 `true`、后台「运行环境 → 容器管理」可切换 | §3.7、§4-11、§7 |
| 2 | 存量容器 unknown(旧 agent 不报 build) | **视为漂移**,one-shot 自愈一次(每容器最多重启一次) | §3.4、§5-1 |
| 3 | 管理后台展示是否本期 | **本期含前端**:容器管理面板加自动升级开关、代码/后台版本列与「待升级」徽标 | §3.6、§8 |
| 4 | 自动重启是否通知用户 | **不通知**:静默处理,靠日志与后台观察 | §5、§7 |
## 11. 实施记录(2026-11-01,0.0.394)
按本文实现,落地细节与偏差记录:
1. **开关常量分层**:`runtime.container.autoUpgrade` 的 key / 值解析放在 `src/common/container-auto-upgrade.ts`(settings 保存校验与 harness 解析共用),解析器 `ContainerAutoUpgradeResolver` 在 `src/harness/container/container-auto-upgrade.ts` —— 与 V176 container-hosts 的 common/harness 分层一致,避免 settings 域反向依赖 harness。
2. **注释内 import 防护**:闭包遍历前先 strip 注释(tsc 输出保留注释,文档注释里的 import 样式文本会误报 unresolved import)。
3. **接住首个 hello**:新容器创建过程中条目尚未登记时到达的 hello 写入 registry 的 `lastAgentBuild`(按用户),条目登记后回填 `entry.runtimeBuild`;重建时重置 one-shot 标记 —— 否则新容器首调会被误判 unknown 漂移、白重启一次。
4. **构建链**:`npm run build` = `tsc && node scripts/emit-container-runtime-build.mjs`;`start:dev`(tsx)不产出 manifest → 检查自动关闭(判定为可接受降级)。连续两次构建 buildId 相同(幂等已验证)。
5. **测试**:新增 `runtime-build.spec.ts` / `container-auto-upgrade.spec.ts`,扩充 registry / agent / protocol / settings spec(§6 全量落地);后端全量 5208 例通过、`npm run build` 与 admin 构建通过。`tests/admin-settings-cards.spec.ts` 新增容器管理面板结构断言(需在线管理后台执行,本次未本地运行)。