# 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。 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` 的修改会影响配置中的 `` 占位符和由配置显式传入的新组件。它不会改变现有进程环境;仅依赖继承环境、但没有通过配置显式传值的 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`;它不会隐式触发重载。显式的 `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` 与显式指向同一有效数据库路径(默认 `{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 完成通知和自动重连状态提示。