PicoBot/docs/CONFIG_HOT_RELOAD_DESIGN.md

390 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` 返回 409Gateway 退出或候选准备失败返回 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。
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 acknowledgementguard 只有在回复实际投递成功或明确失败后才释放,不再依赖固定 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 重新创建并启动已启用 Channelallowlist、凭据、媒体和实时投递策略更新 |
| `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`;它不会隐式触发重载。显式的 `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/模型等运行时字段变化。
- 相对 `workspace_dir` 按启动 cwd 正确解析。
- workspace 变化被拒绝且返回明确错误。
- `None``./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 完成通知和自动重连状态提示。