feat(search): provider 热重载 + primary/fallback(W3)

- 保存被包成事务:快照 → 落盘 → 按新配置重建工具 → 原子替换注册表条目(registry.ReplaceTool
  窄方法,保留运行期 Policy);下一轮即生效,不要求重启
- 若新配置会让后端下次起不来(主家未配齐 fail-fast)⇒ 文件按字节回滚 + 409 config_unstartable
- 声明面补 primary + fallback[](可配可生效);否决「另开 form」方案(会破坏既有 reauth 回填)
- 主 provider 解析口径核查结论:没被 PLG-4 改坏(顺手修了一处日志缺陷)
- 4 个变异自检(丢 Policy/不换条目/回滚 no-op/忽略 fallback)
This commit is contained in:
AskaEth committed 2026-10-06 15:59:12 +08:00
1 parent 5ee5f9126f
commit a30f4a49ff
12 files changed
+1696 -16

No files matched your search

+257
View File
@@ -0,0 +1,257 @@
package main
// 本文件是「搜索提供商」页的**热重载**(切片 W3;`DECISIONS-2026-10-06` 裁决 #1):
// 面板保存后,**重建** web-search 工具并**替换**能力注册表里的条目,使改动在下一轮对话
// 即生效 —— 不再要求「重启后端」。
//
// 为什么必须做(而不是把「重启后生效」写进面板文案):面板的意义就是「不改 `.env`、
// 不重启」,留着「重启后生效」等于把配置面做成摆设(裁决 #1 的理由原文)。design/09 §5.5
// 的端到端判据也正是「面板加一家 ⇒ 不改 `.env`、不重启 ⇒ 下一次搜索真的走它」。
//
// 三条口径:
//
// 1. **一次保存 = 一次事务**:写可变源 → 重建工具 → 替换注册表条目。任何一步失败都把
// 文件**回滚**到保存前那一份,绝不留「盘上是新的、内存是旧的」或「盘上的配置让后端
// 下次起不来」这两种半状态。
// 2. **重建走启动期同一条路**(`buildWebSearchTool` → `webSearchOptions`):重载后的工具
// 与启动期构造的逐字段同源,不是第二份接线逻辑(避免两处语义漂移)。
// 3. **替换是原子的、保留策略**:走 `registry.ReplaceTool` 而不是 Remove+Register
// (详见该方法注释:中间无空窗,且分组/陌生人可达标记不被重置)。
//
// 触发面只有一处:`wire_plugin_actions.go` 把**写可变源**的那个动作(`plugin.saveProvider`)
// 包上 `webSearchReloader.wrap`。动作本体(websearch_action_data.go 的 saveProvider)保持
// 「只负责把这条存进去」的单一职责,重载与回滚都在本文件。
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"sync"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// webSearchReloadLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。
const webSearchReloadLogSource = "cmd/cyrene/websearch_reload"
// actionCodeConfigUnstartable 报告「这次保存的结果会让后端**下一次启动直接失败**」——
// 文件已回滚,改动**没有**生效(406 / 409 这类「与当前状态冲突」的档,不是 500 内部错)。
const actionCodeConfigUnstartable = "config_unstartable"
// webSearchRollbackFileMode 是回滚写回时的文件权限:与 runtimeconfig 落盘可变源的 0600
// 同口径(那份文件带 API Key)。硬编码成 0600 而不是读对方的常量,是因为那个常量未导出,
// 而变化方向是「收紧」,不存在比 0600 更松的风险。
const webSearchRollbackFileMode os.FileMode = 0o600
// webSearchReloadOutcome 报告一次热重载的结局,并作为动作响应里可机读的事实。
type webSearchReloadOutcome struct {
// Applied 报告这次保存**真的触发了**热重载(false = 没有能力注册表可换,
// 例如测试夹具;此时只落了盘,运行中的工具没动)。
Applied bool `json:"applied"`
// Registered 报告保存之后 web-search **在不在模型的工具面里**(Applied 为 true
// 时才有意义):关掉主 provider / 端点会让它整把消失,这是设计语义,不是失败。
Registered bool `json:"registered"`
}
// webSearchReloader 把「保存可变源 → 重建工具 → 换注册表条目」做成一次事务。
//
// 锁的粒度是**整次保存**(含动作本体那一步):两个并发的面板保存如果交错,可能出现
// 「A 的写 + B 的重载」这种盘上与内存对不上的组合。面板保存是低频动作,串行化的代价
// 可以忽略,换来的是「最后一次保存定终局」这条可证的顺序。
type webSearchReloader struct {
mu sync.Mutex
app *app
path string
log *slog.Logger
}
// webSearchActionHandlersWithHotReload 在既有的动作处理器表之上,给**写可变源**的动作
// 套上热重载事务(其余动作不动:`testProvider` / `reauthProvider` 不改文件,无需重载)。
//
// 包在 `wireBuiltinPluginActions` 里调用(登记入口一处决定「谁会被热重载」),因此
// 动作名到处理器的映射关系在这里仍然可见,不额外引入第二张表。
func webSearchActionHandlersWithHotReload(
env webSearchActionEnv, a *app, log *slog.Logger,
) map[string]pluginmgr.ActionHandler {
handlers := webSearchActionHandlers(env)
r := &webSearchReloader{app: a, path: env.path, log: logger.WithSource(log, webSearchReloadLogSource)}
for _, action := range []string{search.ActionSaveProvider} {
if inner, ok := handlers[action]; ok && inner != nil {
handlers[action] = r.wrap(inner)
}
}
return handlers
}
// wrap 把一次「写可变源的保存」变成事务:
//
// ① 快照文件(回滚的凭据);
// ② 跑动作本体(它负责把改动落盘);
// ③ 按**落盘后的配置**重建工具并替换注册表条目;
// ④ ③ 失败 ⇒ 回滚文件(内存没动过,因此回滚后两处一致)并回结构化错误。
func (r *webSearchReloader) wrap(inner pluginmgr.ActionHandler) pluginmgr.ActionHandler {
return func(ctx context.Context, req pluginmgr.ActionRequest) (any, error) {
r.mu.Lock()
defer r.mu.Unlock()
snap, err := r.snapshot()
if err != nil {
return nil, pluginmgr.NewActionError(actionCodeConfigUnreadable,
"面板配置读不出来,本次**没有**改动它;详见服务端日志", http.StatusInternalServerError, err)
}
res, err := inner(ctx, req)
if err != nil {
return nil, err
}
outcome, rerr := r.reload()
if rerr == nil {
return withReloadOutcome(res, outcome), nil
}
if rberr := r.rollback(snap); rberr != nil {
// 回滚都失败了 ⇒ 只能如实说「盘上可能已经变了」,绝不假装改动没发生。
r.log.Error("web-search config rollback failed after an unstartable save",
"path", r.path, "reloadError", rerr, "rollbackError", rberr)
return nil, pluginmgr.NewActionError(actionCodeConfigWriteFailed,
"这次改动会让 web-search 起不来,且回滚也失败了:请人工检查 "+
r.path+"(详见服务端日志)", http.StatusInternalServerError, errors.Join(rerr, rberr))
}
r.log.Warn("web-search panel save rolled back: the resulting config cannot start",
"path", r.path, "error", rerr)
return nil, pluginmgr.NewActionError(actionCodeConfigUnstartable,
unstartableMessage(rerr), http.StatusConflict, rerr)
}
}
// reload 按落盘后的配置重建工具并替换注册表条目。
//
// 三种结局:换掉(工具该在)/摘掉(当前配置下它不该在注册面里,与启动期语义一致)/
// 报错(这份配置起不来 ⇒ 调用方回滚)。没有能力注册表时如实报告 Applied=false。
func (r *webSearchReloader) reload() (webSearchReloadOutcome, error) {
if r.app == nil || r.app.cfg == nil || r.app.caps == nil {
// 没有注册表可换(测试夹具 / 装配未完成):只落了盘,工具没动。
// 不静默当成「换过了」——否则响应里的 registered 会是一句假话。
r.log.Debug("web-search hot reload skipped: no capability registry",
"path", r.path, "effect", "the running tool keeps the startup wiring")
return webSearchReloadOutcome{}, nil
}
tool, err := r.app.buildWebSearchTool(webSearchCausePanelSave)
if err != nil {
return webSearchReloadOutcome{}, err
}
if tool == nil {
return webSearchReloadOutcome{Applied: true}, r.app.removeSearchTool(r.log)
}
if err := r.app.replaceSearchTool(tool); err != nil {
return webSearchReloadOutcome{}, err
}
r.log.Info("web-search tool hot-reloaded after panel save",
"path", r.path, "action", search.ActionSaveProvider,
"effect", "the next turn uses the new wiring; no restart needed")
return webSearchReloadOutcome{Applied: true, Registered: true}, nil
}
// replaceSearchTool 把重建出来的工具放进注册面:**已登记就就地替换**(原子、保留策略),
// 之前不在注册面里(例如上一次保存把它关了)就新增。
func (a *app) replaceSearchTool(t sdk.Tool) error {
err := a.caps.ReplaceTool(t)
if err == nil {
return nil
}
if !errors.Is(err, registry.ErrNotFound) {
return fmt.Errorf("web-search: replace tool: %w", err)
}
if rerr := a.caps.RegisterTool(t); rerr != nil {
return fmt.Errorf("web-search: register tool: %w", rerr)
}
return nil
}
// removeSearchTool 把 web-search 从注册面摘掉(当前配置下它不该在:端点/主 provider 被
// 显式关闭,或选定 provider 没配齐)。本来就不在时是幂等的。
func (a *app) removeSearchTool(log *slog.Logger) error {
err := a.caps.Remove(webSearchToolName)
if err != nil && !errors.Is(err, registry.ErrNotFound) {
return fmt.Errorf("web-search: remove tool: %w", err)
}
log.Info("web-search tool removed from the registry after panel save",
"action", search.ActionSaveProvider,
"effect", "the model no longer sees a web-search tool aimed at a closed provider")
return nil
}
// fileSnapshot 是可变源在**一次保存之前**的样子(回滚的凭据)。
//
// 存原始字节而不是解析后的结构:回滚的语义是「一字不差地回到那一份」。经结构往返
// (Load + Save)会丢掉人工写在文件里的、本包不认识的键 —— 那正是「损坏配置可人工救回」
// 场景里最不该丢的东西。
type fileSnapshot struct {
// exists 报告保存前文件在不在(不存在时回滚 = 把它删掉)。
exists bool
// raw 是保存前的原始内容(exists 为 false 时无意义)。
raw []byte
}
// snapshot 读一份保存前的快照。文件不存在 = 合法的「还没配过」状态,不是错误。
func (r *webSearchReloader) snapshot() (fileSnapshot, error) {
raw, err := os.ReadFile(r.path)
if err != nil {
if os.IsNotExist(err) {
return fileSnapshot{}, nil
}
return fileSnapshot{}, fmt.Errorf("web-search: snapshot %s: %w", r.path, err)
}
return fileSnapshot{exists: true, raw: raw}, nil
}
// rollback 把文件恢复成快照那一份(temp+rename,与 runtimeconfig 的落盘口径同源:
// 并发读者永远看不到半个文件)。
func (r *webSearchReloader) rollback(snap fileSnapshot) error {
if !snap.exists {
if err := os.Remove(r.path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("web-search: rollback remove %s: %w", r.path, err)
}
return nil
}
tmp := r.path + ".rollback.tmp"
if err := os.WriteFile(tmp, snap.raw, webSearchRollbackFileMode); err != nil {
return fmt.Errorf("web-search: rollback write %s: %w", tmp, err)
}
if err := os.Rename(tmp, r.path); err != nil {
return fmt.Errorf("web-search: rollback rename %s: %w", r.path, err)
}
return nil
}
// unstartableMessage 把「重建失败」的病因翻成**能照着改**的一句话:主 provider 未配齐
// 与名字未登记是两种不同的修法,笼统说一句「配置不可用」等于让用户自己猜。
func unstartableMessage(err error) string {
switch {
case errors.Is(err, search.ErrNotConfigured):
return "这次改动会让 web-search 没有可用的后端(主提供商未配齐:缺 API Key 或接口地址)," +
"后端下次启动会直接失败 ⇒ 已回滚,改动**没有**生效。请先给主提供商补齐凭据,或改选一家已配齐的。"
case errors.Is(err, search.ErrUnknownProvider):
return "这次改动指向了一个未登记的搜索提供商(名字写错了 / 本期没做这一家)," +
"后端下次启动会直接失败 ⇒ 已回滚,改动**没有**生效。"
default:
return "这次改动会让 web-search 起不来(后端下次启动会直接失败)⇒ 已回滚,改动**没有**生效;" +
"详见服务端日志。"
}
}
// withReloadOutcome 把热重载的结局挂到动作响应上(响应体是对象时追加一个 `hotReload`
// 字段;不是对象时原样返回 —— 宁可少一个字段,也不把结果包成另一种形状)。
func withReloadOutcome(res any, outcome webSearchReloadOutcome) any {
m, ok := res.(map[string]any)
if !ok {
return res
}
m["hotReload"] = outcome
return m
}