# 配置热重载功能审核报告

> 状态：审查完成；主要意见已于 2026-07-21 落地。本文前六节保留首次审查快照，行号、测试数和“当前实现”描述可能已过时；以下处置表与代码为最新结论。
>
> 本文是对 `docs/CONFIG_HOT_RELOAD_DESIGN.md` 描述的配置热重载功能及其当前未提交实现的综合审核。审核覆盖架构合理性、实现一致性、关键不变量和改进建议；具体行为以代码和测试为最终依据。相关实现位于 `src/gateway/reload.rs`、`src/gateway/mod.rs::run`、`src/config/mod.rs::load_for_reload`、`src/session/session.rs::wait_until_idle`、`src/tools/reload_config.rs` 与 `src/gateway/http.rs::reload_config`。

## 0. Review 意见处置与实现结果

| 意见 | 处置 | 结论与实现 |
|------|------|------------|
| A1 / E1 MCP 准备期副作用 | 接收 | `connect_all()` 移到运行代激活阶段，候选构造不再启动双份 MCP 或提前覆盖全局 status。 |
| A2 / E2 generation 与状态查询 | 接收 | 每次请求分配 generation；新增 `GET /api/config/reload/status`，状态为 `preparing/draining/activating/active/failed`。 |
| A3 激活失败回滚 | 部分接收 | 接收“需要明确失败状态”，但驳回先启动新 Channel 再停旧 Channel。飞书长连接双开会重复消费事件，风险高于短暂切换空窗；完整 prepare/activate 或可恢复旧代留作后续。 |
| A4 / L4 Agent 工具风险 | 部分接收 | 保留用户要求的 `reload_config`，但只允许根交互 Agent 使用；子 Agent、Cron、managed scheduled Agent 均剔除该工具，并保持 exclusive。驳回直接删除工具。 |
| A5 / E5 后台任务排空 | 接收 | 引入运行代 admission/activity guard；Scheduler 已执行 job 与后台子 Agent 持有 guard，新任务在 drain 后不再进入。 |
| I1 / E4 Slash 回复可能丢失 | 接收 | `/reload` 所在 inbound 在 lane 入队前持有 guard；command output 改为显式等待 outbound delivery acknowledgement，删除固定 500ms sleep。 |
| I2 / F2 DB 路径误判 | 接收 | 比较归一化后的有效路径，`None` 与 `./picobot.db` 可判为同一数据库。 |
| I3 零散预校验 | 部分接收 | 保留 default agent 与 Feishu 的快速错误提示；其余组件统一由候选 `from_config()` 构造验证，不继续扩张 ad-hoc 字段校验。长期采用显式 prepare contract。 |
| I4 HTTP 错误码 | 接收 | pending 返回 409，退出/准备故障返回 503，配置与不可变字段错误返回 400。错误改为 `ReloadError` 类型。 |
| I5 候选双 Storage | 驳回为正确性缺陷 | 同库多连接是 sqlx/SQLite 的正常模式，候选无消息入口；路径身份仍被强制保持一致。该点保留为测试与锁竞争观察项，而非阻止上线。 |
| F1 集成测试 | 部分接收 | 新增真实 Gateway 子进程测试，覆盖成功切换到 generation 2、状态查询、无效候选不影响旧代健康。可控假 Provider、长 Turn 和故障注入仍待补充。 |
| L1 prepare/activate 分离 | 方向接收 | 本轮已把 MCP activation 与候选构造分离；完整组件级接口留作后续架构演进。 |
| L2 动态 Router | 暂缓 | 当前保留 listener 并为 Axum graceful shutdown 增加 10 秒硬上限；不为本功能引入动态 service 复杂度。 |
| L3 客户端完成通知 | 暂缓 | 后端 generation/status 已具备；TUI/WebUI 展示可在后续独立实现。 |

首次 Review 未指出、但本轮一并修复的两个关键问题：候选构造原先直接在 `select!` 分支内 await，会暂停轮询旧 Axum serve 与进程信号；现在 serve 独立受监督运行，准备和排空阶段都继续响应服务退出。其次，原实现排空前没有关闭入口，持续新消息可能让排空永不稳定；现在 admission 先关闭再 drain。

## 1. 审查范围与依据

### 1.1 审查对象

- 设计文档：`docs/CONFIG_HOT_RELOAD_DESIGN.md`
- 实现：当前未提交的 17 个文件变更与 2 个新增文件（`src/gateway/reload.rs`、`src/tools/reload_config.rs`），共 ~304 行净增
- 关联变更：`Cargo.toml`、`webui/package.json` 版本号 1.2.2 → 1.3.0；`README.md`、`AGENTS.md`、`docs/ARCHITECTURE.md`、`resources/skills/about-picobot/references/config.md` 文档同步

### 1.2 审查依据

- 仓库既有架构边界与并发不变量（见 `docs/ARCHITECTURE.md`）
- 现有相似机制（Channel 生命周期、TaskSupervisor、TurnController、OutboundDispatcher）
- 验证命令：`cargo build`、`cargo clippy --all-targets --all-features -- -D warnings`、`cargo test --lib`（330 passed）、`webui && npm run check && npm run build`

### 1.3 验证结果

| 命令 | 结果 |
|------|------|
| `cargo build` | 通过 |
| `cargo clippy --all-targets --all-features -- -D warnings` | 通过 |
| `cargo test --lib` | 330 passed / 0 failed |
| `cd webui && npm run check && npm run build` | 0 errors / 0 warnings |

## 2. 架构审查

### 2.1 整体架构评估

**结论：架构方向正确，运行代（runtime generation）切换模型是 PicoBot 配置散落现状下的唯一可靠方案。**

PicoBot Gateway 启动时把配置拆分复制到 `SessionManager`、`ChannelManager`、MCP、Scheduler、Browser、Auth、Upload 等长生命周期组件；`gateway.host/port`、进程 cwd、SQLite 连接具有进程级生命周期。在这种结构下，任何"原地替换 Config 指针"的方案都会导致请求处理组件混用新旧配置（新会话用新模型、旧 Session 仍用旧 Provider；或飞书配置显示禁用但旧连接仍在接收消息）。

运行代切换通过"先在旧代仍服务时构造完整候选代；候选可用后排空当前交互工作，再回收旧代并激活新代"避免了半套配置暴露。这一选择与 PicoBot 单进程、单 Gateway 模型契合，不引入 daemon/fork 层。

### 2.2 运行代模型合理性

运行代模型的关键设计点均合理：

| 设计点 | 评估 |
|--------|------|
| 保留原始 `std::net::TcpListener`，每代 `try_clone()` | ✅ 避免 bind 失败与端口抢占，保留内核 backlog |
| 候选构造期间不修改旧 `GatewayState` | ✅ 避免半套配置暴露 |
| `Config::load_for_reload` 使用启动环境快照、不调 `set_var` | ✅ 多线程运行期修改进程环境的危险被正确规避 |
| 不可变字段（host/port/workspace/db_path）边界清晰 | ✅ 边界划分正确 |
| 三个入口（CLI、`/reload`、`reload_config` 工具）共享控制通道 | ✅ 单一校验路径，语义一致 |
| 拒绝 nginx 式新旧 worker 长期并行 | ✅ 单进程规模不值得这份复杂度 |
| `ReloadHandle` 用有界 MPSC + `try_send` | ✅ 无界积压被正确拒绝 |

### 2.3 边界划分评估

热重载边界表（设计文档第 8 节）覆盖完整：

- 可热重载：`providers`/`models`/`agents`、`channels`、`mcp`、`browser`、`memory`、`gateway.scheduler`、`gateway.max_concurrent_background_tasks`、`gateway.file_transfer`、`gateway.require_pairing`
- 必须重启：`gateway.host`/`port`、`workspace_dir`、`gateway.session_db_path`、未进入配置组件的继承环境变量

边界划分与实现中 `load_candidate()` 的校验项一一对应。`workspace_dir` 在比较前按启动 cwd 解析并 canonicalize（`reload.rs:80-89`），与 `from_config` 中 `ensure_workspace_dir` 的 canonicalize 行为一致，比较基准正确。

### 2.4 并发不变量评估

设计文档第 10 节列出的 10 条不变量在实现中均得到遵守：

| 不变量 | 实现位置 | 遵守情况 |
|--------|----------|----------|
| 只有 `run()` 拥有 receiver 与当前运行代 | `mod.rs:331` ReloadController 在 `run()` 内创建 | ✅ |
| 候选构造不修改旧 GatewayState | `mod.rs:399` `from_config` 创建独立 state | ✅ |
| 不在热重载路径调 `env::set_var` | `config/mod.rs:611` `apply_to_process=false` | ✅ |
| 不释放原始 listener 后 rebind | `mod.rs:345` listener 在 `run()` 内持有 | ✅ |
| 旧 WebSocket 观察 `connection_shutdown` | `mod.rs:421` 切换前 cancel | ✅ |
| 旧任务由旧 TaskSupervisor 回收 | `mod.rs:432-436` 旧 supervisor shutdown | ✅ |
| 排空检查不长时间持有 Session mutex | `session.rs:2113-2124` 先克隆 Arc 再短锁 | ✅ |
| reload tool 保持 exclusive | `reload_config.rs:49` `exclusive: true` | ✅ |
| 不可变字段比较在候选构造之前 | `reload.rs:80-100` | ✅ |
| 新增启动期配置消费者需更新边界表 | 文档约束 | ⚠️ 维护性约束，无机制强制 |

## 3. 实现审查

### 3.1 与设计文档的一致性

实现与设计文档的关键路径高度一致：

| 设计文档章节 | 实现位置 | 一致性 |
|--------------|----------|--------|
| §6.1 准备阶段：load_candidate → from_config | `mod.rs:386-408` | ✅ |
| §6.2 接受响应：候选构造成功后通过 oneshot 返回 | `mod.rs:410-411` | ✅ |
| §6.3 排空阶段：wait_until_idle 60s + 500ms 投递窗口 | `mod.rs:412-419` | ✅ |
| §6.4 切换顺序：connection_shutdown → generation_shutdown → channel stop → task_supervisor → 新代启动 | `mod.rs:421-422, 429-436, 350-351` | ✅ |
| §4 控制通道：容量 8、try_send、队列满立即返回错误 | `reload.rs:8, 47-54` | ✅ |
| §7.2 环境变量语义：不修改进程环境 | `config/mod.rs:541-553` | ✅ |
| §9 失败语义：候选构造失败时旧代不变 | `mod.rs:394-408` continue 不切换 | ✅ |

### 3.2 关键路径分析

#### 3.2.1 切换时序

`gateway::run` 的主循环（`mod.rs:349-441`）正确实现了运行代切换：

```
外层 loop {
  start_all / start_message_processing  // 新代激活
  内层 loop { select! { serve | process_signal | reload_request } }
  serve.await                           // 等待旧 axum graceful shutdown
  channel_manager.stop_all              // 停止旧 channel intake
  task_supervisor.cancel + shutdown(10s)// 回收旧受监督任务
  state = next_state                    // 切换
}
```

`wait_for_shutdown_signal()` 在外层循环内创建（`mod.rs:365`），每代新建 future，不存在重复 poll 已完成 future 的 UB。

#### 3.2.2 候选构造与旧代隔离

`from_config`（`mod.rs:51-236`）为候选创建独立的 Storage、MessageBus、ChannelManager、SessionManager、ToolRegistry、AuthManager、UploadRegistry。候选的 `channel_manager.init()` 仅构造 Channel 对象，不调用 `start()`，因此不会与旧 channel 并发连接飞书 API。

#### 3.2.3 监听 socket 保留

`std::net::TcpListener`（`mod.rs:345`）在进程生命周期内持有；每代通过 `try_clone()`（`mod.rs:353`）获得新 fd。旧代 `serve` future 退出时仅释放克隆 fd，原始 socket 不释放，新代可重新克隆并 accept。内核 backlog 在切换窗口中暂存新 TCP 连接。

### 3.3 测试覆盖评估

**结论：单元测试覆盖不足，集成测试完全缺失。**

当前测试：

| 测试 | 位置 | 覆盖范围 |
|------|------|----------|
| `candidate_accepts_runtime_changes_and_rejects_workspace_changes` | `reload.rs:127` | load_candidate 的 model_id 变更接受与 workspace 拒绝 |
| `resolve_slash_command("reload")` | `session.rs:3485` | slash 命令解析 |

缺失但设计文档第 12 节明确列出的测试：

1. 启动真实 Gateway、修改 model/channel 配置、HTTP 触发重载、验证新代生效
2. 配置无效时验证旧 WebSocket 与旧 Provider 仍可工作
3. 长 Turn 中调用 `reload_config`、验证 tool result 与最终消息投递后才断开
4. Session 队列积压时验证重载等待排空
5. 排空超时、Channel stop 超时、候选激活失败的故障注入
6. 重载前后鉴权策略变化与旧 WebSocket 失效

当前测试仅覆盖纯函数路径（`load_candidate`、slash 解析），未验证任何运行时切换行为。这是上线前的主要风险点。

### 3.4 代码质量

- **Clippy**：`-D warnings` 通过
- **类型安全**：`ReloadHandle::unavailable()`（`reload.rs:39`）通过 drop receiver 使 `try_send` 返回 `Closed`，正确表达"Gateway 未由 `run()` 启动"的语义
- **错误处理**：候选构造失败时 `request.response.send(Err(...))` 后 `continue`，不切换；MCP 单 server 失败沿用启动语义（记录错误、跳过工具），不阻断候选构造
- **资源管理**：旧 TaskSupervisor 的 10s 有界 shutdown + abort 保证回收有硬时间边界

## 4. 发现的问题

### 4.1 架构层面问题

#### A1. MCP 在准备阶段连接，制造进程级副作用【中】

`from_config`（`mod.rs:171`）调用 `mcp::connect_all()`，立即建立 MCP 客户端连接或启动 stdio 子进程，并更新进程级 `MCP_SERVER_STATUS`（`mcp/mod.rs:42`）。这违反了设计文档第 10 节不变量 #2"候选构造不得修改旧 GatewayState"的精神——MCP status 虽非请求处理状态，但仍是旧代可见的进程级状态。

**具体影响：**

- 候选构造期间，旧代的 `/mcp` 命令显示候选的连接状态而非旧代状态
- stdio MCP 子进程双份运行（旧代 + 候选）可能竞争资源或 stdin/stdout
- 候选在 `connect_all` 之后失败（如 `ensure_default_maintenance_job` 失败，`mod.rs:194`），MCP 连接被 drop 但 `MCP_SERVER_STATUS` 仍显示 `connected: true`，旧代 `/mcp` 显示陈旧数据

设计文档第 6.1 节与第 14 节将此列为"已知例外"。但该例外的收益仅为"提前发现 MCP 失败"——而 MCP 单 server 失败本就被当非致命跳过（`mcp/mod.rs:171`），不需要提前连接来验证。

#### A2. 无 generation ID 与状态查询【中】

调用方只能得到准备阶段结果（"配置校验通过；Gateway 将在当前任务结束后切换到新配置"），无法查询重载最终是否完成。运维需要翻日志或重连客户端确认切换状态。设计文档第 14 节将此列为"演进方向"，但 generation ID（一个 atomic 计数器）+ `/api/config/reload/status` 端点成本极低，收益显著，应在 v1 内置。

#### A3. 候选激活失败无回滚【中-高】

切换顺序为：停旧 channel → 取消旧 TaskSupervisor → 切换 state → 启动新 channel（`mod.rs:429-351`）。若新代 `start_all()` 失败，`run()` 返回错误，依赖 systemd 拉起。但 channel 启动失败常为瞬时问题（飞书 5xx、端口冲突），丢掉本来正常服务的旧代去重启是可用性损失。旧代此时已被回收，无法回滚。

#### A4. `reload_config` Agent 工具的软约束【低-中】

`ReloadConfigTool`（`reload_config.rs`）注册到默认工具集，依赖 description"仅在用户明确要求重新加载配置时调用"约束 LLM。LLM compliance 是软约束，非可靠边界。`exclusive: true` 仅保证不与其他副作用工具并行，不保证调用时机正确。

#### A5. 排空仅覆盖交互 Session，不覆盖 Scheduler/后台 Agent【低】

`wait_until_idle` 仅检查内存中 Session 的 `current_cancel` 与 `agent_tx` 队列。Scheduler job、独立后台子 Agent、HTTP handler 不在排空范围内。Scheduler job 可能正在写 DB 或发送消息，被 TaskSupervisor 10s abort 截断可能留下不一致状态。设计文档第 6.3 节已承认此范围。

### 4.2 实现层面问题

#### I1. `/reload` slash command 绕过 `wait_until_idle`【中】

`session.rs:2631` 显示 slash command 在 `handle_message` 内联处理，返回 `HandleResult::CommandOutput`，**不进入 session worker 队列**。因此 `wait_until_idle` 检查 `current_cancel`/`agent_tx.capacity()` 时看不到 `/reload` Turn 的活动状态，立即返回（仅 100ms 稳定 + 500ms sleep）。

slash command output 经 `process_inbound` → `publish_command_output` → outbound dispatcher → channel API 投递，整条链路必须在 ~600ms（+ serve graceful shutdown + 10s TaskSupervisor shutdown）内完成。对 CLI channel 足够，但对 Feishu 等远端 channel 较紧。设计文档第 6.3 节"为 slash command output 和终态投递留出发送窗口"承认了 500ms 窗口，但 500ms 是固定值，无背压保证。

实际窗口因 TaskSupervisor 的 10s shutdown 较宽，不会丢消息——但若 channel `stop_all()` 关闭了连接，in-flight 的 `send_message` 可能失败。

#### I2. `session_db_path` 比较为原始字符串【低】

`reload.rs:91` 直接比较 `current.gateway.session_db_path != candidate.gateway.session_db_path`。若用户把 `null` 改为 `"./picobot.db"`（解析后同一路径），会被误拒。保守是对的，但产生假阳性。`workspace_dir` 已做 canonicalize 比较，`session_db_path` 应保持一致。

#### I3. `load_candidate` 仅校验 Feishu 凭据【低】

`reload.rs:73-78` 仅校验飞书 `app_id`/`app_secret` 非空。其他 channel 配置（若有）、MCP 配置、Browser 配置等在 `from_config` 期间才验证，可能在那里失败。这与设计文档第 6.1 节一致（"若飞书启用，校验 app_id 和 app_secret 非空"），但将失败发现延后到了候选构造阶段。

#### I4. `reload_config` HTTP handler 错误码语义【低】

`http.rs:355` 对所有错误用 `ApiError::bad_request`（400）。"gateway is shutting down"（队列关闭）更适合 503 Service Unavailable，"another configuration reload is already pending"更适合 409 Conflict。

#### I5. 候选 Storage 与旧 Storage 共享同一 SQLite 文件【低】

`from_config` 为候选创建新 Storage 连接到同一 `picobot.db`。候选的 background notification consumer 与 cleanup task 已归候选 TaskSupervisor，cleanup 跳过首次 tick，无入口触发 notification，故实际不写。理论上有并发写锁竞争可能，实际风险低。设计文档未显式说明此点。

### 4.3 严重度分级

| 问题 | 严重度 | 影响 |
|------|--------|------|
| A3 候选激活失败无回滚 | 中-高 | 瞬时 channel 故障导致整个 Gateway 重启 |
| A1 MCP 准备阶段连接 | 中 | 进程级 status 污染、子进程双份、失败后状态陈旧 |
| A2 无 generation ID | 中 | 运维无法确认切换完成状态 |
| I1 `/reload` 绕过排空 | 中 | 远端 channel output 投递窗口紧 |
| A4 reload_config 软约束 | 低-中 | LLM 误调用风险 |
| A5 排空不覆盖 Scheduler | 低 | 后台 job 被硬取消可能留下不一致 |
| I2 session_db_path 假阳性 | 低 | 用户需重启而非重载 |
| I3 仅校验 Feishu | 低 | 失败发现延后 |
| I4 HTTP 错误码 | 低 | 语义不准确 |
| I5 双 Storage 连接 | 低 | 理论并发风险 |

## 5. 改进建议

### 5.1 必须修复（上线前）

#### F1. 补充端到端集成测试

至少覆盖设计文档第 12 节列出的前 3 项：

1. 启动真实 Gateway、修改 model_id、HTTP 触发重载、验证新 model 在新 Turn 中生效
2. 配置无效（如 default agent 解析失败）时验证旧 WebSocket 与旧 Provider 仍可工作
3. 长 Turn 中调用 `reload_config`、验证 tool result 与最终消息投递后才断开

这些测试无法用单元测试替代，需启动真实 Gateway 进程。

#### F2. `session_db_path` 比较归一化

`reload.rs:91` 应将 `session_db_path` 相对于 workspace 解析并 canonicalize 后比较，与 `workspace_dir` 处理方式一致。`None` 与 `"picobot.db"`（默认值）应视为等价。

### 5.2 建议增强（近期演进）

#### E1. MCP 连接移出 `from_config`，消除"已知例外"

将 `mcp::connect_all()` 从 `from_config`（`mod.rs:171`）移到 `start_all()` 阶段，与 channel 启动同相位。收益：

- 消除进程级 `MCP_SERVER_STATUS` 在候选构造期间被污染
- 消除 stdio 子进程双份运行
- 候选失败时无 MCP 连接泄漏
- 不再需要设计文档第 10 节不变量 #2 的"已知例外"声明

代价：MCP 连接失败从"候选构造失败"延后到"激活失败"。但 MCP 单 server 失败本就非致命（跳过该 server 工具），整体激活失败语义不变。

#### E2. 引入 generation ID 与 status 查询

在 `ReloadController` 增加 `Arc<AtomicU64>` generation 计数器与 `ReloadState` enum（`Idle`/`Preparing`/`Draining`/`Activating`/`Active`/`Failed`）。提供 `GET /api/config/reload/status` 端点。调用方在收到 "accepted" 后可轮询确认切换完成。成本极低（一个 atomic + 一个路由 + 一个 enum），运维收益显著。

#### E3. 旧代保留至新代 `start_all()` 成功

调整切换顺序为：先启动新 channel（新代 channel_manager.start_all），成功后再停止旧 channel。短暂双 channel 并存对飞书 webhook 幂等消息可容忍。代价是需处理两代 channel 并存的资源冲突（如媒体目录、飞书事件去重），但避免瞬时 channel 故障导致 Gateway 整体重启。

若实现成本过高，至少应在 `start_all()` 失败时尝试重启旧代 channel（旧 TaskSupervisor 已 cancel，可能无法恢复；需评估可行性）。

#### E4. `/reload` slash command 排空路径

两种方案：

- **方案 A**：让 reload controller 记录触发源（channel, chat_id），等待该 inbound lane 的当前消息处理完成后再切换，而非泛化等待所有 session idle
- **方案 B**：为 outbound dispatcher 增加显式 drain contract，在 `stop_all` 前等待 outbound 队列排空或超时

方案 A 更精确，方案 B 更通用。两者都比固定 500ms sleep 可靠。

#### E5. Scheduler 与后台 job 协作式 drain

在 Scheduler job 的协作取消边界检查 reload token，允许 job 在写 DB 前/后选择继续完成或退出。避免 TaskSupervisor 10s abort 截断 DB 写一半的 job。设计文档第 14 节已列出此项。

### 5.3 演进方向（中长期）

#### L1. `prepare()` / `activate()` 显式分离

将 `from_config` 拆为：

- `construct()`：纯内存，无 I/O，可反复调用
- `prepare()`：可失败的 I/O（channel health check、MCP 连接、Storage ping），旧代仍服务
- `activate()`：开始接收消息

使更多失败前移到旧代仍可回退的阶段，而非等到 activate 才暴露。设计文档第 14 节已列出此项。

#### L2. 动态 Router service

引入新旧 HTTP generation 短期重叠，消除 accept/Channel intake 空窗。需不破坏 Channel/Session 边界。设计文档第 14 节已列出此项。

#### L3. WebUI/TUI reload 完成通知

客户端在 WebSocket 重连后显示 reload 完成状态与自动重连提示。依赖 E2 的 generation ID。

#### L4. 重新评估 `reload_config` Agent 工具

考虑移除该工具，仅保留 CLI 与 `/reload`。Agent 触发进程级状态切换的风险（A4）可能不抵边际收益。若保留，应在 SessionManager 层做调用方校验（如要求参数带确认 token），而非靠 description。

## 6. 总体结论

### 6.1 设计评估

设计文档质量高，边界清晰，不变量明确，失败语义完整。运行代切换模型是 PicoBot 当前架构下的正确选择。设计文档诚实地列出了已知限制（第 14 节），未掩饰缺陷。

主要设计层面的不足是把几件本应 v1 内置的能力（generation ID、MCP 相位对齐、旧代保留至新代激活成功）推迟到"演进方向"，导致可用性与可观测性打了折扣。MCP 的"已知例外"（A1）是设计妥协被文档化的典型，留着会让后续维护者也认为"再来一个例外无所谓"，应尽早消除而非长期承担。

### 6.2 实现评估

实现忠实遵循设计，关键不变量均得到遵守。代码通过 `cargo build`、`cargo clippy -D warnings`、`cargo test --lib`（330 passed）与 WebUI `check/build`。版本号、文档、AGENTS.md 同步更新。

主要实现层面的不足是测试覆盖：仅 `load_candidate` 与 slash 解析有单元测试，无任何运行时切换行为的集成验证（I1-I5 中多数问题需要集成测试才能暴露）。设计文档第 12 节明确列出但未实现的 6 项集成测试是上线前的主要风险。

### 6.3 上线建议

| 项 | 判定 |
|----|------|
| 架构方向 | ✅ 可接受 |
| 实现一致性 | ✅ 可接受 |
| 代码质量 | ✅ 可接受（Clippy/tests/build 全通过） |
| 测试覆盖 | ⚠️ 不足，需补集成测试（F1） |
| 已知限制 | ⚠️ 文档已承认，但 A1/A3 应优先修复 |

**建议：在完成 F1（集成测试）与 F2（session_db_path 归一化）后可提交。A1（MCP 相位）、A3（无回滚）应列为后续优先修复项，不应长期承担。**

## 附录：关键文件与符号索引

| 文件/符号 | 职责 | 行号 |
|-----------|------|------|
| `src/gateway/reload.rs::ReloadController` | 重载控制通道、候选配置加载、不可变字段校验 | 19-36 |
| `src/gateway/reload.rs::ReloadHandle` | 可克隆的请求端，注入 SessionManager/工具/HTTP state | 14-59 |
| `src/gateway/reload.rs::load_candidate` | 候选配置解析与不可变字段校验 | 61-102 |
| `src/gateway/mod.rs::run` | 持有 listener、当前运行代与切换主循环 | 318-441 |
| `src/gateway/mod.rs::GatewayState::from_config` | 构造一套完整运行代依赖 | 51-236 |
| `src/gateway/mod.rs::build_router` | 为每代构建 Axum Router | 448-489 |
| `src/config/mod.rs::Config::load_for_reload` | 使用启动环境/cwd 安全重新解析配置 | 541-553 |
| `src/config/mod.rs::Config::load_from_with_process_env` | 共享的配置加载实现，支持不写入进程环境 | 547-625 |
| `src/session/session.rs::wait_until_idle` | 检查活动 Turn、Session 队列与稳定空闲窗口 | 2109-2144 |
| `src/session/session.rs::execute_slash_command` | `/reload` slash 入口 | 2093-2099 |
| `src/tools/reload_config.rs::ReloadConfigTool` | Agent 可调用的独占重载工具 | 6-52 |
| `src/gateway/http.rs::reload_config` | 受保护的 `POST /api/config/reload` | 347-360 |
| `src/client/mod.rs::reload_gateway` | CLI HTTP 客户端与 bearer token 注入 | 101-124 |
| `src/main.rs::Command::Reload` | `picobot reload` CLI 定义 | 59-64, 132-138 |
| `src/mcp/mod.rs::connect_all` | MCP 连接（当前在 from_config 期间调用，见 A1） | 126-181 |
| `src/mcp/mod.rs::MCP_SERVER_STATUS` | 进程级 MCP 状态（A1 的副作用源） | 36-45 |
