feat: add gateway configuration hot reload
This commit is contained in:
parent
a2af5f9991
commit
0d61354ba1
@ -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
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "picobot"
|
||||
version = "1.2.2"
|
||||
version = "1.3.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
13
README.md
13
README.md
@ -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`。重载会主动断开 WebSocket,TUI/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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;重连时恢复内存中的当前 dialog,Gateway 重启后则恢复该 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`;配置目录层先用于定位 workspace,workspace 层不得重定向自身位置。环境文件只在单线程启动阶段写入进程环境,不能移到后台任务启动之后。切换完成后所有相对路径都应按 workspace 解释,不能假设仍位于源码仓库。
|
||||
Gateway 启动时先从配置目录 `.env`、workspace `.env` 和既有进程环境合并启动变量,再初始化日志并切换进程工作目录到 `workspace_dir`。优先级为进程环境 > workspace `.env` > 配置目录 `.env`;配置目录层先用于定位 workspace,workspace 层不得重定向自身位置。环境文件只在单线程启动阶段写入进程环境,不能移到后台任务启动之后。重载使用启动前保存的进程环境快照解析各层,但不再修改进程环境,避免多线程运行期调用 `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 连接或拼接任意查询。
|
||||
|
||||
389
docs/CONFIG_HOT_RELOAD_DESIGN.md
Normal file
389
docs/CONFIG_HOT_RELOAD_DESIGN.md
Normal 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` 返回 409,Gateway 退出或候选准备失败返回 503,配置或不可变字段错误返回 400。
|
||||
|
||||
HTTP 路由属于现有 protected Router,因此:
|
||||
|
||||
- pairing 关闭时沿用 `PairingDisabled` 身份。
|
||||
- pairing 开启时需要已配对的 bearer/cookie 凭据。
|
||||
- 本地 `web_admin_token` 仍只允许用于既有的 loopback WebSocket 特例,不能绕过管理 API 鉴权。
|
||||
|
||||
pairing 开启但本机尚未保存 TUI token 时,`picobot reload` 会收到 HTTP 401;应先完成现有配对流程,或从一个已认证的聊天/WebUI 连接触发 `/reload`。
|
||||
|
||||
`GatewayState::new()` 只构造独立 state,没有运行代主循环,因此其中的 reload handle 明确不可用。正式 Gateway 必须通过 `gateway::run()` 启动,才能执行热重载。
|
||||
|
||||
## 6. 端到端时序
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller as CLI / Slash / Tool
|
||||
participant RC as ReloadController
|
||||
participant Old as Old GatewayState
|
||||
participant New as Candidate GatewayState
|
||||
participant HTTP as Axum / Listener
|
||||
|
||||
Caller->>RC: ReloadHandle::request()
|
||||
RC->>RC: 重新读取 config + .env
|
||||
RC->>RC: 校验 default agent、飞书凭据、不可变字段
|
||||
RC->>New: GatewayState::from_config(candidate)
|
||||
alt 解析、校验或构造失败
|
||||
RC-->>Caller: Error
|
||||
Note over Old: 旧运行代继续服务
|
||||
else 候选构造成功
|
||||
RC->>Old: 关闭 admission,拒绝新工作
|
||||
RC-->>Caller: Accepted + generation / 等待当前任务后切换
|
||||
RC->>Old: 等待 inbound、Session、Scheduler 与后台 Agent 排空(最多 60s)
|
||||
RC->>Old: cancel WebSocket connections
|
||||
RC->>HTTP: graceful shutdown 当前 serve future
|
||||
RC->>Old: stop_all channels
|
||||
RC->>Old: TaskSupervisor shutdown(10s)
|
||||
RC->>New: start_all channels + message processing
|
||||
RC->>HTTP: 从保留 listener 创建新 serve future
|
||||
end
|
||||
```
|
||||
|
||||
### 6.1 准备阶段
|
||||
|
||||
准备阶段在旧运行代继续提供服务时执行:
|
||||
|
||||
1. `load_candidate()` 重新读取当前 Gateway 启动时确定的配置文件。
|
||||
2. 使用启动环境快照和启动 cwd 解析 `.env`、占位符与相对 workspace。
|
||||
3. 校验 default agent 能解析为完整 `LLMProviderConfig`。
|
||||
4. 若飞书启用,校验 `app_id` 和 `app_secret` 非空。
|
||||
5. 比较不可热变更字段。
|
||||
6. 调用 `GatewayState::from_config()` 构造候选运行代。
|
||||
|
||||
候选构造会重新创建 Storage handle、MemoryManager、MessageBus、ChannelManager、SessionManager、工具集、MCP 配置 wrapper、AuthManager 和 UploadRegistry。外部 Channel、MCP 连接、消息 routers、outbound dispatcher 和 Scheduler 在候选成为当前运行代前不会激活。`from_config(..., false)` 也不会再次修改进程 cwd 或释放默认配置文件。
|
||||
|
||||
MCP 的真实连接位于激活阶段,避免准备候选时启动双份 stdio 子进程或提前覆盖进程级 `MCP_SERVER_STATUS`。单个 MCP Server 连接失败沿用启动语义:记录错误并跳过其工具,不会让整个运行代激活失败。
|
||||
|
||||
SessionManager 构造期注册的通知消费者和周期清理任务已经归候选 `TaskSupervisor` 所有,但在激活前没有候选消息入口;周期清理也会跳过首次 interval tick。
|
||||
|
||||
### 6.2 接受响应
|
||||
|
||||
候选运行代构造成功并关闭旧代 admission 后,Controller 通过 oneshot 返回 generation 与消息:
|
||||
|
||||
```text
|
||||
配置校验通过;Gateway 将在当前任务结束后切换到新配置。
|
||||
```
|
||||
|
||||
此响应表示候选配置已经通过准备阶段,不表示切换完成。先返回响应有两个原因:
|
||||
|
||||
- `/reload` 的 command output 需要通过旧运行代发送给用户。
|
||||
- `reload_config` 工具需要返回 tool result,让发起它的 Agent Turn 正常完成。
|
||||
|
||||
### 6.3 排空阶段
|
||||
|
||||
每个运行代有一个 `RuntimeAdmission`。Inbound 在进入会话 lane 前获取 activity guard,因此已经进入 lane 的消息也计入排空;关闭 admission 后新消息不再进入会话处理,并尽量收到“正在重新加载”提示。`/reload` 的 command output 使用 outbound delivery acknowledgement,guard 只有在回复实际投递成功或明确失败后才释放,不再依赖固定 sleep。
|
||||
|
||||
`SessionManager::wait_until_idle()` 同时检查所有已加载 Session:
|
||||
|
||||
- `current_cancel.is_some()` 表示当前有 Agent Turn 正在执行。
|
||||
- Session MPSC sender 的剩余容量小于最大容量,表示仍有排队任务。
|
||||
- 必须连续空闲 100ms 才认为稳定,避免 worker 刚取出任务、尚未设置 `current_cancel` 的竞态窗口。
|
||||
|
||||
Scheduler 在领取和执行任务前检查 admission,已执行任务持有 guard 到结果提交与投递完成;后台子 Agent 同样持有 guard。最长统一等待 60 秒。超时不会撤销已经接受的重载,而是记录 warning 并继续回收旧运行代;未完成工作随后会被取消。
|
||||
|
||||
Axum 自身会优雅等待已进入 handler 的 HTTP 请求;其 graceful shutdown 另有 10 秒硬上限,超过后 abort serve task。未纳入 admission 的维护型后台任务由 `TaskSupervisor` 的 10 秒有界关停负责。
|
||||
|
||||
### 6.4 切换与回收阶段
|
||||
|
||||
切换顺序是:
|
||||
|
||||
1. 取消旧 `connection_shutdown`,使 WebSocket handler 主动退出。
|
||||
2. 取消当前 Axum generation shutdown token,停止接受新请求并等待已进入的请求完成。
|
||||
3. 调用旧 `ChannelManager::stop_all()`,停止外部消息入口。
|
||||
4. 取消旧 `TaskSupervisor`,最多等待 10 秒,超时任务被 abort。
|
||||
5. 把候选 state 设为当前 state。
|
||||
6. 启动候选 Channel、MCP、消息处理循环、outbound dispatcher 和 Scheduler。
|
||||
7. 从原始 listener 克隆新 Tokio listener,构建并运行新 Axum Router。
|
||||
|
||||
旧 WebSocket 不跨运行代迁移。TUI/WebUI 重连后按原有 client scope 从 SQLite 恢复 dialog 和历史;运行中的内存 Session 不直接搬迁到新 SessionManager。
|
||||
|
||||
## 7. 配置与环境变量语义
|
||||
|
||||
### 7.1 启动加载
|
||||
|
||||
正常启动使用以下优先级解析配置:
|
||||
|
||||
```text
|
||||
进程启动环境 > workspace/.env > config目录/.env
|
||||
```
|
||||
|
||||
启动时合并的 `.env` 值会在单线程阶段写入进程环境,供后续 MCP 和工具子进程继承。
|
||||
|
||||
### 7.2 热重载加载
|
||||
|
||||
Gateway 在首次调用 `Config::load_from()` 前保存:
|
||||
|
||||
- 原始进程环境 `startup_process_env`。
|
||||
- 切换 workspace 前的 `startup_cwd`。
|
||||
|
||||
热重载调用 `Config::load_for_reload()`:
|
||||
|
||||
- 重新读取 config 目录 `.env` 和 workspace `.env`。
|
||||
- 继续以原始启动环境作为最高优先级,避免启动时注入进程环境的旧 `.env` 值错误覆盖新文件。
|
||||
- 相对 `workspace_dir` 始终相对于启动 cwd 解析,不受 Gateway 已经 `chdir(workspace)` 影响。
|
||||
- 只解析得到候选配置,不调用 `env::set_var`,避免多线程进程中修改全局环境。
|
||||
|
||||
因此,`.env` 的修改会影响配置中的 `<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. 并发与生命周期不变量
|
||||
|
||||
维护或扩展热重载时必须保持以下约束:
|
||||
|
||||
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 完成通知和自动重连状态提示。
|
||||
369
docs/CONFIG_HOT_RELOAD_REVIEW.md
Normal file
369
docs/CONFIG_HOT_RELOAD_REVIEW.md
Normal 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 guard;Scheduler 已执行 job 与后台子 Agent 持有 guard,新任务在 drain 后不再进入。 |
|
||||
| I1 / E4 Slash 回复可能丢失 | 接收 | `/reload` 所在 inbound 在 lane 入队前持有 guard;command 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 已归候选 TaskSupervisor,cleanup 跳过首次 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/O(channel 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(集成测试)与 F2(session_db_path 归一化)后可提交。A1(MCP 相位)、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 |
|
||||
@ -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 请求及响应体读取的硬超时,运行时限制在 5–120 秒 |
|
||||
|
||||
飞书属于外部渠道:无论是否开启实时卡片,都不会接收模型 reasoning;工具只显示紧凑状态。配置修改需重启 Gateway 生效。
|
||||
飞书属于外部渠道:无论是否开启实时卡片,都不会接收模型 reasoning;工具只显示紧凑状态。渠道配置可通过 Gateway 配置重载生效。
|
||||
|
||||
## mcp 字段
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
486
src/gateway/reload.rs
Normal 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(),
|
||||
¤t,
|
||||
&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(),
|
||||
¤t,
|
||||
&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(),
|
||||
¤t,
|
||||
&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);
|
||||
}
|
||||
}
|
||||
@ -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]
|
||||
|
||||
13
src/main.rs
13
src/main.rs
@ -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,
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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};
|
||||
|
||||
52
src/tools/reload_config.rs
Normal file
52
src/tools/reload_config.rs
Normal 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
146
tests/test_config_reload.rs
Normal 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();
|
||||
}
|
||||
4
webui/package-lock.json
generated
4
webui/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picobot-webui",
|
||||
"private": true,
|
||||
"version": "1.2.2",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user