feat(plugins): 服务路径/适配器名冲突进裁决而非崩溃或硬拒绝(用户要求 A+B)

此前两类冲突各有问题:
A) 两个插件声明同一条 /api/plugins/http/ 服务路径 → 分发器按加载顺序静默
   选一个(装个新插件就可能改变既有路径归属)。
B) 插件装与内置/他插件同名的适配器 → 硬错误 ErrAdapterNameReserved / owned by。

用户要求:都改成「报冲突、列出候选、运维选一个保留、选择持久化」,绝不崩溃
或拒绝启动。

## A 类:插件 HTTP 服务路径冲突
- dispatch(plugin_http_routes.go)同路径多认领时不再 load-order 选边:未裁决
  返回 409 并点名候选;已裁决按赢家分发。
- route_conflicts.go:RouteConflicts/RouteWinner/ResolveRouteConflict,只统计
  loaded 且未禁用的插件;赢家插件若不再声明该路径,裁决作废回未裁决态。

## B 类:适配器名冲突
- InstallAdapter 撞名(内置或他插件所有)不再硬拒,改挂起:现任继续服务,候选
  入 AdapterConflicts,返回 typed ErrAdapterConflictPending(区分「代码错」)。
- ResolveAdapterConflict:incumbent 保留现任 / candidate 真正装上插件版(绕过
  reserved 门禁——这是运维显式授权覆盖内置的唯一入口)并转移归属。
- 现任内置永不被静默覆盖,守住「第三方插件不得篡改内核发行物」信任边界。

## 持久化 + WebAPI + WebUI
- 两张裁决表落 .route-conflicts.json(routes + adapters),LoadDir 时读回;
  重启后插件再装同名适配器自动套用上次裁决。
- WebAPI:GET /api/plugins/conflicts 列冲突;POST {kind,path|name,winner} 裁决。
- WebUI:插件页顶部「冲突裁决」卡片,逐条列路径/适配器冲突 + 候选 + 裁决按钮。

## 测试
- 单测 conflict_arbitration_test.go 6 项全过:路径冲突检测/裁决/持久化/重启、
  赢家消失回退、适配器撞名挂起/incumbent/candidate/归属转移、非法 winner 拒绝、
  自身更新不受影响(token 轮换不走冲突路径)。
- e2e conflict_arbitration_e2e_test.go 2 项全过(真二进制):两插件抢路径→409
  点名→裁决→200 赢家应答→/v1/models 无回归;插件装 shadow openai→挂起→现任
  保留→裁决 candidate→插件版生效→冲突清空。
- 全量 build + vet 通过,既有 lua/gateway/core 回归无破坏。

修正过程中发现并修了一个真 bug:LoadAdapterSource 对 key≠declared 的适配器
重装会建第二个池、留旧池继续服务(v2 装了 v1 还在跑)——改成统一按注册键
归档,裁决 candidate 后新版本才真正生效。
This commit is contained in:
JianFeeeee
2026-10-12 12:32:10 +08:00
parent 6b6f9526ff
commit 5948ad19ca
9 changed files with 1130 additions and 28 deletions

View File

@ -841,12 +841,35 @@ func (v *VM) LoadAdapterSource(name, code string) error {
// it controls while shipping an adapter that declares the name sources
// reference). Without this, updating that adapter would install a second
// pool and leave the old one serving traffic.
//
// The key can live under EITHER name (this is the "reload replaces the same
// pool entry rather than silently registering a second one" rule):
//
// - pools[static.name] — the pool filed under the DECLARED name (the
// common case, and the only case when a plugin installs under a key that
// matches its script's own name), or
// The pool registry is keyed by the DECLARED name (static.name), always:
// first install files pools[static.name], and a reload replaces that same
// slot. registeredAs only RECORDS the registration key for reverse lookups
// (EffectiveAdapterName scans for it); it is not a second key space.
//
// An earlier version redirected the key to prev.static.registeredAs here,
// assuming pools were filed under the registration key — but first installs
// file under the declared name, so that redirect created a SECOND pool and
// left the old one serving whenever key≠declared (a real bug: a v2 install
// via arbitration left v1 serving, and UpdateAdapter had the same hole).
key := static.name
if prev, ok := v.pools[key]; ok && prev.static.registeredAs != "" {
key = prev.static.registeredAs
}
old := v.pools[key]
p := newAdapterPool(static.name, code, *static)
// The new pool MUST be filed under the SAME slot as the old one (key), and
// named after that slot, so a later lookup by either the DECLARED name or
// the REGISTRATION KEY finds the one live pool. Naming it after static.name
// would move the pool to the declared-name slot when the incumbent lived
// under the registration key, leaving the registration-key slot pointing at
// the stale (old) pool that shutdown() then tears down — callers reading by
// the registration key see "adapter missing", callers by declared name see
// a pool that isn't the one serving. key is the stable identity; the
// declared name is reachable through static.name.
p := newAdapterPool(key, code, *static)
p.setMax(maxW)
// Carry the revision forward so the new source keeps counting ABOVE the old
// one. A cache keyed on (vm, revision, name) must see a value different