From 89db5446717a4c883184db05413486a2c2480ad0 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 13 Sep 2026 13:40:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(plugin):=20"=E5=8F=AA=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E5=87=BA=E7=AB=99=E9=80=9A=E9=81=93"=E7=9A=84=E5=91=8A?= =?UTF-8?q?=E8=AD=A6=E6=94=B9=E4=B8=BA=E6=8F=92=E4=BB=B6=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=90=8E=E5=88=A4=E5=AE=9A=EF=BC=88=E6=AD=A4?= =?UTF-8?q?=E5=89=8D=E6=8C=89=E6=B3=A8=E5=86=8C=E9=A1=BA=E5=BA=8F=E8=AF=AF?= =?UTF-8?q?=E6=8A=A5=20qq=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象 生产日志(v1.3.1 启动)出现: `[plugin] qq 只声明了输出通道 "qq",已按双向通道兜底登记 inputch;若要明确意图请显式 RegisterInputChannel` 用户据此问"qq 插件你没更新?" ## 查证:qq 没漏,是我的判据错了 - SDK 示例 `example/qq/plugin.go`:`RegisterOutputChannel("qq")` 在 368 行、 `RegisterInputChannel("qq", {NoMemory:true, Cleaner: inputCleaner})` 在 399 行 —— **先出站后入站**; - 生产 `plugins/qq/plugin.bin`:版本 1.4.0,且二进制里含 `inputCleaner` 痕迹 ⇒ 确实调用了入站声明; - 我的兜底告警在 **RegisterOutputChannel 的那一刻**判"有没有入站声明" ⇒ 对"先出站后入站" 这种完全合法的写法必然误报(a2a/acp/weather 同理)。 ## 修法 告警判据从"注册时刻"改为"**插件 Start 结束后最终声明了什么**": - `regOutput` 只保留兜底登记(功能不变),不再告警; - 新增 `warnOutputOnlyChannels(plugin)`,在插件加载/重载成功后统一判定: 遍历该插件**最终**声明过的出站通道,只有始终没有对应入站声明的才告警, 且措辞改为"内核已兜底登记 inputch,若这是有意为之可忽略"。 - 判据与顺序解耦后,告警才代表真实缺口(例:weather 的 `weather_out` 与 `weather_in` 名字不同,出站名从未被声明为入站 —— 那条告警就是真的)。 ## 验证 - 新增 `TestWarnOutputOnlyChannels`:①先出站后入站(qq 写法)**不告警**; ②只声明出站(weather 写法)**告警且只报那一个通道**。 - `go test ./internal/plugin/ ./internal/plugins/...` 全绿。 --- internal/plugin/channel_warn_test.go | 43 ++++++++++++++++++++++++++++ internal/plugin/registry.go | 38 +++++++++++++++++++++--- 2 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 internal/plugin/channel_warn_test.go diff --git a/internal/plugin/channel_warn_test.go b/internal/plugin/channel_warn_test.go new file mode 100644 index 0000000..b43c542 --- /dev/null +++ b/internal/plugin/channel_warn_test.go @@ -0,0 +1,43 @@ +package plugin + +import ( + "bytes" + "log" + "strings" + "testing" +) + +// 延迟判定的语义:看的是"插件 Start 结束后最终声明了什么", +// 而不是"注册出站通道的那一刻有没有入站声明"。 +// +// 为什么必须这样判:声明顺序自由 —— qq/weather 都是**先** RegisterOutputChannel +// **后** RegisterInputChannel,按注册时刻判会把它们误报成"只声明了输出通道" +// (实测发生过:用户据此以为 qq 插件没更新)。 +func TestWarnOutputOnlyChannels(t *testing.T) { + r := NewRegistry() + var buf bytes.Buffer + oldOut := log.Writer() + log.SetOutput(&buf) + defer log.SetOutput(oldOut) + + // ① 出站+入站都声明了(先出站后入站)⇒ 不该告警 + r.noteChannel("qq", "qq", true) + r.noteChannel("qq", "qq", false) + r.warnOutputOnlyChannels("qq") + if s := buf.String(); s != "" { + t.Fatalf("qq 声明了入站通道,不应告警,实际: %s", s) + } + + // ② 只声明出站 ⇒ 应告警,且只报这一个通道 + buf.Reset() + r.noteChannel("weather", "weather_weather_out", true) + r.noteChannel("weather", "weather_weather_in", false) + r.warnOutputOnlyChannels("weather") + out := buf.String() + if !strings.Contains(out, "weather_weather_out") { + t.Fatalf("只声明出站的通道应被告警,实际: %q", out) + } + if strings.Contains(out, "weather_weather_in") { + t.Fatalf("已声明入站的通道不该被牵连,实际: %q", out) + } +} diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index 2a39c17..f8aa71d 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -307,12 +307,15 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK { // 但历史插件常常只用 RegisterOutputChannel 声明(却用同一个名字注入输入, // 例:cli 只声明输出 "cli" 就用 InjectTextSync("cli", ...) 注入)。 // 不兜底的话 inputch 登记表里没有它,"把 inputch 划给驻留子"直接失败 - // (实测报 `划入 inputch cli: inputch 未注册`)。兜底要**留痕**, - // 否则插件作者永远不知道该补一行 RegisterInputChannel。 + // (实测报 `划入 inputch cli: inputch 未注册`)。 + // + // ❗这里**不能**判"是否声明过入站通道"并告警:声明顺序是自由的, + // 先 RegisterOutputChannel 再 RegisterInputChannel 是常见写法(qq 就是), + // 按此刻的状态判会对它误报(实测:把 qq 报成"只声明了输出通道")。 + // 真正该问的问题是"插件 Start 结束后,这个出站通道有没有对应的入站声明" —— + // 那在 load 完成后统一判(见 warnOutputOnlyChannels)。 if _, ok := r.iom.LookupInputChannel(chName); !ok { _ = r.iom.RegisterInputChannelFrom(name, chName, agentIO.ChannelDef(def)) - log.Printf("[plugin] %s 只声明了输出通道 %q,已按双向通道兜底登记 inputch;"+ - "若要明确意图请显式 RegisterInputChannel", name, chName) } r.noteChannel(name, chName, true) return nil @@ -452,6 +455,7 @@ func (r *Registry) Load(dir string) error { r.pluginAutoRestart[name] = plgSDK.AutoRestart() r.instances = append(r.instances, p) r.mu.Unlock() + r.warnOutputOnlyChannels(name) log.Printf("[plugin] loaded: %s", name) } @@ -545,6 +549,7 @@ func (r *Registry) loadOne(plgDir, name string) bool { r.pluginAutoRestart[name] = plgSDK.AutoRestart() r.sdkRefs[name] = plgSDK r.instances = append(r.instances, plg) + r.warnOutputOnlyChannels(name) if h := pluginEntryHash(plgDir); h != "" { r.pluginHashes[name] = h } else { @@ -579,6 +584,31 @@ func (r *Registry) stageRegistrarFor() (func(plugin string, stage sdk.Stage, han return nil, false } +// warnOutputOnlyChannels 在插件 Start 结束后,报告"只声明了出站、没有入站声明"的通道。 +// +// 为什么放在 Start 之后:声明顺序自由(先出站后入站很常见),注册时刻的状态 +// 判不出意图。这里看的是**插件最终声明了什么**,因此不会误报 qq 这种写法。 +// +// 注:这类通道内核已兜底登记 inputch(功能可用),告警只是提醒插件作者把意图写明。 +func (r *Registry) warnOutputOnlyChannels(plugin string) { + r.channelsMu.Lock() + set := r.pluginChannels[plugin] + var only []string + if set != nil { + for ch := range set.outputs { + if !set.inputs[ch] { + only = append(only, ch) + } + } + } + r.channelsMu.Unlock() + sort.Strings(only) + for _, ch := range only { + log.Printf("[plugin] %s 只声明了出站通道 %q(未 RegisterInputChannel);"+ + "内核已兜底登记 inputch,若这是有意为之可忽略", plugin, ch) + } +} + // noteChannel 记住插件注册了哪个通道,供卸载/崩溃时摘除。 // forgetChannel 把某个通道从"本插件注册过哪些通道"的记账里摘掉(注销通道时用)。 //