# PicoBot 配置热重载设计与实现

本文档描述 PicoBot 1.3.0 配置热重载功能的设计目标、运行时模型、实现边界、失败语义和维护要求。代码与测试是最终事实来源；本文用于解释为什么采用当前方案，以及后续修改必须保持哪些不变量。

## 1. 背景

PicoBot 的配置并非只在一个全局对象中读取。Gateway 启动时会把配置拆分并复制到多个长生命周期组件：

- `SessionManager`、现有 `Session`、子 Agent 和 Scheduler 持有 Provider/模型配置。
- `ChannelManager` 按配置创建并启动飞书、CLI Chat 等 Channel。
- MCP 配置在启动时用于连接 Server，并把发现的工具注册到 `ToolRegistry`。
- Browser、文件上传、鉴权、后台任务并发度等配置在各自组件构造时固化。
- Gateway 的监听 socket、进程 cwd 和 SQLite 连接具有进程级生命周期。

因此，简单地重新读取 `config.json` 或替换一个 `Config` 指针并不能可靠生效。这样会造成请求处理组件混用新旧配置，例如新会话使用新模型、旧 Session 仍使用旧 Provider，或者配置显示飞书已禁用但旧连接仍在接收消息。

当前实现采用“运行代（runtime generation）切换”：先在旧运行代仍然服务时解析、校验并构造完整候选运行代；候选可用后排空当前交互工作，再回收旧运行代并激活新运行代。

## 2. 目标与非目标

### 2.1 目标

- 提供统一的 `picobot reload`、`/reload` 和 `reload_config` 工具入口。
- 在停止旧运行代前完成候选配置解析、关键字段校验和依赖构造。
- 配置错误或候选构造失败时继续使用旧运行代，不中断服务。
- 尽量让正在执行及已经排队的交互 Turn 完成，避免热重载直接截断发起重载的 Turn。
- 重新创建所有启动期固化配置的组件，使 Provider、Channel、MCP、Scheduler、Browser、鉴权和上传策略一致地切换。
- 保持监听 socket，不释放端口，避免切换期间被其他进程抢占。
- 保持运行期环境变量操作线程安全：热重载不得调用 `std::env::set_var`。
- 为所有入口提供相同的校验、排队和错误语义。

### 2.2 非目标

- 不支持热变更监听地址、workspace 或 SQLite 路径。
- 不承诺 WebSocket 连接无感迁移；切换会主动关闭旧连接，客户端需要重连。
- 不实现 nginx 式新旧 worker 长时间并行处理连接。PicoBot 是单进程、单 Gateway 运行代模型，采用保留 socket 的顺序切换。
- 不动态修改已经启动进程的环境变量；`.env` 新值只用于重新解析配置占位符和显式组件配置。
- 不把“请求已接受”解释为“切换已经完成”。触发方在候选运行代构造成功后收到响应，实际切换在排空阶段之后发生。
- 不赋予本地 admin token 调用管理 API 的新权限；重载 HTTP API 遵循现有设备鉴权边界。

## 3. 核心设计：Gateway 运行代

### 3.1 生命周期结构

Gateway 进程拥有两层生命周期：

```text
进程生命周期
├── 固定配置路径
├── 启动前进程环境快照
├── 启动 cwd
├── 原始监听 socket
├── ReloadController
└── 当前 Gateway 运行代（可替换）
    ├── GatewayState
    ├── SessionManager / Session workers
    ├── MessageBus / routers / outbound dispatcher
    ├── ChannelManager / Channel connections
    ├── Scheduler / MCP / tools
    ├── AuthManager / UploadRegistry
    ├── Axum Router / WebSocket connections
    └── TaskSupervisor
```

进程级资源在 `gateway::run()` 外层只创建一次；运行代资源由 `GatewayState::from_config()` 重新构造。

### 3.2 为什么保留监听 socket

`gateway::run()` 首次启动时创建一个非阻塞 `std::net::TcpListener`，并在整个进程生命周期内持有它。每个运行代通过 `try_clone()` 获得一个 Tokio listener 交给 Axum。

切换时旧 Axum serve future 停止接受连接并退出，但原始 listener 仍然占有地址。旧运行代清理完成后，新运行代再克隆同一个 listener 开始接受连接。这样可以：

- 避免重新 bind 失败或端口被其他进程抢占。
- 保留内核 listen backlog；短暂切换窗口中的新 TCP 连接可能排队等待新运行代接收。
- 允许 Axum Router、鉴权 middleware 和 WebSocket state 随运行代完整替换。

这不是零停顿切换。旧运行代停止和受监督任务回收期间没有 Axum accept loop；当前回收宽限期上限为 10 秒，通常会更短。

## 4. 重载控制通道

重载协调类型位于 `src/gateway/reload.rs`：

- `ReloadHandle`：可克隆的请求端，注入 SessionManager、工具和 Gateway HTTP state。
- `ReloadController`：由 `gateway::run()` 独占，持有请求 receiver、启动环境快照和启动 cwd。
- `ReloadRequest`：包含 generation ID 与 oneshot response，用于把候选校验/构造结果返回触发方。
- `ReloadStatus`：记录 `preparing`、`draining`、`activating`、`active`、`failed` 相位、时间与最近错误。

控制通道使用容量为 8 的 Tokio MPSC 队列，并用原子 pending 标记保证同一时间最多只有一次重载。`ReloadHandle::request()` 使用 `try_send`：

- 已有重载尚未进入 `active` 或 `failed` 终态时立即返回 `another configuration reload is already pending`。
- Gateway 正在退出、receiver 已关闭时立即返回 `gateway is shutting down`。
- 请求成功入队后等待对应 oneshot 结果。

有界队列避免错误调用或模型重复调用形成无界重载积压；并发请求不会排队形成连续运行代切换，而是收到明确冲突错误。

## 5. 三种触发入口

三个入口只负责鉴权、参数适配和结果展示，最终都调用同一个 `ReloadHandle::request()`。

| 入口 | 实现 | 行为 |
|------|------|------|
| `picobot reload` | `src/main.rs`、`client::reload_gateway()` | 把 WebSocket/HTTP Gateway URL 转为 HTTP base URL，使用已保存的 TUI bearer token 调用 `POST /api/config/reload` |
| `/reload` | `SessionManager::execute_slash_command()` | 通过普通 slash command 路由执行，不进入 Agent 队列；结果作为 command output 返回当前 Channel |
| `reload_config` | `ReloadConfigTool` | 仅注册到根交互 Agent；无参数、独占执行，描述要求仅在用户明确要求重载时调用。子 Agent、Cron 和 managed scheduled Agent 无权获得该工具 |

`POST /api/config/reload` 返回 accepted generation；`GET /api/config/reload/status` 返回当前重载相位和最近错误。并发 `POST` 返回 409，Gateway 退出或候选准备失败返回 503，配置或不可变字段错误返回 400。

HTTP 路由属于现有 protected Router，因此：

- pairing 关闭时沿用 `PairingDisabled` 身份。
- pairing 开启时需要已配对的 bearer/cookie 凭据。
- 本地 `web_admin_token` 仍只允许用于既有的 loopback WebSocket 特例，不能绕过管理 API 鉴权。

pairing 开启但本机尚未保存 TUI token 时，`picobot reload` 会收到 HTTP 401；应先完成现有配对流程，或从一个已认证的聊天/WebUI 连接触发 `/reload`。

`GatewayState::new()` 只构造独立 state，没有运行代主循环，因此其中的 reload handle 明确不可用。正式 Gateway 必须通过 `gateway::run()` 启动，才能执行热重载。

## 6. 端到端时序

```mermaid
sequenceDiagram
    participant Caller as CLI / Slash / Tool
    participant RC as ReloadController
    participant Old as Old GatewayState
    participant New as Candidate GatewayState
    participant HTTP as Axum / Listener

    Caller->>RC: ReloadHandle::request()
    RC->>RC: 重新读取 config + .env
    RC->>RC: 校验 default agent、飞书凭据、不可变字段
    RC->>New: GatewayState::from_config(candidate)
    alt 解析、校验或构造失败
        RC-->>Caller: Error
        Note over Old: 旧运行代继续服务
    else 候选构造成功
        RC->>Old: 关闭 admission，拒绝新工作
        RC-->>Caller: Accepted + generation / 等待当前任务后切换
        RC->>Old: 等待 inbound、Session、Scheduler 与后台 Agent 排空（最多 60s）
        RC->>Old: cancel WebSocket connections
        RC->>HTTP: graceful shutdown 当前 serve future
        RC->>Old: stop_all channels
        RC->>Old: TaskSupervisor shutdown(10s)
        RC->>New: start_all channels + message processing
        RC->>HTTP: 从保留 listener 创建新 serve future
    end
```

### 6.1 准备阶段

准备阶段在旧运行代继续提供服务时执行：

1. `load_candidate()` 重新读取当前 Gateway 启动时确定的配置文件。
2. 使用启动环境快照和启动 cwd 解析 `.env`、占位符与相对 workspace；统一配置加载器在请求内副本上忽略可恢复的未知字段、类型错误和失效非核心条目，并保留原始 JSON Pointer 诊断，不改写磁盘文件。
3. 校验 default agent 能解析为完整 `LLMProviderConfig`；核心链路不可恢复时仍拒绝候选。
4. 若飞书启用，校验 `app_id` 和 `app_secret` 非空。
5. 比较不可热变更字段。
6. 调用 `GatewayState::from_config()` 构造候选运行代。

候选构造会重新创建 Storage handle、MemoryManager、MessageBus、ChannelManager、SessionManager、工具集、MCP 配置 wrapper、AuthManager 和 UploadRegistry。外部 Channel、MCP 连接、消息 routers、outbound dispatcher 和 Scheduler 在候选成为当前运行代前不会激活。`from_config(..., false)` 也不会再次修改进程 cwd 或释放默认配置文件。

MCP 的真实连接位于激活阶段，避免准备候选时启动双份 stdio 子进程或提前覆盖进程级 `MCP_SERVER_STATUS`。单个 MCP Server 连接失败沿用启动语义：记录错误并跳过其工具，不会让整个运行代激活失败。

SessionManager 构造期注册的通知消费者和周期清理任务已经归候选 `TaskSupervisor` 所有，但在激活前没有候选消息入口；周期清理也会跳过首次 interval tick。

### 6.2 接受响应

候选运行代构造成功并关闭旧代 admission 后，Controller 通过 oneshot 返回 generation 与消息：

```text
配置校验通过；Gateway 将在当前任务结束后切换到新配置。
```

此响应表示候选配置已经通过准备阶段，不表示切换完成。先返回响应有两个原因：

- `/reload` 的 command output 需要通过旧运行代发送给用户。
- `reload_config` 工具需要返回 tool result，让发起它的 Agent Turn 正常完成。

### 6.3 排空阶段

每个运行代有一个 `RuntimeAdmission`。Inbound 在进入会话 lane 前获取 activity guard，因此已经进入 lane 的消息也计入排空；关闭 admission 后新消息不再进入会话处理，并尽量收到“正在重新加载”提示。`/reload` 的 command output 使用 outbound delivery acknowledgement，guard 只有在回复实际投递成功或明确失败后才释放，不再依赖固定 sleep。

`SessionManager::wait_until_idle()` 同时检查所有已加载 Session：

- `current_cancel.is_some()` 表示当前有 Agent Turn 正在执行。
- Session MPSC sender 的剩余容量小于最大容量，表示仍有排队任务。
- 必须连续空闲 100ms 才认为稳定，避免 worker 刚取出任务、尚未设置 `current_cancel` 的竞态窗口。

Scheduler 在领取和执行任务前检查 admission，已执行任务持有 guard 到结果提交与投递完成；后台子 Agent 同样持有 guard。最长统一等待 60 秒。超时不会撤销已经接受的重载，而是记录 warning 并继续回收旧运行代；未完成工作随后会被取消。

Axum 自身会优雅等待已进入 handler 的 HTTP 请求；其 graceful shutdown 另有 10 秒硬上限，超过后 abort serve task。未纳入 admission 的维护型后台任务由 `TaskSupervisor` 的 10 秒有界关停负责。

### 6.4 切换与回收阶段

切换顺序是：

1. 取消旧 `connection_shutdown`，使 WebSocket handler 主动退出。
2. 取消当前 Axum generation shutdown token，停止接受新请求并等待已进入的请求完成。
3. 调用旧 `ChannelManager::stop_all()`，停止外部消息入口。
4. 取消旧 `TaskSupervisor`，最多等待 10 秒，超时任务被 abort。
5. 把候选 state 设为当前 state。
6. 启动候选 Channel、MCP、消息处理循环、outbound dispatcher 和 Scheduler。
7. 从原始 listener 克隆新 Tokio listener，构建并运行新 Axum Router。

旧 WebSocket 不跨运行代迁移。TUI/WebUI 重连后按原有 client scope 从 SQLite 恢复 dialog 和历史；运行中的内存 Session 不直接搬迁到新 SessionManager。

## 7. 配置与环境变量语义

### 7.1 启动加载

正常启动使用以下优先级解析配置：

```text
进程启动环境 > workspace/.env > config目录/.env
```

启动时合并的 `.env` 值会在单线程阶段写入进程环境，供后续 MCP 和工具子进程继承。

### 7.2 热重载加载

Gateway 在首次调用 `Config::load_from()` 前保存：

- 原始进程环境 `startup_process_env`。
- 切换 workspace 前的 `startup_cwd`。

热重载调用 `Config::load_for_reload()`：

- 重新读取 config 目录 `.env` 和 workspace `.env`。
- 继续以原始启动环境作为最高优先级，避免启动时注入进程环境的旧 `.env` 值错误覆盖新文件。
- 相对 `workspace_dir` 始终相对于启动 cwd 解析，不受 Gateway 已经 `chdir(workspace)` 影响。
- 只解析得到候选配置，不调用 `env::set_var`，避免多线程进程中修改全局环境。

因此，`.env` 的修改会影响配置中的 `<VAR_NAME>` 占位符和由配置显式传入的新组件。它不会改变现有进程环境；仅依赖继承环境、但没有通过配置显式传值的 Shell/子进程仍会看到启动时环境。需要变更这类继承环境时应完整重启 Gateway。

## 8. 热重载边界

### 8.1 可通过新运行代生效的配置

以下配置消费者会随 `GatewayState` 重建：

| 配置区域 | 新运行代中的效果 |
|----------|------------------|
| `providers`、`models`、`agents` | 新 SessionManager、Session、主 Agent、子 Agent 和 Scheduler Agent 使用新 Provider/模型参数 |
| `channels` | ChannelManager 重新创建并启动已启用 Channel，allowlist、凭据、媒体和实时投递策略更新 |
| `mcp` | 重新连接 MCP Server，并重新生成工具注册表 |
| `browser` | 根据新配置注册或移除 Browser 工具 |
| `memory` | 重建 MemoryManager；维护任务使用新的 retention 配置 |
| `gateway.scheduler` | 启用、关闭或按新并发/轮询/超时参数创建 Scheduler |
| `gateway.max_concurrent_background_tasks` | 新 SubAgentManager 使用新的并发上限 |
| `gateway.file_transfer` | 新 UploadRegistry 和 WebSocket capability 使用新限制 |
| `gateway.require_pairing` | 新 Router/AuthManager 使用新鉴权要求；已有 WebSocket 在切换时断开 |

配置中尚未被运行时代码消费的字段，在热重载后仍然不会产生功能效果。例如当前 `session_ttl_hours` 和 `cleanup_interval_minutes` 只完成了解析，尚未接入 Session 清理逻辑。

`client.gateway_url` 是 CLI 侧配置：`picobot reload` 在发请求前读取它来确定目标 Gateway，但它不是 Gateway 运行代设置。

### 8.2 必须完整重启的配置

| 字段 | 原因 |
|------|------|
| `gateway.host`、`gateway.port` | 原始监听 socket 在进程生命周期内固定；热重载不会重新 bind |
| `workspace_dir` | Gateway 已修改进程 cwd，工具路径、媒体路径和相对文件语义均依赖它 |
| `gateway.session_db_path` | Storage、SessionManager、Scheduler 和历史恢复必须共享同一个数据库身份 |
| 未显式进入配置组件的继承环境变量 | 热重载禁止运行期修改进程全局环境 |

候选值与当前值不一致时，`load_candidate()` 返回错误，并明确提示重启 Gateway。`workspace_dir` 在比较前按启动 cwd 解析并 canonicalize；通过校验后会被归一化为当前绝对 workspace 路径，避免候选构造时受当前 cwd 影响。

## 9. 原子性与失败语义

这里的“原子”是指请求处理组件的配置可见性：旧运行代不会被逐项改造成半套新配置。它不是数据库事务，也不是两个 worker 的瞬时指针交换；进程级 MCP status 的提前更新是下文记录的已知例外。

| 失败阶段 | 行为 |
|----------|------|
| 已有 pending 重载/控制器关闭 | 分别返回 409/503，不读取配置 |
| JSON、`.env`、占位符或默认 Agent 校验失败 | 返回错误，旧运行代保持不变；可恢复的历史字段问题只产生诊断，不进入此失败分支 |
| 不可热变更字段发生变化 | 返回 restart-required 错误，旧运行代保持不变 |
| `GatewayState::from_config()` 构造失败 | 返回错误；候选被丢弃，其 TaskSupervisor 随对象释放取消；旧请求处理运行代保持不变 |
| 单个 MCP Server 连接或工具发现失败 | 记录 MCP 失败状态，候选继续构造且不注册该 Server 的工具；这不视为整体 reload 失败 |
| 等待交互空闲超过 60 秒 | 记录 warning，继续切换，旧运行中的剩余工作可能被取消 |
| 旧 Channel 停止失败 | 记录 error，继续回收其他组件和切换 |
| 旧受监督任务 10 秒内未退出 | TaskSupervisor abort 剩余任务，继续切换 |
| 候选激活阶段 `start_all()` 失败 | `gateway::run()` 返回错误；若由 systemd 管理，则按 service restart policy 重启 |

准备阶段成功后才向调用者返回 accepted。激活阶段仍可能遇到运行时错误，因此调用者不应把 accepted 当作健康检查；可使用 `GET /api/config/reload/status` 等待相同 generation 进入 `active`，并结合 `/health` 与客户端重连确认。

WebUI `PUT /api/config` 只负责严格校验、原子写文件、恢复被掩码的 secret 并返回 `restart_required: true`；它不会允许新提交内容依赖容错忽略，也不会隐式触发重载。`GET /api/config` 返回原始文件 SHA-256 revision 和可恢复诊断；`POST /api/config/cleanup-invalid` 必须提交相同 revision，后端才会在共享配置写锁下原子删除已诊断路径，revision 变化返回 409。显式的 `POST /api/config/reload` 将文件写入与运行代切换解耦，使用户可以批量编辑或清理后主动决定生效时机。

## 10. 并发与生命周期不变量

维护或扩展热重载时必须保持以下约束：

1. 只有 `gateway::run()` 拥有 reload receiver 和当前运行代，其他组件只能持有 `ReloadHandle`。
2. 候选构造不得修改旧 `GatewayState`，也不得提前连接 MCP 或替换旧 MessageBus、Channel、ToolRegistry、MCP status 或 Router。
3. 不得在热重载路径调用 `env::set_var`；环境文件只允许在单线程首次启动时安装到进程环境。
4. 不得释放原始 listener 后再尝试 bind 同一地址。
5. 旧 WebSocket 必须观察 `connection_shutdown`，不能在新鉴权/配置运行代启动后继续无限存活。
6. 旧长生命周期任务必须由旧 `TaskSupervisor` 回收；候选任务必须由候选 Supervisor 所有。
7. 排空检查不得长时间持有 `SessionManagerInner` 或 Session mutex；当前实现先复制 Session Arc，再逐一短暂检查。
8. reload tool 必须保持 exclusive 且只对根交互 Agent 可见，避免后台或子 Agent 触发进程级切换。
9. admission 必须在会话 lane 入队前获取；Slash 回复、Scheduler job 和后台子 Agent 必须持有 guard 到其持久化/投递边界。
10. 不可热变更字段的比较必须按有效路径归一化，并发生在候选运行代构造之前。
11. 新增启动期配置消费者时，应确认它是否由 `GatewayState::from_config()` 重建，并更新本文的热重载边界表。

## 11. 关键实现位置

| 文件/符号 | 职责 |
|-----------|------|
| `src/gateway/reload.rs` | 重载请求通道、候选配置加载、不可变字段校验 |
| `src/gateway/mod.rs::run` | 持有 listener、当前运行代和切换主循环 |
| `src/gateway/mod.rs::GatewayState::from_config` | 构造一套完整运行代依赖 |
| `src/config/mod.rs::Config::load_for_reload` | 使用启动环境/cwd 安全重新解析配置，不修改进程环境 |
| `src/session/session.rs::wait_until_idle` | 检查活动 Turn、Session 队列和稳定空闲窗口 |
| `src/session/session.rs::execute_slash_command` | `/reload` 入口 |
| `src/tools/reload_config.rs` | Agent 可调用的独占重载工具 |
| `src/gateway/http.rs::reload_config` | 受保护的 `POST /api/config/reload` |
| `src/client/mod.rs::reload_gateway` | CLI HTTP 客户端与 bearer token 注入 |
| `src/main.rs::Command::Reload` | `picobot reload` CLI 定义 |

## 12. 测试策略

当前回归测试覆盖：

- 候选配置允许 Provider/模型等运行时字段变化。
- 可恢复未知字段、类型错误和失效非核心条目不阻止候选，并保留原始数组索引诊断；严格 WebUI 写入拒绝同样内容，清理只删除诊断路径。
- 相对 `workspace_dir` 按启动 cwd 正确解析。
- workspace 变化被拒绝且返回明确错误。
- `None` 与显式指向同一有效数据库路径（默认 `{config_dir}/data/picobot.db`）时允许重载。
- admission 关闭后拒绝新工作，并等待现有 activity guard 释放。
- command output 在 dispatcher 明确确认投递前不会释放处理任务。
- 真实子进程 Gateway 可完成 generation 2 切换；无效候选返回 400 且旧代 `/health` 继续可用。
- `/reload` alias 能解析到规范命令。
- 全量 Rust 单元测试验证 Gateway、Session、Channel、鉴权和 TaskSupervisor 既有行为。
- 离线协议集成测试验证 slash/WebSocket 相关协议没有退化。
- Clippy、Cargo build 和 WebUI check/build 验证完整构建链。

后续适合增加的集成测试：

1. 使用可控假 Provider 验证新 model 被下一 Turn 实际使用。
2. 在长 Turn 中调用 `reload_config`，验证 tool result 和最终消息投递后才断开。
3. Session 队列有积压时验证重载等待队列排空。
4. 排空超时、Channel stop 超时和候选激活失败的故障注入。
5. 重载前后鉴权策略变化以及旧 WebSocket 失效。

## 13. 运维使用

修改配置并保存后执行：

```bash
picobot reload
```

连接非默认 Gateway：

```bash
picobot reload --gateway-url https://gateway.example.com
```

也可以在支持 slash command 的聊天中发送：

```text
/reload
```

若配置修改涉及监听地址、workspace、数据库路径或必须进入进程继承环境的变量，应执行完整重启：

```bash
picobot service restart
```

建议的运维流程是：

1. 原子保存配置文件。
2. 执行 reload 并检查返回是否为候选已接受。
3. 使用已认证请求轮询 `GET /api/config/reload/status`，确认返回的 generation 进入 `active`。
4. 等待客户端重连，检查 `/health` 和关键 Channel。
5. 若激活失败，由 systemd 重启或人工恢复配置后再次启动。

## 14. 已知限制与演进方向

- 旧运行代回收与新运行代激活是顺序执行，存在短暂 accept/Channel intake 空窗。
- WebSocket 需要客户端自行重连，服务端没有连接迁移协议。
- 候选激活失败没有自动回滚到已经回收的旧运行代，依赖 systemd restart 或人工恢复。
- 同一时间只允许一个 pending 重载；并发请求返回 409，不做合并或排队。
- `.env` 不能热修改进程继承环境。
- admission 覆盖消息入口、Scheduler 与后台子 Agent；其他维护型任务仍依赖 TaskSupervisor 的有界关停。

可能的后续演进包括：

- 将 Channel 激活前检查拆成显式 `prepare()`，把更多运行时失败提前到旧运行代仍可回退的阶段。
- 在不破坏 Channel/Session 边界的前提下，引入动态 Router service，实现新旧 HTTP generation 短期重叠。
- 为 TUI/WebUI 增加 reload 完成通知和自动重连状态提示。
