feat: add gateway configuration hot reload

This commit is contained in:
xiaoxixi 2026-07-21 12:59:46 +08:00
parent a2af5f9991
commit 0d61354ba1
23 changed files with 2061 additions and 105 deletions

View File

@ -8,6 +8,7 @@ This file is the operational contract for coding agents working in this reposito
- `cargo run -- gateway` — start gateway server (binds `127.0.0.1:19876` by default)
- `cargo run -- chat` — connect to gateway as CLI client (default `ws://127.0.0.1:19876/ws`)
- `cargo run -- run "prompt"` — send one prompt through Gateway, print the terminal Turn, and exit; stdin, JSON, verbose progress, and timeout modes are available
- `cargo run -- reload` — validate and gracefully reload a running Gateway's configuration
- `docker compose up -d` — start the container with Gateway bound/published on `0.0.0.0:19876`; override `PICOBOT_GATEWAY_HOST`, `PICOBOT_PUBLISH_HOST`, or `PICOBOT_GATEWAY_PORT` as needed
- WebUI — start Gateway, then open `http://127.0.0.1:19876/`; no separate frontend build is required
- `cd webui && npm ci && npm run check && npm run build` — validate the Svelte WebUI independently (Node.js 20+); its local `dist/` is ignored
@ -91,6 +92,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
- **Scheduler** supports legacy direct-delivery jobs and managed `task`/`monitor` jobs; managed agents cannot call `send_message`, and `on_alert` suppresses only healthy informational results
- **AgentLoop** is stateless across turns; it receives prepared history, calls LLM providers, executes tools, and returns one result
- **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact
- **Gateway config reload** validates and prepares a complete next runtime generation before retiring the old one; close runtime admission before draining inbound lanes, Sessions, Scheduler jobs, and background sub-agents; MCP connects only during activation; host/port, workspace, and effective storage path remain restart-only, and runtime reload must never mutate process environment variables
- **WebUI slash completion** must consume the existing `get_slash_commands` WebSocket response; do not duplicate the backend command list in frontend source
- **WebUI chat rendering** sanitizes Markdown before inserting HTML; durable `turn_committed` deltas calibrate normal terminal Turns without a full history reload, and history must preserve structured tool-call metadata so calls and results remain independently collapsible
- **WebUI/TUI file transfer** streams bytes over authenticated HTTP and sends only short-lived upload IDs/attachment metadata over WebSocket; messages persist local media paths without guaranteeing later availability, and client responses must never expose those paths

View File

@ -1,6 +1,6 @@
[package]
name = "picobot"
version = "1.2.2"
version = "1.3.0"
edition = "2024"
[dependencies]

View File

@ -175,7 +175,7 @@ WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件
- 本地滚动日志的尾部查看、过滤和自动刷新。
- `config.json``~/.picobot/USER.md``~/.picobot/AGENTS.md` 编辑。
配置接口会掩码 API Key、secret、password 和 token保留 `********` 再保存不会覆盖原密钥。运行配置采用原子写入并在 Gateway 重启后生效,`USER.md``AGENTS.md` 则会用于后续构建的 Agent 上下文。
配置接口会掩码 API Key、secret、password 和 token保留 `********` 再保存不会覆盖原密钥。运行配置采用原子写入,保存后可执行 `picobot reload` 或发送 `/reload` 热重载;`USER.md``AGENTS.md` 的修改用于后续构建的 Agent 上下文。
WebUI 默认启用设备配对鉴权,管理 API 与 `/ws` 都拒绝未配对客户端;静态配对页、公开健康检查和配对提交接口除外。唯一的 WebSocket 例外是本机 `picobot run`:请求必须同时来自真实回环对端并持有权限为 `0600``~/.picobot/web_admin_token`,该管理令牌不能绕过任何管理 API 的设备鉴权。配对令牌只以 SHA-256 哈希写入 `~/.picobot/web_auth.json`。鉴权不提供传输加密;如果通过 `--host 0.0.0.0`、反向代理或端口转发暴露 Gateway仍必须使用 TLS。可通过 `gateway.require_pairing=false` 显式关闭配对,但不建议在非隔离环境使用。
@ -210,6 +210,14 @@ picobot service stop
picobot service uninstall
```
修改配置后无需重启 systemd service
```bash
picobot reload
```
该命令连接正在运行的 Gateway先解析并校验新配置再停止接收新工作等待当前交互 Turn、Scheduler job 和后台子 Agent 到达安全边界后切换运行代。也可在聊天中发送 `/reload`,或让根交互 Agent 在用户明确要求时调用 `reload_config` 工具;子 Agent 与定时任务不能触发重载。监听地址、workspace 和数据库路径涉及进程级资源,不能热重载;修改这些字段时命令会保留旧配置并提示使用 `picobot service restart`。重载会主动断开 WebSocketTUI/WebUI 随后可重新连接并从持久化历史恢复。受认证客户端可通过 `GET /api/config/reload/status` 查询 generation、切换阶段和最近错误。
unit 位于 `~/.config/systemd/user/picobot.service`,以执行 `service install` 时的当前目录作为初始工作目录。服务异常退出时由 systemd 自动重启;`stop``restart` 会通过 SIGTERM 触发 Gateway 的有界优雅关停。
TUI 会把一个随机客户端标识保存到 `~/.picobot/tui_client_id`,因此关闭并重新打开客户端后会恢复同一组 dialog 和最近使用的会话。界面支持流式正文、独立思考与工具状态、历史回放、会话列表与归档筛选、命令补全、Unicode/中文编辑、括号粘贴、多行输入和文件传输。
@ -292,6 +300,7 @@ Session ID 使用三段式:
| `/mcp` | 查看 MCP 服务器和工具状态 |
| `/stop` | 停止当前任务并清空队列 |
| `/todo [done\|cancel]` | 查看、完成或取消当前 session 的任务计划 |
| `/reload` | 校验并重新加载 Gateway 配置 |
| `/?`, `/help` | 查看帮助 |
### 记忆
@ -318,6 +327,7 @@ PicoBot 有两类记忆:
| `http_request` / `web_fetch` | HTTP 请求和网页文本抽取 |
| `get_skill` | 列出或读取本地 Skill |
| `memory_store` / `memory_recall` / `timeline_recall` / `memory_forget` | 长期记忆操作 |
| `reload_config` | 在用户明确要求时校验并重新加载 Gateway 配置 |
| `delegate` | 启动 inline、background 或 parallel 子 Agent |
| `todo` | 为复杂、多轮任务创建并更新当前 session 的持久化计划 |
| `send_message` | 向指定渠道或当前会话发送消息,可附带文件/截图WebUI/TUI 当前 Turn 的附件并入最终回复 |
@ -472,6 +482,7 @@ docs/ 面向维护者和 Agent 的架构与开发文档
## 进一步阅读
- [维护者架构文档](docs/ARCHITECTURE.md)
- [配置热重载设计与实现](docs/CONFIG_HOT_RELOAD_DESIGN.md)
- [WebUI 与 TUI 文件收发设计](docs/FILE_TRANSFER_DESIGN.md)
- [内置 Skill架构机制](resources/skills/about-picobot/references/architecture.md)
- [配置说明](resources/skills/about-picobot/references/config.md)

View File

@ -3,7 +3,7 @@ services:
build:
context: .
dockerfile: Dockerfile
image: picobot:1.2.2
image: picobot:1.3.0
container_name: picobot-test
restart: unless-stopped
ports:

View File

@ -2,7 +2,7 @@
本文档描述 PicoBot 当前实现的运行时边界、数据流、并发模型和演进约束。它面向维护者和后续参与改进的 Agent是代码架构的主入口行为细节仍以代码和测试为最终依据。
流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递的详细设计与取舍见 [STREAMING_TURN_DESIGN.md](STREAMING_TURN_DESIGN.md)。用户输入路由、Session 执行拆分、终态投递确认和历史增量校准的重构方案见 [MESSAGE_FLOW_REFACTOR_DESIGN.md](MESSAGE_FLOW_REFACTOR_DESIGN.md)。
流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递的详细设计与取舍见 [STREAMING_TURN_DESIGN.md](STREAMING_TURN_DESIGN.md)。用户输入路由、Session 执行拆分、终态投递确认和历史增量校准的重构方案见 [MESSAGE_FLOW_REFACTOR_DESIGN.md](MESSAGE_FLOW_REFACTOR_DESIGN.md)。配置运行代、重载边界和失败语义见 [CONFIG_HOT_RELOAD_DESIGN.md](CONFIG_HOT_RELOAD_DESIGN.md)。
## 1. 设计目标
@ -28,13 +28,15 @@ PicoBot 只有一个二进制,提供三种运行模式:
Linux 上可通过 `picobot service install/start/stop/status/restart/uninstall` 管理 systemd 用户服务。unit 固定为 `picobot.service`,其主进程仍是普通 Gateway 模式,不引入额外 daemon/fork 层;异常退出由 systemd 按 `Restart=on-failure` 拉起。
`picobot reload``/reload` 和根交互 Agent 的 `reload_config` 工具共享 Gateway 内部的有界重载控制通道。重载先用启动前捕获的进程环境重新解析配置和 `.env`,构造完整的下一运行代;候选构造失败时旧运行代不变。成功后关闭旧代 admission等待已进入的消息、交互 Turn、Scheduler job 和后台子 Agent 到达持久化/投递边界,再停止旧渠道和受监督任务、主动关闭旧 WebSocket并在保留的监听 socket 上启动新运行代。MCP 只在新代激活时连接。每次重载有 generation ID可通过 `GET /api/config/reload/status` 查询相位。监听 host/port、workspace 和 SQLite 有效路径属于进程级不变量,变更时拒绝热重载并要求完整重启。
原生 Gateway 默认绑定 `127.0.0.1:19876``gateway.host`/`gateway.port` 可由命令行 `--host`/`--port` 覆盖。Docker Compose 为保证端口映射可达,默认向容器传入 `0.0.0.0:19876``PICOBOT_GATEWAY_HOST` 控制容器内监听地址,`PICOBOT_PUBLISH_HOST` 控制宿主机发布地址,`PICOBOT_GATEWAY_PORT` 同时控制监听与映射端口。
CLI TUI 在 `~/.picobot/tui_client_id` 保存非敏感客户端标识,并通过 WebSocket 查询参数 `client_id` 传给 Gateway。`cli_chat` 以该标识作为稳定 chat scope重连时恢复内存中的当前 dialogGateway 重启后则恢复该 scope 最近活跃的未归档 dialog。无效或缺失的标识会退化为连接级随机 scope。
One-shot client 不绕过 Gateway 直接调用 Provider。每次 `run` 生成独立的 `run-<uuid>` scope通过相同的 `cli_chat`、MessageBus、SessionManager、AgentLoop 和 Turn delivery 路径执行;它不复用 TUI scope因而不会替换同一 scope 的活动 WebSocket。默认 stdout 只投影终态 Assistant blocks进度写到 stderr超时或 Ctrl-C 会先在当前 scope 发送 `/stop`
Gateway 启动时先从配置目录 `.env`、workspace `.env` 和既有进程环境合并启动变量,再初始化日志并切换进程工作目录到 `workspace_dir`。优先级为进程环境 > workspace `.env` > 配置目录 `.env`;配置目录层先用于定位 workspaceworkspace 层不得重定向自身位置。环境文件只在单线程启动阶段写入进程环境,不能移到后台任务启动之后。切换完成后所有相对路径都应按 workspace 解释,不能假设仍位于源码仓库。
Gateway 启动时先从配置目录 `.env`、workspace `.env` 和既有进程环境合并启动变量,再初始化日志并切换进程工作目录到 `workspace_dir`。优先级为进程环境 > workspace `.env` > 配置目录 `.env`;配置目录层先用于定位 workspaceworkspace 层不得重定向自身位置。环境文件只在单线程启动阶段写入进程环境,不能移到后台任务启动之后。重载使用启动前保存的进程环境快照解析各层,但不再修改进程环境,避免多线程运行期调用 `set_var`;新 Provider、MCP 和渠道使用解析后配置中的值。切换完成后所有相对路径都应按 workspace 解释,不能假设仍位于源码仓库。
## 3. 组件关系
@ -253,7 +255,7 @@ WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输WebSocket 只
同源 `/api/*` 管理接口只提供显式白名单能力:
- 配置读取/原子写入;响应中的密钥字段统一掩码,原样提交掩码会恢复现有值。运行配置只在重启后生效,不热替换运行中组件
- 配置读取/原子写入;响应中的密钥字段统一掩码,原样提交掩码会恢复现有值。`POST /api/config/reload` 通过同一重载控制器校验并切换 Gateway 运行代,`GET /api/config/reload/status` 查询 generation、相位与最近错误
- `USER.md``AGENTS.md` 只允许固定文件名,不接受任意路径。
- 日志、记忆、任务和运行记录均限制单次返回数量;日志目录固定为 `~/.picobot/logs`
- 任务与记忆读取复用 Storage API不允许 WebUI 直接持有 SQLite 连接或拼接任意查询。

View File

@ -0,0 +1,389 @@
# 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 完成通知和自动重连状态提示。

View File

@ -0,0 +1,369 @@
# 配置热重载功能审核报告
> 状态:审查完成;主要意见已于 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 guardScheduler 已执行 job 与后台子 Agent 持有 guard新任务在 drain 后不再进入。 |
| I1 / E4 Slash 回复可能丢失 | 接收 | `/reload` 所在 inbound 在 lane 入队前持有 guardcommand 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 已归候选 TaskSupervisorcleanup 跳过首次 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/Ochannel 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集成测试与 F2session_db_path 归一化后可提交。A1MCP 相位、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 |

View File

@ -3,7 +3,7 @@
配置文件加载顺序:`~/.picobot/config.json` → 当前目录 `./config.json`
占位符 `<VAR_NAME>` 从启动环境替换。PicoBot 依次加载 `config.json` 同目录的 `.env``workspace_dir/.env`最后保留启动进程已有环境变量作为最高优先级workspace 层覆盖配置目录层。合并值也会进入进程环境,供 MCP 和工具子进程继承。workspace `.env` 不能修改用于定位自身的 `workspace_dir`
Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取时 API Key、secret、password 和 token 会显示为 `********`,保持掩码不变再保存会保留原值;写入采用同目录临时文件替换。运行配置保存后需要重启 Gateway`USER.md``AGENTS.md` 的修改用于后续构建的 Agent 上下文。
Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取时 API Key、secret、password 和 token 会显示为 `********`,保持掩码不变再保存会保留原值;写入采用同目录临时文件替换。运行配置保存后可执行 `picobot reload`、发送 `/reload`,或由根交互 Agent 在用户明确要求时调用 `reload_config` 工具。Gateway 会先校验候选配置,停止接收新工作并等待交互 Turn、Scheduler job 和后台子 Agent 到达安全边界后切换;失败时继续使用旧配置。`GET /api/config/reload/status` 可查询 generation、相位与最近错误。`gateway.host``gateway.port``workspace_dir``gateway.session_db_path` 的有效路径必须通过完整重启变更。`USER.md``AGENTS.md` 的修改用于后续构建的 Agent 上下文。
## config.json 结构
@ -105,7 +105,7 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
| `media_dir_max_bytes` | int | 536870912 | 飞书媒体目录容量上限;达到上限后拒绝新下载,不自动删除旧文件 |
| `request_timeout_secs` | int | 30 | 单次飞书 HTTP 请求及响应体读取的硬超时,运行时限制在 5120 秒 |
飞书属于外部渠道:无论是否开启实时卡片,都不会接收模型 reasoning工具只显示紧凑状态。配置修改需重启 Gateway 生效。
飞书属于外部渠道:无论是否开启实时卡片,都不会接收模型 reasoning工具只显示紧凑状态。渠道配置可通过 Gateway 配置重载生效。
## mcp 字段

View File

@ -126,6 +126,7 @@ pub struct SubAgentManager {
skills_loader: Option<Arc<SkillsLoader>>,
work_manager: Option<Arc<crate::work::WorkManager>>,
task_supervisor: crate::task_supervisor::TaskSupervisor,
admission: crate::gateway::reload::RuntimeAdmission,
}
impl SubAgentManager {
@ -149,9 +150,18 @@ impl SubAgentManager {
skills_loader,
work_manager: None,
task_supervisor,
admission: crate::gateway::reload::RuntimeAdmission::open(),
}
}
pub(crate) fn with_admission(
mut self,
admission: crate::gateway::reload::RuntimeAdmission,
) -> Self {
self.admission = admission;
self
}
pub fn with_work_manager(mut self, work_manager: Arc<crate::work::WorkManager>) -> Self {
self.work_manager = Some(work_manager);
self
@ -164,7 +174,11 @@ impl SubAgentManager {
};
let filtered = ToolRegistry::new();
for (name, tool) in self.full_tools.iter() {
if allowed_set.contains(name.as_str()) && name != "delegate" && name != "todo" {
if allowed_set.contains(name.as_str())
&& name != "delegate"
&& name != "todo"
&& name != "reload_config"
{
filtered.register_raw(name, tool);
}
}
@ -324,6 +338,12 @@ impl SubAgentManager {
config: SubAgentConfig,
ctx: DelegateContext,
) -> Result<String, SubAgentError> {
let activity = self.admission.try_enter().ok_or_else(|| {
SubAgentError::Other(
"gateway is draining for configuration reload and cannot accept background tasks"
.to_string(),
)
})?;
let permit = self
.background_permits
.clone()
@ -423,6 +443,7 @@ impl SubAgentManager {
let spawned = self.task_supervisor.spawn_graceful(
format!("sub-agent:{task_id}"),
async move {
let _activity = activity;
let _permit = permit;
let started_at = chrono::Utc::now().timestamp_millis();
@ -851,4 +872,17 @@ mod tests {
assert!(matches!(error, SubAgentError::TooManyTasks(1)));
}
#[test]
fn reload_tool_is_never_delegated_to_sub_agents() {
let manager = manager(1);
manager
.full_tools
.register(crate::tools::ReloadConfigTool::new(
crate::gateway::reload::ReloadHandle::unavailable(),
));
let filtered = manager.filter_tools(&Some(vec!["reload_config".to_string()]));
assert!(filtered.get("reload_config").is_none());
}
}

View File

@ -98,6 +98,30 @@ fn gateway_http_base_url(gateway_url: &str) -> Result<String, Box<dyn std::error
Ok(url.to_string().trim_end_matches('/').to_string())
}
pub async fn reload_gateway(gateway_url: &str) -> Result<String, Box<dyn std::error::Error>> {
let base = gateway_http_base_url(gateway_url)?;
let mut request = reqwest::Client::new().post(format!("{base}/api/config/reload"));
if let Some(token) = load_auth_token() {
request = request.bearer_auth(token);
}
let response = request.send().await?;
let status = response.status();
let body: serde_json::Value = response.json().await?;
if !status.is_success() {
return Err(body
.get("error")
.and_then(serde_json::Value::as_str)
.unwrap_or("configuration reload failed")
.to_string()
.into());
}
Ok(body
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("configuration reload scheduled")
.to_string())
}
async fn exchange_pairing_code(
gateway_url: &str,
code: &str,

View File

@ -538,6 +538,32 @@ impl Config {
}
pub(crate) fn load_from(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
let process_env = collect_process_env();
Self::load_from_with_process_env(path, &process_env, true, None)
}
/// Reload configuration without mutating the process environment. The
/// supplied environment must be the process environment captured before
/// startup `.env` layers were installed, preserving the documented
/// precedence while keeping runtime reload thread-safe.
pub(crate) fn load_for_reload(
path: &Path,
startup_process_env: &HashMap<String, String>,
startup_cwd: &Path,
) -> Result<Self, Box<dyn std::error::Error>> {
Self::load_from_with_process_env(path, startup_process_env, false, Some(startup_cwd))
}
pub(crate) fn startup_process_env() -> HashMap<String, String> {
collect_process_env()
}
fn load_from_with_process_env(
path: &Path,
process_env: &HashMap<String, String>,
apply_to_process: bool,
workspace_base: Option<&Path>,
) -> Result<Self, Box<dyn std::error::Error>> {
let config_path = if path.exists() {
path.to_path_buf()
} else {
@ -554,7 +580,6 @@ impl Config {
};
let content = fs::read_to_string(&config_path)?;
let process_env = collect_process_env();
let config_env_path = config_path
.parent()
.unwrap_or_else(|| Path::new("."))
@ -563,14 +588,19 @@ impl Config {
// The config-directory layer selects the workspace. Loading the workspace
// layer first would be circular because its location comes from config.json.
let initial_env = merge_env_layers(&config_env, &HashMap::new(), &process_env);
let initial_env = merge_env_layers(&config_env, &HashMap::new(), process_env);
let initial_content = resolve_env_placeholders(&content, &initial_env);
let initial_config: Config = serde_json::from_str(&initial_content)?;
let workspace_path = expand_path(&initial_config.workspace_dir);
let mut workspace_path = expand_path(&initial_config.workspace_dir);
if workspace_path.is_relative()
&& let Some(base) = workspace_base
{
workspace_path = base.join(workspace_path);
}
let workspace_env_path = workspace_path.join(".env");
let workspace_env = read_env_file(&workspace_env_path)?;
let effective_env = merge_env_layers(&config_env, &workspace_env, &process_env);
let effective_env = merge_env_layers(&config_env, &workspace_env, process_env);
let resolved_content = resolve_env_placeholders(&content, &effective_env);
let config: Config = serde_json::from_str(&resolved_content)?;
if config.workspace_dir != initial_config.workspace_dir {
@ -581,7 +611,9 @@ impl Config {
.into());
}
apply_env_layers(&config_env, &workspace_env, &process_env);
if apply_to_process {
apply_env_layers(&config_env, &workspace_env, process_env);
}
tracing::info!(
path = %config_path.display(),
config_env = %config_env_path.display(),

View File

@ -106,6 +106,20 @@ impl ApiError {
}
}
fn conflict(message: impl Into<String>) -> Self {
Self {
status: StatusCode::CONFLICT,
message: message.into(),
}
}
fn service_unavailable(message: impl Into<String>) -> Self {
Self {
status: StatusCode::SERVICE_UNAVAILABLE,
message: message.into(),
}
}
fn internal(error: impl std::fmt::Display) -> Self {
tracing::error!(error = %error, "WebUI API request failed");
Self {
@ -344,6 +358,36 @@ pub struct ConfigResponse {
restart_required: bool,
}
#[derive(Serialize)]
pub struct ReloadResponse {
generation: u64,
message: String,
}
pub async fn reload_config(
State(state): State<Arc<GatewayState>>,
) -> Result<Json<ReloadResponse>, ApiError> {
let accepted = state.reload.request().await.map_err(|error| match error {
super::reload::ReloadError::AlreadyPending => ApiError::conflict(error.to_string()),
super::reload::ReloadError::ShuttingDown
| super::reload::ReloadError::PreparationFailed(_) => {
ApiError::service_unavailable(error.to_string())
}
super::reload::ReloadError::InvalidConfig(_)
| super::reload::ReloadError::ImmutableField(_) => ApiError::bad_request(error.to_string()),
})?;
Ok(Json(ReloadResponse {
generation: accepted.generation,
message: accepted.message,
}))
}
pub async fn reload_status(
State(state): State<Arc<GatewayState>>,
) -> Json<super::reload::ReloadStatus> {
Json(state.reload.status())
}
pub async fn get_config(
State(state): State<Arc<GatewayState>>,
) -> Result<Json<ConfigResponse>, ApiError> {
@ -389,7 +433,7 @@ pub async fn put_config(
let pretty = serde_json::to_string_pretty(&incoming).map_err(ApiError::internal)? + "\n";
atomic_write(&state.config_path, pretty.as_bytes()).await?;
tracing::info!(path = %state.config_path.display(), "Configuration updated from WebUI; restart required");
tracing::info!(path = %state.config_path.display(), "Configuration updated from WebUI; reload or restart required");
let mut response = incoming;
redact_secrets(&mut response);

View File

@ -1,5 +1,6 @@
pub mod auth;
pub mod http;
pub(crate) mod reload;
mod router;
pub mod uploads;
pub mod ws;
@ -32,20 +33,33 @@ pub struct GatewayState {
pub connection_shutdown: tokio_util::sync::CancellationToken,
pub auth: auth::AuthManager,
pub uploads: uploads::UploadRegistry,
pub(crate) reload: reload::ReloadHandle,
pub(crate) admission: reload::RuntimeAdmission,
}
impl GatewayState {
/// Construct a standalone state. Configuration reload is available only
/// when the state is owned by [`run`], which owns the generation loop.
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
let config_path = crate::config::resolve_default_config_path();
let config = Config::load_from(&config_path)?;
Self::from_config(config, config_path).await
Self::from_config(
config,
config_path,
reload::ReloadHandle::unavailable(),
true,
)
.await
}
async fn from_config(
config: Config,
config_path: std::path::PathBuf,
reload: reload::ReloadHandle,
initialize_process: bool,
) -> Result<Self, Box<dyn std::error::Error>> {
let task_supervisor = TaskSupervisor::new();
let admission = reload::RuntimeAdmission::open();
let delivery_coordinator = DeliveryCoordinator::new(ConversationWriteLocks::default());
let connection_shutdown = tokio_util::sync::CancellationToken::new();
let auth = auth::AuthManager::load(
@ -59,19 +73,24 @@ impl GatewayState {
let workspace_path = expand_path(&config.workspace_dir);
let workspace_path = ensure_workspace_dir(&workspace_path)?;
// Switch current working directory to workspace
std::env::set_current_dir(&workspace_path).map_err(|e| {
format!(
"Failed to switch to workspace directory {}: {}",
workspace_path.display(),
e
)
})?;
if initialize_process {
// Startup is single-threaded. Reload candidates reuse the already
// selected workspace and must not mutate process-global cwd.
std::env::set_current_dir(&workspace_path).map_err(|e| {
format!(
"Failed to switch to workspace directory {}: {}",
workspace_path.display(),
e
)
})?;
}
tracing::info!("Using workspace directory: {}", workspace_path.display());
// Release default AGENTS.md and USER.md to ~/.picobot/ if not exist
ensure_default_config_files();
if initialize_process {
ensure_default_config_files();
}
// Get provider config for SessionManager
let mut provider_config = config.get_provider_config("default")?;
@ -141,7 +160,9 @@ impl GatewayState {
memory_manager,
task_supervisor.clone(),
turn_delivery,
),
reload.clone(),
)
.with_admission(admission.clone()),
browser_config,
config.gateway.max_concurrent_background_tasks,
)?;
@ -160,21 +181,6 @@ impl GatewayState {
valid_channels.clone(),
));
// Initialize MCP servers — connect and register discovered tools
if !config.mcp.servers.is_empty() {
let mcp_tools = mcp::connect_all(&config.mcp).await;
for tool_info in mcp_tools {
let wrapper = mcp::McpToolWrapper::new(
&tool_info.server_name,
tool_info.tool_name,
tool_info.description,
tool_info.schema,
tool_info.connection,
);
session_manager.tools().register(wrapper);
}
}
// Initialize scheduler if enabled in config
let scheduler_config = config.gateway.scheduler.clone().unwrap_or_default();
if scheduler_config.enabled {
@ -225,6 +231,8 @@ impl GatewayState {
connection_shutdown,
auth,
uploads,
reload,
admission,
})
}
@ -240,6 +248,21 @@ impl GatewayState {
/// Start the message processing loops
pub async fn start_message_processing(&self) {
// MCP connections have external/process-wide side effects. Activate
// them only after this generation becomes current, never while it is
// merely a reload candidate.
let mcp_tools = mcp::connect_all(&self.config.mcp).await;
for tool_info in mcp_tools {
let wrapper = mcp::McpToolWrapper::new(
&tool_info.server_name,
tool_info.tool_name,
tool_info.description,
tool_info.schema,
tool_info.connection,
);
self.session_manager.tools().register(wrapper);
}
let bus = self.bus();
let bus_for_outbound = bus.clone();
let session_manager = self.session_manager.clone();
@ -276,7 +299,12 @@ impl GatewayState {
}
});
router::spawn_message_routers(bus.clone(), session_manager, self.task_supervisor.clone());
router::spawn_message_routers(
bus.clone(),
session_manager,
self.task_supervisor.clone(),
self.admission.clone(),
);
// Spawn outbound dispatcher
let dispatcher = OutboundDispatcher::new(
@ -295,10 +323,11 @@ impl GatewayState {
// Spawn scheduler background task if enabled
let scheduler_config = self.config.gateway.scheduler.clone().unwrap_or_default();
if scheduler_config.enabled {
let sched = Arc::new(Scheduler::new(
let sched = Arc::new(Scheduler::with_admission(
self.storage.clone(),
self.session_manager.clone(),
scheduler_config,
self.admission.clone(),
));
self.task_supervisor.spawn("scheduler", async move {
sched.run().await;
@ -313,30 +342,230 @@ pub async fn run(
port: Option<u16>,
) -> Result<(), Box<dyn std::error::Error>> {
let config_path = crate::config::resolve_default_config_path();
let startup_process_env = Config::startup_process_env();
let startup_cwd = std::env::current_dir()?;
let config = Config::load_from(&config_path)?;
// Initialize logging
logging::init_logging();
tracing::info!(config_path = %config_path.display(), "Starting PicoBot Gateway");
let state = Arc::new(GatewayState::from_config(config, config_path).await?);
// Start all channels (init already done while constructing GatewayState)
state.channel_manager.start_all().await?;
// Start message processing (inbound processor + control processor + outbound dispatcher)
state.start_message_processing().await;
let mut reload_controller = reload::ReloadController::new(startup_process_env, startup_cwd);
let mut state = Arc::new(
GatewayState::from_config(
config,
config_path.clone(),
reload_controller.handle.clone(),
true,
)
.await?,
);
// CLI args override config file values
let bind_host = host.unwrap_or_else(|| state.config.gateway.host.clone());
let bind_port = port.unwrap_or(state.config.gateway.port);
let addr = format!("{}:{}", bind_host, bind_port);
let listener = std::net::TcpListener::bind(&addr)?;
listener.set_nonblocking(true)?;
tracing::info!(address = %addr, "Gateway listening");
let process_signal = wait_for_shutdown_signal();
tokio::pin!(process_signal);
let mut current_generation = 1_u64;
loop {
if let Err(error) = state.channel_manager.start_all().await {
reload_controller.set_failed(current_generation, error.to_string());
return Err(error.into());
}
state.start_message_processing().await;
reload_controller.set_phase(current_generation, reload::ReloadPhase::Active);
let app = build_router(state.clone());
let generation_listener = TcpListener::from_std(listener.try_clone()?)?;
let generation_shutdown = tokio_util::sync::CancellationToken::new();
let shutdown_wait = generation_shutdown.clone();
let mut serve_task = tokio::spawn(async move {
axum::serve(
generation_listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async move { shutdown_wait.cancelled().await })
.await
});
let mut next_state = None;
let mut serve_result = None;
'generation: loop {
tokio::select! {
result = &mut serve_task => {
serve_result = Some(result);
state.connection_shutdown.cancel();
generation_shutdown.cancel();
break 'generation;
}
_ = &mut process_signal => {
tracing::info!("Shutdown signal received");
state.admission.close();
state.connection_shutdown.cancel();
generation_shutdown.cancel();
break 'generation;
}
request = reload_controller.receiver.recv() => {
let Some(request) = request else {
state.admission.close();
state.connection_shutdown.cancel();
generation_shutdown.cancel();
break 'generation;
};
let requested_generation = request.generation;
reload_controller.set_phase(requested_generation, reload::ReloadPhase::Preparing);
let candidate = match reload::load_candidate(
&config_path,
&reload_controller.startup_process_env,
&reload_controller.startup_cwd,
&state.config,
&state.workspace_dir,
) {
Ok(candidate) => candidate,
Err(error) => {
reload_controller.set_failed(requested_generation, error.to_string());
let _ = request.response.send(Err(error));
continue;
}
};
let preparation = GatewayState::from_config(
candidate,
config_path.clone(),
reload_controller.handle.clone(),
false,
);
tokio::pin!(preparation);
let prepared = match tokio::select! {
result = &mut preparation => Some(result),
result = &mut serve_task => {
serve_result = Some(result);
None
}
_ = &mut process_signal => None,
} {
None => {
let error = reload::ReloadError::ShuttingDown;
reload_controller.set_failed(requested_generation, error.to_string());
let _ = request.response.send(Err(error));
state.admission.close();
state.connection_shutdown.cancel();
generation_shutdown.cancel();
break 'generation;
}
Some(result) => match result {
Ok(prepared) => Arc::new(prepared),
Err(error) => {
let error = reload::ReloadError::PreparationFailed(format!(
"configuration reload failed: {error}"
));
reload_controller.set_failed(requested_generation, error.to_string());
let _ = request.response.send(Err(error));
continue;
}
}};
state.admission.close();
reload_controller.set_phase(requested_generation, reload::ReloadPhase::Draining);
let message = "配置校验通过Gateway 将在当前任务结束后切换到新配置。".to_string();
let _ = request.response.send(Ok(reload::ReloadAccepted {
generation: requested_generation,
message,
}));
let drain = async {
tokio::join!(
state.admission.wait_for_idle(),
state.session_manager.wait_until_idle(std::time::Duration::from_secs(60)),
)
};
let drain_result = tokio::select! {
result = tokio::time::timeout(std::time::Duration::from_secs(60), drain) => {
Some(matches!(result, Ok(((), true))))
}
result = &mut serve_task => {
serve_result = Some(result);
None
}
_ = &mut process_signal => None,
};
let Some(drained) = drain_result else {
reload_controller.set_failed(
requested_generation,
"gateway stopped while draining configuration reload",
);
state.connection_shutdown.cancel();
generation_shutdown.cancel();
break 'generation;
};
if !drained {
tracing::warn!("Reload drain period ended before all work became idle");
}
tracing::info!(path = %config_path.display(), "Switching to reloaded configuration");
reload_controller.set_phase(requested_generation, reload::ReloadPhase::Activating);
state.connection_shutdown.cancel();
generation_shutdown.cancel();
next_state = Some((prepared, requested_generation));
break 'generation;
}
}
}
if serve_result.is_none() {
serve_result =
match tokio::time::timeout(std::time::Duration::from_secs(10), &mut serve_task)
.await
{
Ok(result) => Some(result),
Err(_) => {
tracing::warn!("Aborting Axum generation after shutdown timeout");
serve_task.abort();
Some(serve_task.await)
}
};
}
if let Err(error) = state.channel_manager.stop_all().await {
tracing::error!(error = %error, "Failed to stop channels cleanly");
}
state.task_supervisor.cancel();
state
.task_supervisor
.shutdown(std::time::Duration::from_secs(10))
.await;
if let Some(result) = serve_result {
match result {
Ok(Ok(())) => {}
Ok(Err(error)) => return Err(error.into()),
Err(error) if error.is_cancelled() => {}
Err(error) => return Err(format!("Gateway server task failed: {error}").into()),
}
}
match next_state.take() {
Some((prepared, generation)) => {
state = prepared;
current_generation = generation;
}
None => break,
}
}
Ok(())
}
fn build_router(state: Arc<GatewayState>) -> Router {
let protected = Router::new()
.route("/api/health", routing::get(http::health))
.route(
"/api/config",
routing::get(http::get_config).put(http::put_config),
)
.route("/api/config/reload", routing::post(http::reload_config))
.route(
"/api/config/reload/status",
routing::get(http::reload_status),
)
.route(
"/api/profiles/{name}",
routing::get(http::get_profile).put(http::put_profile),
@ -360,7 +589,7 @@ pub async fn run(
auth::require_auth,
));
let app = Router::new()
Router::new()
.route("/", routing::get(http::webui_index))
.route("/app.js", routing::get(http::webui_script))
.route("/styles.css", routing::get(http::webui_styles))
@ -369,36 +598,7 @@ pub async fn run(
.route("/api/auth/pair", routing::post(auth::pair))
.route("/api/auth/code", routing::post(auth::issue_code))
.merge(protected)
.with_state(state.clone());
let addr = format!("{}:{}", bind_host, bind_port);
let listener = TcpListener::bind(&addr).await?;
tracing::info!(address = %addr, "Gateway listening");
let connection_shutdown = state.connection_shutdown.clone();
let serve_result = axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async move {
wait_for_shutdown_signal().await;
tracing::info!("Shutdown signal received");
connection_shutdown.cancel();
})
.await;
// Stop external intake before waiting for internal work to finish.
if let Err(error) = state.channel_manager.stop_all().await {
tracing::error!(error = %error, "Failed to stop channels cleanly");
}
state.task_supervisor.cancel();
state
.task_supervisor
.shutdown(std::time::Duration::from_secs(10))
.await;
serve_result?;
Ok(())
.with_state(state)
}
async fn wait_for_shutdown_signal() {

486
src/gateway/reload.rs Normal file
View File

@ -0,0 +1,486 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, RwLock};
use serde::Serialize;
use tokio::sync::{Notify, mpsc, oneshot};
use crate::config::Config;
const RELOAD_QUEUE_CAPACITY: usize = 8;
#[derive(Clone)]
pub(crate) struct RuntimeAdmission {
inner: Arc<AdmissionInner>,
}
struct AdmissionInner {
accepting: AtomicBool,
active: AtomicUsize,
idle: Notify,
}
pub(crate) struct ActivityGuard {
admission: RuntimeAdmission,
}
impl RuntimeAdmission {
pub fn open() -> Self {
Self {
inner: Arc::new(AdmissionInner {
accepting: AtomicBool::new(true),
active: AtomicUsize::new(0),
idle: Notify::new(),
}),
}
}
pub fn try_enter(&self) -> Option<ActivityGuard> {
if !self.inner.accepting.load(Ordering::Acquire) {
return None;
}
self.inner.active.fetch_add(1, Ordering::AcqRel);
if !self.inner.accepting.load(Ordering::Acquire) {
self.leave();
return None;
}
Some(ActivityGuard {
admission: self.clone(),
})
}
pub fn close(&self) {
self.inner.accepting.store(false, Ordering::Release);
if self.inner.active.load(Ordering::Acquire) == 0 {
self.inner.idle.notify_waiters();
}
}
pub fn is_accepting(&self) -> bool {
self.inner.accepting.load(Ordering::Acquire)
}
pub async fn wait_for_idle(&self) {
loop {
let notified = self.inner.idle.notified();
if self.inner.active.load(Ordering::Acquire) == 0 {
return;
}
notified.await;
}
}
fn leave(&self) {
if self.inner.active.fetch_sub(1, Ordering::AcqRel) == 1 {
self.inner.idle.notify_waiters();
}
}
}
impl Drop for ActivityGuard {
fn drop(&mut self) {
self.admission.leave();
}
}
pub(crate) struct ReloadRequest {
pub generation: u64,
pub response: oneshot::Sender<Result<ReloadAccepted, ReloadError>>,
}
#[derive(Clone)]
pub struct ReloadHandle {
sender: mpsc::Sender<ReloadRequest>,
next_generation: Arc<AtomicU64>,
pending: Arc<AtomicBool>,
status: Arc<RwLock<ReloadStatus>>,
}
pub(crate) struct ReloadController {
pub handle: ReloadHandle,
pub receiver: mpsc::Receiver<ReloadRequest>,
pub startup_process_env: HashMap<String, String>,
pub startup_cwd: PathBuf,
status: Arc<RwLock<ReloadStatus>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReloadAccepted {
pub generation: u64,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReloadPhase {
Active,
Preparing,
Draining,
Activating,
Failed,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReloadStatus {
pub generation: u64,
pub phase: ReloadPhase,
pub requested_at: Option<i64>,
pub activated_at: Option<i64>,
pub last_error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReloadError {
AlreadyPending,
ShuttingDown,
InvalidConfig(String),
ImmutableField(String),
PreparationFailed(String),
}
impl std::fmt::Display for ReloadError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AlreadyPending => {
write!(formatter, "another configuration reload is already pending")
}
Self::ShuttingDown => write!(formatter, "gateway is shutting down"),
Self::InvalidConfig(error)
| Self::ImmutableField(error)
| Self::PreparationFailed(error) => formatter.write_str(error),
}
}
}
impl std::error::Error for ReloadError {}
impl ReloadController {
pub fn new(startup_process_env: HashMap<String, String>, startup_cwd: PathBuf) -> Self {
let (sender, receiver) = mpsc::channel(RELOAD_QUEUE_CAPACITY);
let status = Arc::new(RwLock::new(ReloadStatus {
generation: 1,
phase: ReloadPhase::Active,
requested_at: None,
activated_at: Some(chrono::Utc::now().timestamp_millis()),
last_error: None,
}));
Self {
handle: ReloadHandle {
sender,
next_generation: Arc::new(AtomicU64::new(2)),
pending: Arc::new(AtomicBool::new(false)),
status: status.clone(),
},
receiver,
startup_process_env,
startup_cwd,
status,
}
}
pub fn set_phase(&self, generation: u64, phase: ReloadPhase) {
let mut status = self
.status
.write()
.unwrap_or_else(|error| error.into_inner());
status.generation = generation;
status.phase = phase;
if phase == ReloadPhase::Preparing {
status.requested_at = Some(chrono::Utc::now().timestamp_millis());
status.activated_at = None;
status.last_error = None;
}
if phase == ReloadPhase::Active {
status.activated_at = Some(chrono::Utc::now().timestamp_millis());
self.handle.pending.store(false, Ordering::Release);
}
}
pub fn set_failed(&self, generation: u64, error: impl Into<String>) {
let mut status = self
.status
.write()
.unwrap_or_else(|error| error.into_inner());
status.generation = generation;
status.phase = ReloadPhase::Failed;
status.last_error = Some(error.into());
self.handle.pending.store(false, Ordering::Release);
}
}
impl ReloadHandle {
pub(crate) fn unavailable() -> Self {
let (sender, receiver) = mpsc::channel(1);
drop(receiver);
Self {
sender,
next_generation: Arc::new(AtomicU64::new(1)),
pending: Arc::new(AtomicBool::new(false)),
status: Arc::new(RwLock::new(ReloadStatus {
generation: 0,
phase: ReloadPhase::Failed,
requested_at: None,
activated_at: None,
last_error: Some("reload controller is unavailable".to_string()),
})),
}
}
pub async fn request(&self) -> Result<ReloadAccepted, ReloadError> {
self.pending
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.map_err(|_| ReloadError::AlreadyPending)?;
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
let (response, receiver) = oneshot::channel();
if let Err(error) = self.sender.try_send(ReloadRequest {
generation,
response,
}) {
self.pending.store(false, Ordering::Release);
return Err(match error {
mpsc::error::TrySendError::Full(_) => ReloadError::AlreadyPending,
mpsc::error::TrySendError::Closed(_) => ReloadError::ShuttingDown,
});
}
match receiver.await {
Ok(result) => result,
Err(_) => {
self.pending.store(false, Ordering::Release);
Err(ReloadError::ShuttingDown)
}
}
}
pub fn status(&self) -> ReloadStatus {
self.status
.read()
.unwrap_or_else(|error| error.into_inner())
.clone()
}
}
pub(crate) fn load_candidate(
config_path: &Path,
startup_process_env: &HashMap<String, String>,
startup_cwd: &std::path::Path,
current: &Config,
current_workspace: &std::path::Path,
) -> Result<Config, ReloadError> {
let mut candidate = Config::load_for_reload(config_path, startup_process_env, startup_cwd)
.map_err(|error| {
ReloadError::InvalidConfig(format!("configuration reload failed: {error}"))
})?;
candidate
.get_provider_config("default")
.map_err(|error| ReloadError::InvalidConfig(format!("invalid default agent: {error}")))?;
if let Some(feishu) = candidate.channels.get("feishu")
&& feishu.enabled
&& (feishu.app_id.trim().is_empty() || feishu.app_secret.trim().is_empty())
{
return Err(ReloadError::InvalidConfig(
"enabled channels.feishu requires non-empty app_id and app_secret".to_string(),
));
}
let mut candidate_workspace = crate::config::expand_path(&candidate.workspace_dir);
if candidate_workspace.is_relative() {
candidate_workspace = startup_cwd.join(candidate_workspace);
}
let candidate_workspace = candidate_workspace
.canonicalize()
.unwrap_or(candidate_workspace);
if current_workspace != candidate_workspace {
return Err(ReloadError::ImmutableField(
"workspace_dir cannot be reloaded; restart the gateway".to_string(),
));
}
candidate.workspace_dir = current_workspace.to_string_lossy().to_string();
if effective_db_path(current, current_workspace)
!= effective_db_path(&candidate, current_workspace)
{
return Err(ReloadError::ImmutableField(
"gateway.session_db_path cannot be reloaded; restart the gateway".to_string(),
));
}
if current.gateway.host != candidate.gateway.host
|| current.gateway.port != candidate.gateway.port
{
return Err(ReloadError::ImmutableField(
"gateway.host and gateway.port cannot be reloaded; restart the gateway".to_string(),
));
}
Ok(candidate)
}
fn effective_db_path(config: &Config, workspace: &Path) -> PathBuf {
let path = config
.gateway
.session_db_path
.as_deref()
.map(crate::config::expand_path)
.unwrap_or_else(|| workspace.join("picobot.db"));
let path = if path.is_relative() {
workspace.join(path)
} else {
path
};
path.canonicalize().unwrap_or(path)
}
#[cfg(test)]
mod tests {
use super::*;
fn config_json(workspace: &std::path::Path, model_id: &str) -> String {
serde_json::json!({
"providers": {
"provider": {
"type": "openai",
"base_url": "https://example.invalid/v1",
"api_key": "test"
}
},
"models": { "model": { "model_id": model_id } },
"agents": {
"default": { "provider": "provider", "model": "model" }
},
"workspace_dir": workspace
})
.to_string()
}
#[test]
fn candidate_accepts_runtime_changes_and_rejects_workspace_changes() {
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let config_path = temp.path().join("config.json");
let current: Config = serde_json::from_str(&config_json(&workspace, "old-model")).unwrap();
std::fs::write(
&config_path,
config_json(std::path::Path::new("workspace"), "new-model"),
)
.unwrap();
let candidate = load_candidate(
&config_path,
&HashMap::new(),
temp.path(),
&current,
&workspace,
)
.unwrap();
assert_eq!(
candidate.get_provider_config("default").unwrap().model_id,
"new-model"
);
let other_workspace = temp.path().join("other");
std::fs::create_dir_all(&other_workspace).unwrap();
std::fs::write(&config_path, config_json(&other_workspace, "new-model")).unwrap();
let error = load_candidate(
&config_path,
&HashMap::new(),
temp.path(),
&current,
&workspace,
)
.unwrap_err();
assert!(
error
.to_string()
.contains("workspace_dir cannot be reloaded")
);
}
#[test]
fn equivalent_default_database_paths_are_reloadable() {
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
std::fs::write(workspace.join("picobot.db"), []).unwrap();
let config_path = temp.path().join("config.json");
let current: Config = serde_json::from_str(&config_json(&workspace, "old-model")).unwrap();
let mut candidate: serde_json::Value =
serde_json::from_str(&config_json(&workspace, "new-model")).unwrap();
candidate["gateway"] = serde_json::json!({ "session_db_path": "./picobot.db" });
std::fs::write(&config_path, serde_json::to_vec(&candidate).unwrap()).unwrap();
load_candidate(
&config_path,
&HashMap::new(),
temp.path(),
&current,
&workspace,
)
.unwrap();
}
#[tokio::test]
async fn admission_closes_and_waits_for_existing_activity() {
let admission = RuntimeAdmission::open();
let activity = admission.try_enter().unwrap();
admission.close();
assert!(admission.try_enter().is_none());
let waiting = admission.wait_for_idle();
tokio::pin!(waiting);
assert!(
tokio::time::timeout(std::time::Duration::from_millis(10), &mut waiting)
.await
.is_err()
);
drop(activity);
tokio::time::timeout(std::time::Duration::from_secs(1), waiting)
.await
.unwrap();
}
#[tokio::test]
async fn one_reload_remains_pending_until_its_generation_is_terminal() {
let mut controller = ReloadController::new(HashMap::new(), PathBuf::from("."));
let handle = controller.handle.clone();
let first = tokio::spawn({
let handle = handle.clone();
async move { handle.request().await }
});
let request = controller.receiver.recv().await.unwrap();
assert_eq!(request.generation, 2);
request
.response
.send(Ok(ReloadAccepted {
generation: 2,
message: "accepted".to_string(),
}))
.unwrap();
first.await.unwrap().unwrap();
assert_eq!(
handle.request().await.unwrap_err(),
ReloadError::AlreadyPending
);
controller.set_phase(2, ReloadPhase::Active);
let next = tokio::spawn({
let handle = handle.clone();
async move { handle.request().await }
});
let request = controller.receiver.recv().await.unwrap();
assert_eq!(request.generation, 3);
controller.set_failed(3, "invalid candidate");
request
.response
.send(Err(ReloadError::InvalidConfig(
"invalid candidate".to_string(),
)))
.unwrap();
assert!(matches!(
next.await.unwrap(),
Err(ReloadError::InvalidConfig(_))
));
let status = handle.status();
assert_eq!(status.generation, 3);
assert_eq!(status.phase, ReloadPhase::Failed);
}
}

View File

@ -8,6 +8,7 @@ use tokio::sync::{Semaphore, mpsc};
use crate::bus::{ControlMessage, InboundMessage, MessageBus, OutboundMessage};
use crate::channels::ChannelError;
use crate::channels::parse_slash_command;
use crate::gateway::reload::{ActivityGuard, RuntimeAdmission};
use crate::session::{SessionCommand, SessionEvent, SessionManager};
use crate::task_supervisor::TaskSupervisor;
@ -19,8 +20,14 @@ pub(super) fn spawn_message_routers(
bus: Arc<MessageBus>,
session_manager: Arc<SessionManager>,
supervisor: TaskSupervisor,
admission: RuntimeAdmission,
) {
spawn_inbound_router(bus.clone(), session_manager.clone(), supervisor.clone());
spawn_inbound_router(
bus.clone(),
session_manager.clone(),
supervisor.clone(),
admission,
);
spawn_control_router(bus, session_manager, supervisor);
}
@ -28,11 +35,12 @@ fn spawn_inbound_router(
bus: Arc<MessageBus>,
session_manager: Arc<SessionManager>,
supervisor: TaskSupervisor,
admission: RuntimeAdmission,
) {
let lane_supervisor = supervisor.clone();
supervisor.spawn("inbound-router", async move {
tracing::info!(lane_capacity = INBOUND_LANE_CAPACITY, "Inbound router started");
let mut lanes: HashMap<String, mpsc::Sender<InboundMessage>> = HashMap::new();
let mut lanes: HashMap<String, mpsc::Sender<AdmittedInbound>> = HashMap::new();
let mut messages_seen = 0_u64;
while let Some(inbound) = bus.consume_inbound().await {
@ -41,12 +49,26 @@ fn spawn_inbound_router(
lanes.retain(|_, sender| !sender.is_closed());
}
let Some(activity) = admission.try_enter() else {
publish_command_output(
&bus,
inbound,
"Gateway 正在重新加载配置,请稍后重试。".to_string(),
)
.await;
continue;
};
let inbound = AdmittedInbound { inbound, activity };
// Stop must be able to invalidate a running worker even when an
// earlier slow slash command occupies this conversation's lane.
if is_priority_stop(&inbound.content) {
if is_priority_stop(&inbound.inbound.content) {
let request_bus = bus.clone();
let request_manager = session_manager.clone();
let task_name = format!("inbound-stop:{}:{}", inbound.channel, inbound.chat_id);
let task_name = format!(
"inbound-stop:{}:{}",
inbound.inbound.channel, inbound.inbound.chat_id
);
if !lane_supervisor.spawn(task_name, async move {
process_inbound(request_bus, request_manager, inbound).await;
}) {
@ -55,7 +77,7 @@ fn spawn_inbound_router(
continue;
}
let key = conversation_key(&inbound.channel, &inbound.chat_id);
let key = conversation_key(&inbound.inbound.channel, &inbound.inbound.chat_id);
let mut sender = lanes.get(&key).cloned();
if sender.as_ref().is_none_or(mpsc::Sender::is_closed) {
let (new_sender, receiver) = mpsc::channel(INBOUND_LANE_CAPACITY);
@ -63,8 +85,8 @@ fn spawn_inbound_router(
&lane_supervisor,
bus.clone(),
session_manager.clone(),
inbound.channel.clone(),
inbound.chat_id.clone(),
inbound.inbound.channel.clone(),
inbound.inbound.chat_id.clone(),
receiver,
) {
tracing::warn!("Inbound router is stopping");
@ -81,10 +103,10 @@ fn spawn_inbound_router(
match sender.try_send(inbound) {
Ok(()) => {}
Err(mpsc::error::TrySendError::Full(inbound)) => {
tracing::warn!(channel = %inbound.channel, chat_id = %inbound.chat_id, "Inbound conversation lane is full");
tracing::warn!(channel = %inbound.inbound.channel, chat_id = %inbound.inbound.chat_id, "Inbound conversation lane is full");
publish_command_output(
&bus,
inbound,
inbound.inbound,
"当前对话入口队列已满,请稍后重试。".to_string(),
)
.await;
@ -98,15 +120,15 @@ fn spawn_inbound_router(
&lane_supervisor,
bus.clone(),
session_manager.clone(),
inbound.channel.clone(),
inbound.chat_id.clone(),
inbound.inbound.channel.clone(),
inbound.inbound.chat_id.clone(),
receiver,
) {
break;
}
lanes.insert(key, new_sender.clone());
if let Err(error) = new_sender.try_send(inbound) {
tracing::error!(error = %error, "Failed to enqueue input into replacement lane");
if new_sender.try_send(inbound).is_err() {
tracing::error!("Failed to enqueue input into replacement lane");
}
}
}
@ -121,7 +143,7 @@ fn spawn_inbound_lane(
session_manager: Arc<SessionManager>,
channel: String,
chat_id: String,
receiver: mpsc::Receiver<InboundMessage>,
receiver: mpsc::Receiver<AdmittedInbound>,
) -> bool {
supervisor.spawn(format!("inbound-lane:{channel}:{chat_id}"), async move {
run_ordered_lane(receiver, INBOUND_LANE_IDLE_TIMEOUT, move |inbound| {
@ -152,8 +174,12 @@ async fn run_ordered_lane<T, F, Fut>(
async fn process_inbound(
bus: Arc<MessageBus>,
session_manager: Arc<SessionManager>,
inbound: InboundMessage,
admitted: AdmittedInbound,
) {
let AdmittedInbound {
inbound,
activity: _activity,
} = admitted;
let result = session_manager.handle_message(&inbound).await;
match result {
@ -172,14 +198,20 @@ async fn process_inbound(
}
async fn publish_assistant_output(bus: &MessageBus, inbound: InboundMessage, content: String) {
publish_output(bus, inbound, content, false).await;
publish_output(bus, inbound, content, false, false).await;
}
async fn publish_command_output(bus: &MessageBus, inbound: InboundMessage, content: String) {
publish_output(bus, inbound, content, true).await;
publish_output(bus, inbound, content, true, true).await;
}
async fn publish_output(bus: &MessageBus, inbound: InboundMessage, content: String, command: bool) {
async fn publish_output(
bus: &MessageBus,
inbound: InboundMessage,
content: String,
command: bool,
confirmed: bool,
) {
let mut metadata = inbound.channel_context.private;
if command {
metadata.insert("_type".to_string(), "command".to_string());
@ -193,11 +225,21 @@ async fn publish_output(bus: &MessageBus, inbound: InboundMessage, content: Stri
metadata,
delivery: None,
};
if let Err(error) = bus.publish_outbound(outbound).await {
let result = if confirmed {
bus.deliver_outbound(outbound).await
} else {
bus.publish_outbound(outbound).await
};
if let Err(error) = result {
tracing::error!(error = %error, "Failed to publish routed outbound message");
}
}
struct AdmittedInbound {
inbound: InboundMessage,
activity: ActivityGuard,
}
fn spawn_control_router(
bus: Arc<MessageBus>,
session_manager: Arc<SessionManager>,
@ -382,7 +424,10 @@ mod tests {
},
};
publish_command_output(&bus, inbound, "done".to_string()).await;
let publish_task = tokio::spawn({
let bus = bus.clone();
async move { publish_command_output(&bus, inbound, "done".to_string()).await }
});
let output = bus.consume_outbound().await.unwrap();
assert_eq!(output.reply_to.as_deref(), Some("parent"));
@ -394,6 +439,9 @@ mod tests {
output.metadata.get("_type").map(String::as_str),
Some("command")
);
assert!(!publish_task.is_finished());
output.complete_delivery(Ok(()));
publish_task.await.unwrap();
}
#[tokio::test]

View File

@ -56,6 +56,12 @@ enum Command {
#[arg(long)]
port: Option<u16>,
},
/// Reload a running gateway's configuration
Reload {
/// Gateway WebSocket or HTTP URL
#[arg(long)]
gateway_url: Option<String>,
},
/// Generate a one-time browser pairing code from the local gateway
Pair {
/// Gateway WebSocket or HTTP URL
@ -123,6 +129,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Command::Gateway { host, port } => {
picobot::gateway::run(host, port).await?;
}
Command::Reload { gateway_url } => {
let config = picobot::config::Config::load_default().ok();
let url = gateway_url
.or_else(|| config.as_ref().map(|c| c.client.gateway_url.clone()))
.unwrap_or_else(|| "ws://127.0.0.1:19876/ws".to_string());
println!("{}", picobot::client::reload_gateway(&url).await?);
}
Command::Pair {
gateway_url,
revoke_all,

View File

@ -97,6 +97,7 @@ pub struct Scheduler {
session_manager: Arc<SessionManager>,
config: SchedulerConfig,
owner: String,
admission: crate::gateway::reload::RuntimeAdmission,
}
impl Scheduler {
@ -104,12 +105,27 @@ impl Scheduler {
storage: Arc<Storage>,
session_manager: Arc<SessionManager>,
config: SchedulerConfig,
) -> Self {
Self::with_admission(
storage,
session_manager,
config,
crate::gateway::reload::RuntimeAdmission::open(),
)
}
pub(crate) fn with_admission(
storage: Arc<Storage>,
session_manager: Arc<SessionManager>,
config: SchedulerConfig,
admission: crate::gateway::reload::RuntimeAdmission,
) -> Self {
Self {
storage,
session_manager,
config,
owner: uuid::Uuid::new_v4().to_string(),
admission,
}
}
@ -132,6 +148,9 @@ impl Scheduler {
loop {
interval.tick().await;
if !self.admission.is_accepting() {
continue;
}
let now = now_ms();
let lease_ms = self
.config
@ -167,6 +186,16 @@ impl Scheduler {
}
async fn execute_claimed_job(self: Arc<Self>, job: ScheduledJob) {
let Some(_activity) = self.admission.try_enter() else {
if let Err(error) = self
.storage
.release_scheduled_job_lease(&job.id, &self.owner)
.await
{
tracing::error!(job_id = %job.id, error = %error, "scheduler: failed to release job claimed during reload drain");
}
return;
};
let start = Instant::now();
let started_at = now_ms();
tracing::info!(job_id = %job.id, job_name = %job.name, "scheduler: executing claimed job");

View File

@ -1428,6 +1428,7 @@ pub struct SessionManager {
sub_agent_manager: Arc<crate::agent::SubAgentManager>,
task_supervisor: crate::task_supervisor::TaskSupervisor,
turn_delivery: TurnDeliveryService,
reload: crate::gateway::reload::ReloadHandle,
}
/// Gateway-owned runtime services shared by all Session workers.
@ -1436,6 +1437,8 @@ pub struct SessionManagerServices {
memory_manager: Arc<crate::memory::MemoryManager>,
task_supervisor: crate::task_supervisor::TaskSupervisor,
turn_delivery: TurnDeliveryService,
reload: crate::gateway::reload::ReloadHandle,
admission: crate::gateway::reload::RuntimeAdmission,
}
impl SessionManagerServices {
@ -1444,14 +1447,25 @@ impl SessionManagerServices {
memory_manager: Arc<crate::memory::MemoryManager>,
task_supervisor: crate::task_supervisor::TaskSupervisor,
turn_delivery: TurnDeliveryService,
reload: crate::gateway::reload::ReloadHandle,
) -> Self {
Self {
bus,
memory_manager,
task_supervisor,
turn_delivery,
reload,
admission: crate::gateway::reload::RuntimeAdmission::open(),
}
}
pub(crate) fn with_admission(
mut self,
admission: crate::gateway::reload::RuntimeAdmission,
) -> Self {
self.admission = admission;
self
}
}
struct SessionManagerInner {
@ -1544,6 +1558,11 @@ pub static SLASH_COMMANDS: &[SlashCommand] = &[
description: "查看、完成或取消当前任务计划",
aliases: &["/todo"],
},
SlashCommand {
name: "reload",
description: "重新加载配置",
aliases: &["/reload"],
},
];
fn resolve_slash_command(command: &str) -> Option<&'static SlashCommand> {
@ -1581,6 +1600,8 @@ impl SessionManager {
memory_manager,
task_supervisor,
turn_delivery,
reload,
admission,
} = services;
let mut skills_loader = SkillsLoader::new();
skills_loader.load_skills();
@ -1608,9 +1629,11 @@ impl SessionManager {
Some(skills_loader.clone()),
task_supervisor.clone(),
)
.with_admission(admission)
.with_work_manager(work_manager.clone()),
);
tools.register(crate::tools::DelegateTool::new(sub_agent_manager.clone()));
tools.register(crate::tools::ReloadConfigTool::new(reload.clone()));
// Start background task notification consumer
let sm_bus = bus.clone();
@ -1669,6 +1692,7 @@ impl SessionManager {
sub_agent_manager,
task_supervisor,
turn_delivery,
reload,
})
}
@ -1689,11 +1713,12 @@ impl SessionManager {
/// 为定时任务创建一个无 session 绑定的 AgentLoop
pub fn create_cron_agent(&self) -> Result<AgentLoop, AgentError> {
let tools = self.tools.without(&["reload_config"]);
let provider = create_provider(self.provider_config.clone())
.map_err(|e| AgentError::Other(format!("failed to create cron provider: {}", e)))?;
Ok(AgentLoop::with_provider_and_tools(
Arc::from(provider),
self.tools.clone(),
tools,
self.provider_config.max_tool_iterations,
self.provider_config.model_id.clone(),
self.provider_config.workspace_dir.clone(),
@ -1710,6 +1735,7 @@ impl SessionManager {
"cron_remove",
"cron_enable",
"cron_disable",
"reload_config",
]);
let provider = create_provider(self.provider_config.clone())
.map_err(|e| AgentError::Other(format!("failed to create scheduled provider: {e}")))?;
@ -2081,6 +2107,12 @@ impl SessionManager {
},
}
}
"reload" => self
.reload
.request()
.await
.map(|accepted| (None, accepted.message))
.map_err(|error| AgentError::Other(error.to_string())),
_ => Err(AgentError::Other(format!(
"未知命令:/{}。输入 /? 获取帮助。",
cmd.name
@ -2088,6 +2120,43 @@ impl SessionManager {
}
}
/// Wait until all interactive session Turns have reached a terminal state.
/// A reload uses this to avoid cancelling the Turn that requested it.
pub async fn wait_until_idle(&self, timeout: std::time::Duration) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
let mut idle_since = None;
loop {
let sessions: Vec<_> = {
let inner = self.inner.lock().await;
inner.sessions.values().cloned().collect()
};
let mut busy = false;
for session in sessions {
let session = session.lock().await;
let queued = session
.agent_tx
.as_ref()
.is_some_and(|sender| sender.capacity() < sender.max_capacity());
if session.current_cancel.is_some() || queued {
busy = true;
break;
}
}
if busy {
idle_since = None;
} else {
let since = idle_since.get_or_insert_with(tokio::time::Instant::now);
if since.elapsed() >= std::time::Duration::from_millis(100) {
return true;
}
}
if tokio::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
pub async fn create_session(
&self,
channel: &str,
@ -3427,6 +3496,10 @@ mod slash_command_tests {
resolve_slash_command("/help").map(|command| command.name),
Some("?")
);
assert_eq!(
resolve_slash_command("reload").map(|command| command.name),
Some("reload")
);
assert!(resolve_slash_command("unknown").is_none());
}
}

View File

@ -17,6 +17,7 @@ pub mod memory;
pub mod path_utils;
pub mod pty;
pub mod registry;
pub mod reload_config;
pub mod schema;
pub mod send_message;
pub mod todo;
@ -39,6 +40,7 @@ pub use maintenance::RoutineMaintenanceTool;
pub use memory::{MemoryForgetTool, MemoryRecallTool, MemoryStoreTool, TimelineRecallTool};
pub use pty::{PtyManager, PtyTool};
pub use registry::ToolRegistry;
pub use reload_config::ReloadConfigTool;
pub use send_message::SendMessageTool;
pub use todo::TodoTool;
pub use traits::{OutboundDelivery, OutboundMessenger, Tool, ToolResult, ToolResultWithMedia};

View File

@ -0,0 +1,52 @@
use async_trait::async_trait;
use super::{Tool, ToolResult};
use crate::gateway::reload::ReloadHandle;
pub struct ReloadConfigTool {
reload: ReloadHandle,
}
impl ReloadConfigTool {
pub fn new(reload: ReloadHandle) -> Self {
Self { reload }
}
}
#[async_trait]
impl Tool for ReloadConfigTool {
fn name(&self) -> &str {
"reload_config"
}
fn description(&self) -> &str {
"重新读取并校验 PicoBot 配置,然后让 Gateway 优雅切换到新配置。仅在用户明确要求重新加载配置时调用。"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false
})
}
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
match self.reload.request().await {
Ok(accepted) => Ok(ToolResult {
success: true,
output: accepted.message,
error: None,
}),
Err(error) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(error.to_string()),
}),
}
}
fn exclusive(&self) -> bool {
true
}
}

146
tests/test_config_reload.rs Normal file
View File

@ -0,0 +1,146 @@
use std::net::TcpListener;
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use serde_json::{Value, json};
use tokio::process::{Child, Command};
struct GatewayProcess(Child);
impl Drop for GatewayProcess {
fn drop(&mut self) {
let _ = self.0.start_kill();
}
}
fn available_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
fn config(workspace: &Path, model_id: &str) -> Value {
json!({
"providers": {
"provider": {
"type": "openai",
"base_url": "https://example.invalid/v1",
"api_key": "test"
}
},
"models": { "model": { "model_id": model_id } },
"agents": { "default": { "provider": "provider", "model": "model" } },
"gateway": { "require_pairing": false },
"workspace_dir": workspace
})
}
async fn wait_for_health(client: &reqwest::Client, base: &str) {
for _ in 0..100 {
if client
.get(format!("{base}/health"))
.send()
.await
.is_ok_and(|response| response.status().is_success())
{
return;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
panic!("gateway did not become healthy");
}
#[tokio::test]
async fn gateway_reloads_valid_config_and_keeps_serving_after_invalid_config() {
let temp = tempfile::tempdir().unwrap();
let home = temp.path().join("home");
let config_dir = home.join(".picobot");
let workspace = temp.path().join("workspace");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::create_dir_all(&workspace).unwrap();
let config_path = config_dir.join("config.json");
std::fs::write(
&config_path,
serde_json::to_vec_pretty(&config(&workspace, "old-model")).unwrap(),
)
.unwrap();
let port = available_port();
let child = Command::new(env!("CARGO_BIN_EXE_picobot"))
.args([
"gateway",
"--host",
"127.0.0.1",
"--port",
&port.to_string(),
])
.env("HOME", &home)
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
.unwrap();
let mut gateway = GatewayProcess(child);
let client = reqwest::Client::new();
let base = format!("http://127.0.0.1:{port}");
wait_for_health(&client, &base).await;
std::fs::write(
&config_path,
serde_json::to_vec_pretty(&config(&workspace, "new-model")).unwrap(),
)
.unwrap();
let accepted: Value = client
.post(format!("{base}/api/config/reload"))
.send()
.await
.unwrap()
.error_for_status()
.unwrap()
.json()
.await
.unwrap();
assert_eq!(accepted["generation"], 2);
let mut active = false;
for _ in 0..100 {
let response = client
.get(format!("{base}/api/config/reload/status"))
.send()
.await;
if let Ok(response) = response
&& let Ok(status) = response.json::<Value>().await
&& status["generation"] == 2
&& status["phase"] == "active"
{
active = true;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(active, "reloaded generation did not become active");
std::fs::write(&config_path, b"{").unwrap();
let invalid = client
.post(format!("{base}/api/config/reload"))
.send()
.await
.unwrap();
assert_eq!(invalid.status(), reqwest::StatusCode::BAD_REQUEST);
assert!(
client
.get(format!("{base}/health"))
.send()
.await
.unwrap()
.status()
.is_success(),
"invalid candidate stopped the active generation"
);
gateway.0.start_kill().unwrap();
gateway.0.wait().await.unwrap();
}

View File

@ -1,12 +1,12 @@
{
"name": "picobot-webui",
"version": "1.2.2",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picobot-webui",
"version": "1.2.2",
"version": "1.3.0",
"dependencies": {
"bits-ui": "^2.0.0",
"dompurify": "^3.4.12",

View File

@ -1,7 +1,7 @@
{
"name": "picobot-webui",
"private": true,
"version": "1.2.2",
"version": "1.3.0",
"type": "module",
"engines": {
"node": ">=20"