23 KiB
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 进程拥有两层生命周期:
进程生命周期
├── 固定配置路径
├── 启动前进程环境快照
├── 启动 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. 端到端时序
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 准备阶段
准备阶段在旧运行代继续提供服务时执行:
load_candidate()重新读取当前 Gateway 启动时确定的配置文件。- 使用启动环境快照和启动 cwd 解析
.env、占位符与相对 workspace。 - 校验 default agent 能解析为完整
LLMProviderConfig。 - 若飞书启用,校验
app_id和app_secret非空。 - 比较不可热变更字段。
- 调用
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 与消息:
配置校验通过;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 切换与回收阶段
切换顺序是:
- 取消旧
connection_shutdown,使 WebSocket handler 主动退出。 - 取消当前 Axum generation shutdown token,停止接受新请求并等待已进入的请求完成。
- 调用旧
ChannelManager::stop_all(),停止外部消息入口。 - 取消旧
TaskSupervisor,最多等待 10 秒,超时任务被 abort。 - 把候选 state 设为当前 state。
- 启动候选 Channel、MCP、消息处理循环、outbound dispatcher 和 Scheduler。
- 从原始 listener 克隆新 Tokio listener,构建并运行新 Axum Router。
旧 WebSocket 不跨运行代迁移。TUI/WebUI 重连后按原有 client scope 从 SQLite 恢复 dialog 和历史;运行中的内存 Session 不直接搬迁到新 SessionManager。
7. 配置与环境变量语义
7.1 启动加载
正常启动使用以下优先级解析配置:
进程启动环境 > 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;它不会隐式触发重载。显式的 POST /api/config/reload 将文件写入与运行代切换解耦,使用户可以批量编辑后主动决定生效时机。
10. 并发与生命周期不变量
维护或扩展热重载时必须保持以下约束:
- 只有
gateway::run()拥有 reload receiver 和当前运行代,其他组件只能持有ReloadHandle。 - 候选构造不得修改旧
GatewayState,也不得提前连接 MCP 或替换旧 MessageBus、Channel、ToolRegistry、MCP status 或 Router。 - 不得在热重载路径调用
env::set_var;环境文件只允许在单线程首次启动时安装到进程环境。 - 不得释放原始 listener 后再尝试 bind 同一地址。
- 旧 WebSocket 必须观察
connection_shutdown,不能在新鉴权/配置运行代启动后继续无限存活。 - 旧长生命周期任务必须由旧
TaskSupervisor回收;候选任务必须由候选 Supervisor 所有。 - 排空检查不得长时间持有
SessionManagerInner或 Session mutex;当前实现先复制 Session Arc,再逐一短暂检查。 - reload tool 必须保持 exclusive 且只对根交互 Agent 可见,避免后台或子 Agent 触发进程级切换。
- admission 必须在会话 lane 入队前获取;Slash 回复、Scheduler job 和后台子 Agent 必须持有 guard 到其持久化/投递边界。
- 不可热变更字段的比较必须按有效路径归一化,并发生在候选运行代构造之前。
- 新增启动期配置消费者时,应确认它是否由
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继续可用。 /reloadalias 能解析到规范命令。- 全量 Rust 单元测试验证 Gateway、Session、Channel、鉴权和 TaskSupervisor 既有行为。
- 离线协议集成测试验证 slash/WebSocket 相关协议没有退化。
- Clippy、Cargo build 和 WebUI check/build 验证完整构建链。
后续适合增加的集成测试:
- 使用可控假 Provider 验证新 model 被下一 Turn 实际使用。
- 在长 Turn 中调用
reload_config,验证 tool result 和最终消息投递后才断开。 - Session 队列有积压时验证重载等待队列排空。
- 排空超时、Channel stop 超时和候选激活失败的故障注入。
- 重载前后鉴权策略变化以及旧 WebSocket 失效。
13. 运维使用
修改配置并保存后执行:
picobot reload
连接非默认 Gateway:
picobot reload --gateway-url https://gateway.example.com
也可以在支持 slash command 的聊天中发送:
/reload
若配置修改涉及监听地址、workspace、数据库路径或必须进入进程继承环境的变量,应执行完整重启:
picobot service restart
建议的运维流程是:
- 原子保存配置文件。
- 执行 reload 并检查返回是否为候选已接受。
- 使用已认证请求轮询
GET /api/config/reload/status,确认返回的 generation 进入active。 - 等待客户端重连,检查
/health和关键 Channel。 - 若激活失败,由 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 完成通知和自动重连状态提示。