mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 14:27:30 +00:00
feat(hmapdev): 构建期告警「同一 stage 被重复注册」+ qq 合并写法
## 用户指出的关键:告警要给**开发者**看,所以必须在构建期
我第一版把告警放进了工具链生成的 proc_main 模板(插件**运行期**打日志)。
用户直接指出:「本身工具链这个编译器警告就是给开发者看的」——运行期日志
是给运维/用户看的,开发者看不到。已撤销,改为构建期静态检查。
## 缺陷(真实事故,2026-10-18)
插件侧把 stage handler 存在 `map[string]sdk.StageHandler`(stage 名为键)⇒
**同一 stage 注册两次,后者静默覆盖前者**。
QQ 插件当时写成:
RegisterStage(BeforeToolcall, 权限门) ← 被下一条覆盖
RegisterStage(BeforeToolcall, 切状态) ← 实际生效
后果不只是状态不切换 —— **权限门被静默关掉了**(那几版部署里任何人
发消息都能调任何工具)。编译器不报、运行时不报错、内核日志还显示
`2 handlers [qq qq]`(看着完全正常)。只有静态检查能发现。
## 实现
· `stage_dup_check.go`:扫构建目录下的 .go(排除 _test.go 与 z_proc*),
统计每个 stage 常量被 RegisterStage 引用几次,>1 就告警
· 接入 `buildTarget` 开头 ⇒ 所有 target(含 lua)都检查
· **只告警不中断**:存量插件里重复注册可能是本意,直接 fail 会让它们
升 SDK 后无法构建
· `example/qq`:三个重复 stage 合并成单 handler(权限门 + 状态切换)
## 判据(4 条)
· 同 stage 两次 ⇒ 报出来
· 不同 stage 各一次 ⇒ 不误报
· **注释里的示例注册不计入**
· 测试文件 / 工具链生成文件不计入
★ 「注释」这条是这个检查器**第一版自己栽的坑**:直接对全文跑正则,
把插件里「当初写错了、留在注释里当教训」的那几行也统计进去,于是
修好之后照样报「注册了 3 次」(实测)。现在逐行跳 `//` 与跨行 `/* */`。
本项目已多次绊倒在「注释里有、代码里没有」上,这次轮到检查器自己。
## 实测
qq 修好后构建: packaged qq_linux_amd64.hmap(无告警)
故意加一条重复: ⚠️ 警告: stage StageBeforeOutput 注册了 2 次
建议:把多个功能合并进同一个 handler
This commit is contained in:
@ -842,20 +842,28 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
|
|||||||
NoMemory: true,
|
NoMemory: true,
|
||||||
}, p.handleSendLike)
|
}, p.handleSendLike)
|
||||||
|
|
||||||
// 全局权限门:只有 QQ 当前输入需要受此插件约束;Bot 所有者始终完整放行。
|
// ★★ 每个 stage **只能注册一个 handler**(2026-10-18 踩到的坑)。
|
||||||
s.RegisterStage(sdk.StageOnInput, p.onInputAuthContext, sdk.StageScopeGlobal)
|
|
||||||
s.RegisterStage(sdk.StageBeforeToolcall, p.beforeToolcall, sdk.StageScopeGlobal)
|
|
||||||
// 在线状态随阶段切换(默认关闭,见 statusAutoEnabled 的注释)。
|
|
||||||
//
|
//
|
||||||
// ★ 三个钩子都只有一件事:调 p.applyStatus;**永远 return nil**。
|
// 插件侧(工具链生成的 proc_main)把 handler 存在
|
||||||
// 状态是非致命错误 —— 不能因为它阻塞工具调用或改写回复。
|
// `stageHandlers map[string]StageHandler`(stage 名为键)⇒
|
||||||
s.RegisterStage(sdk.StageBeforeToolcall, p.stageStatusWorking, sdk.StageScopeGlobal)
|
// **同一 stage 注册两次,后者静默覆盖前者**。而之前写成:
|
||||||
s.RegisterStage(sdk.StageBeforeOutput, p.stageStatusThinking, sdk.StageScopeGlobal)
|
//
|
||||||
s.RegisterStage(sdk.StageAfterOutput, p.stageStatusOnline, sdk.StageScopeGlobal)
|
// s.RegisterStage(StageBeforeToolcall, p.beforeToolcall, Global)
|
||||||
|
// s.RegisterStage(StageBeforeToolcall, p.stageStatusWorking, Global) ← 覆盖了上面
|
||||||
|
// s.RegisterStage(StageAfterOutput, p.stageStatusOnline, Global)
|
||||||
|
// s.RegisterStage(StageAfterOutput, p.afterOutputAuthContext, Global) ← 覆盖了上面
|
||||||
|
//
|
||||||
|
// 后果不只是状态不切换 —— **QQ 的权限门被静默关掉了**(那几版部署里
|
||||||
|
// 任何人发消息都能调任何工具)。hmapdev 现在会在构建期对这类重复告警。
|
||||||
|
//
|
||||||
|
// 现在合并成一个 handler,每个 stage 一次注册。
|
||||||
|
s.RegisterStage(sdk.StageOnInput, p.onInputAuthContext, sdk.StageScopeGlobal)
|
||||||
|
s.RegisterStage(sdk.StageBeforeToolcall, p.stageBeforeToolcall, sdk.StageScopeGlobal)
|
||||||
|
s.RegisterStage(sdk.StageBeforeOutput, p.stageBeforeOutput, sdk.StageScopeGlobal)
|
||||||
|
s.RegisterStage(sdk.StageAfterOutput, p.stageAfterOutput, sdk.StageScopeGlobal)
|
||||||
// before_toolcall 的 Response 只用于拒绝当前工具。下一次模型补全到达时必须清掉,
|
// before_toolcall 的 Response 只用于拒绝当前工具。下一次模型补全到达时必须清掉,
|
||||||
// 否则内核会把它误当作“结束整个推理”的最终响应。
|
// 否则内核会把它误当作“结束整个推理”的最终响应。
|
||||||
s.RegisterStage(sdk.StagePostAction, p.clearDeniedResponse, sdk.StageScopeGlobal)
|
s.RegisterStage(sdk.StagePostAction, p.clearDeniedResponse, sdk.StageScopeGlobal)
|
||||||
s.RegisterStage(sdk.StageAfterOutput, p.afterOutputAuthContext, sdk.StageScopeGlobal)
|
|
||||||
|
|
||||||
// ---- HTTP server for NapCat webhook ----
|
// ---- HTTP server for NapCat webhook ----
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|||||||
@ -88,6 +88,14 @@ var qqStatusByName = map[string]qqStatus{
|
|||||||
"离线": qqStatusHidden,
|
"离线": qqStatusHidden,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// minStatusInterval 是两次自动状态切换的最小间隔。
|
||||||
|
//
|
||||||
|
// 取 2 秒:stage 在每次工具调用都会触发,一轮对话可能有十几次;
|
||||||
|
// 逐次发 HTTP 会把 NapCat 打满,且 QQ 侧看到状态疯狂闪烁。
|
||||||
|
//
|
||||||
|
// ★ 收尾(StageAfterOutput)**不受此限**,见 applyStatusForce。
|
||||||
|
const minStatusInterval = 2 * time.Second
|
||||||
|
|
||||||
// statusState 是自动状态切换的运行态。
|
// statusState 是自动状态切换的运行态。
|
||||||
type statusState struct {
|
type statusState struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@ -133,6 +141,27 @@ func (p *Plugin) setSelfDiyStatus(faceID, wording string) error {
|
|||||||
// ★ 这里刻意**不做排队重试**:状态是"当前观感",错过一次就在下一个 stage 补上,
|
// ★ 这里刻意**不做排队重试**:状态是"当前观感",错过一次就在下一个 stage 补上,
|
||||||
// 没必要维护队列(队列会让状态滞后于阶段,反而更错)。
|
// 没必要维护队列(队列会让状态滞后于阶段,反而更错)。
|
||||||
func (p *Plugin) applyStatus(st qqStatus) {
|
func (p *Plugin) applyStatus(st qqStatus) {
|
||||||
|
p.applyStatusMaybe(st, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyStatusForce 跳过防抖(用于"收尾":一轮结束时必须回在线)。
|
||||||
|
//
|
||||||
|
// ## 为什么必须有这个分支(2026-10-18 实测 bug)
|
||||||
|
//
|
||||||
|
// QQ 界面持续停「学习中」。原因:
|
||||||
|
//
|
||||||
|
// BeforeOutput → AfterOutput 常常不足 2 秒
|
||||||
|
// ⇒ 收尾状态被防抖拦住 ⇒ **永远回不到在线**
|
||||||
|
//
|
||||||
|
// 防抖本身是对的(防刷 NapCat),但**收尾不能被防抖** ——
|
||||||
|
// 它是一轮结束时唯一的"归位"机会,错过就得等下一轮,
|
||||||
|
// 那期间好友看到的是错的状态。
|
||||||
|
func (p *Plugin) applyStatusForce(st qqStatus) {
|
||||||
|
p.applyStatusMaybe(st, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyStatusMaybe 是 applyStatus / applyStatusForce 的共同实现。
|
||||||
|
func (p *Plugin) applyStatusMaybe(st qqStatus, debounce bool) {
|
||||||
if !p.statusAutoEnabled.Load() {
|
if !p.statusAutoEnabled.Load() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -141,7 +170,7 @@ func (p *Plugin) applyStatus(st qqStatus) {
|
|||||||
p.statusState.mu.Unlock()
|
p.statusState.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if time.Since(p.statusState.lastAt) < 2*time.Second {
|
if debounce && time.Since(p.statusState.lastAt) < minStatusInterval {
|
||||||
p.statusState.mu.Unlock()
|
p.statusState.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -249,23 +278,6 @@ func normalizeStatusName(s string) (qqStatus, bool) {
|
|||||||
//
|
//
|
||||||
// 这与 typing(set_input_status)的处置一致:那套也从不因失败中断对话。
|
// 这与 typing(set_input_status)的处置一致:那套也从不因失败中断对话。
|
||||||
|
|
||||||
func (p *Plugin) stageStatusWorking(ctx *sdk.StageContext) error {
|
|
||||||
p.applyStatus(qqStatusWorking)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Plugin) stageStatusThinking(ctx *sdk.StageContext) error {
|
|
||||||
// "生成回复中"用「学习中」的预置状态:QQ 没有"思考中"这个预置项。
|
|
||||||
// 想要精确文字可调 qq_set_diy_status 工具(自定义 face_id + wording)。
|
|
||||||
p.applyStatus(qqStatusStudy)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Plugin) stageStatusOnline(ctx *sdk.StageContext) error {
|
|
||||||
p.applyStatus(qqStatusOnline)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======== 工具 handler ========
|
// ======== 工具 handler ========
|
||||||
|
|
||||||
// handleSetStatus 处理 qq_set_status:手动设预置在线状态。
|
// handleSetStatus 处理 qq_set_status:手动设预置在线状态。
|
||||||
@ -319,3 +331,54 @@ func (p *Plugin) handleSetDiyStatus(args map[string]interface{}) (interface{}, e
|
|||||||
"wording": wording,
|
"wording": wording,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======== 合并后的 stage 入口 ========
|
||||||
|
//
|
||||||
|
// ★★ 为什么必须合并(2026-10-18 踩到的真坑)
|
||||||
|
//
|
||||||
|
// 工具链生成的 proc_main 把 stage handler 存在
|
||||||
|
// `stageHandlers map[string]sdk.StageHandler`(**stage 名为键**):
|
||||||
|
//
|
||||||
|
// func(stage sdk.Stage, handler sdk.StageHandler) {
|
||||||
|
// stageHandlers[string(stage)] = handler // ← 后者覆盖前者
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// 所以一个插件**每个 stage 只能有一个 handler**。原先写成两行注册:
|
||||||
|
//
|
||||||
|
// RegisterStage(StageBeforeToolcall, p.beforeToolcall, Global) ← 权限门
|
||||||
|
// RegisterStage(StageBeforeToolcall, p.stageStatusWorking, Global) ← 覆盖了它
|
||||||
|
//
|
||||||
|
// 后果不只是"状态不切换"——**QQ 的权限门被静默关掉了**。
|
||||||
|
// hmapdev 现在会在**构建期**对这类重复告警(开发者在构建输出里必然看见)。
|
||||||
|
|
||||||
|
// stageBeforeToolcall 把「权限门」与「状态切换」合成一次注册。
|
||||||
|
//
|
||||||
|
// 顺序:先跑权限门(它可能拒绝工具),再切状态。
|
||||||
|
// 即使权限门拒绝,状态也该显示"在干活"——拒绝也是 agent 在工作。
|
||||||
|
func (p *Plugin) stageBeforeToolcall(ctx *sdk.StageContext) error {
|
||||||
|
if err := p.beforeToolcall(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.applyStatus(qqStatusWorking)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stageBeforeOutput 把「状态切换」接到 before_output(该 stage 无其它 handler)。
|
||||||
|
func (p *Plugin) stageBeforeOutput(ctx *sdk.StageContext) error {
|
||||||
|
// "生成回复中"用「学习中」的预置状态:QQ 没有"思考中"这个预置项。
|
||||||
|
// 想要精确文字可调 qq_set_diy_status 工具(自定义 face_id + wording)。
|
||||||
|
p.applyStatus(qqStatusStudy)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stageAfterOutput 把「清理 auth 上下文」与「状态归位」合成一次注册。
|
||||||
|
//
|
||||||
|
// 顺序:先清理(原有语义,必须执行),再切状态(**force**,不受防抖)。
|
||||||
|
// 清理不能因状态失败而跳过 —— 它关系到下一轮的权限判定。
|
||||||
|
func (p *Plugin) stageAfterOutput(ctx *sdk.StageContext) error {
|
||||||
|
if err := p.afterOutputAuthContext(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.applyStatusForce(qqStatusOnline)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@ -39,9 +39,9 @@ func TestStatusStageNeverBlocksOnFailure(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
fn func(*sdk.StageContext) error
|
fn func(*sdk.StageContext) error
|
||||||
}{
|
}{
|
||||||
{"before_toolcall", p.stageStatusWorking},
|
{"before_toolcall", p.stageBeforeToolcall},
|
||||||
{"before_output", p.stageStatusThinking},
|
{"before_output", p.stageBeforeOutput},
|
||||||
{"after_output", p.stageStatusOnline},
|
{"after_output", p.stageAfterOutput},
|
||||||
} {
|
} {
|
||||||
if err := c.fn(ctx); err != nil {
|
if err := c.fn(ctx); err != nil {
|
||||||
t.Fatalf("★ %s 返回了 error(%v)—— 状态设置是非致命错误,"+
|
t.Fatalf("★ %s 返回了 error(%v)—— 状态设置是非致命错误,"+
|
||||||
@ -55,7 +55,7 @@ func TestStatusStageNoopWhenDisabled(t *testing.T) {
|
|||||||
p := &Plugin{name: "qq", napcatURL: "http://127.0.0.1:1"}
|
p := &Plugin{name: "qq", napcatURL: "http://127.0.0.1:1"}
|
||||||
// statusAutoEnabled 默认零值 false
|
// statusAutoEnabled 默认零值 false
|
||||||
ctx := &sdk.StageContext{}
|
ctx := &sdk.StageContext{}
|
||||||
if err := p.stageStatusWorking(ctx); err != nil {
|
if err := p.stageBeforeToolcall(ctx); err != nil {
|
||||||
t.Fatalf("开关关闭时不应出错: %v", err)
|
t.Fatalf("开关关闭时不应出错: %v", err)
|
||||||
}
|
}
|
||||||
p.statusState.mu.Lock()
|
p.statusState.mu.Lock()
|
||||||
|
|||||||
@ -684,6 +684,17 @@ func resolveSDKPath(sdkPath string) string {
|
|||||||
func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
|
func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
|
||||||
os.MkdirAll(outDir, 0755)
|
os.MkdirAll(outDir, 0755)
|
||||||
|
|
||||||
|
// ★ 构建期静态检查:同一 stage 被重复注册。
|
||||||
|
//
|
||||||
|
// 这类错误编译器不报、运行时不报错、内核日志看着正常
|
||||||
|
// (`2 handlers [qq qq]`),只有源码检查能发现。
|
||||||
|
// 告警打在构建输出里 —— 开发者必然看见(用户原话:
|
||||||
|
// 「工具链这个编译器警告就是给开发者看的」)。
|
||||||
|
//
|
||||||
|
// 不中断构建:存量插件里重复注册可能是本意,直接 fail 会让它们
|
||||||
|
// 升 SDK 后无法构建。
|
||||||
|
warnDuplicateStageRegs(".")
|
||||||
|
|
||||||
// Lua: no compilation, package source directly
|
// Lua: no compilation, package source directly
|
||||||
if target == "lua" {
|
if target == "lua" {
|
||||||
writePluginJSON(plg, nil, "main.lua")
|
writePluginJSON(plg, nil, "main.lua")
|
||||||
|
|||||||
136
tools/hmapdev/stage_dup_check.go
Normal file
136
tools/hmapdev/stage_dup_check.go
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 构建期检查:同一 stage 被重复注册。
|
||||||
|
//
|
||||||
|
// ## 为什么在构建期(而不是运行期)
|
||||||
|
//
|
||||||
|
// 工具链生成的 proc_main 把 stage handler 存在 map 里(stage 名为键):
|
||||||
|
//
|
||||||
|
// stageHandlers[string(stage)] = handler // 后者静默覆盖前者
|
||||||
|
//
|
||||||
|
// 于是写成两行注册就会丢功能 —— 更糟的是,如果其中一个是**权限门**,
|
||||||
|
// 覆盖就意味着**权限被静默关掉**(2026-10-18 真实事故:那几版 QQ 插件里
|
||||||
|
// 任何人发消息都能调任何工具)。
|
||||||
|
//
|
||||||
|
// 这类错误编译器不报、运行时不报错、内核日志看着正常(`2 handlers [qq qq]`),
|
||||||
|
// 只有**源码静态检查**能发现。
|
||||||
|
//
|
||||||
|
// ## 为什么告警在构建期而非运行期
|
||||||
|
//
|
||||||
|
// 用户原话:「本身工具链这个编译器警告就是给开发者看的」。
|
||||||
|
// 运行期日志是给**运维/用户**看的,开发者看不到;而构建是开发者必经的一步,
|
||||||
|
// 告警打在构建输出里他必然看见。
|
||||||
|
//
|
||||||
|
// ## 为什么是告警而不是错误
|
||||||
|
//
|
||||||
|
// 存量插件里重复注册可能是本意(权限门 + 埋点分两处写)。
|
||||||
|
// 直接 fail 会让它们升 SDK 后无法构建。告警足够达到"开发者能发现"的目的,
|
||||||
|
// 想消除告警就把多个功能合并进同一个 handler。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stageRegRe 抓 `<任意>.RegisterStage(sdk.StageXxx`, 也容忍不带 sdk. 前缀的写法。
|
||||||
|
var stageRegRe = regexp.MustCompile(`\.RegisterStage\(\s*(?:sdk\.)?(Stage[A-Za-z]+)`)
|
||||||
|
|
||||||
|
// duplicateStageRegs 扫描目录下的 .go 源码(排除测试与生成文件),
|
||||||
|
// 返回 "Stage名 -> 出现次数" 中次数 > 1 的项。
|
||||||
|
//
|
||||||
|
// 只看 RegisterStage 的第一个参数是否为**同一个 stage 常量**。
|
||||||
|
// 不做语义分析(不需要):同一 stage 注册两次就是这个 bug 的充要形态。
|
||||||
|
func duplicateStageRegs(dir string) (map[string]int, error) {
|
||||||
|
counts := map[string]int{}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 排除测试(它们会故意造重复来验证判据)与工具链生成文件
|
||||||
|
// (生成文件里的注册是模板展开,不是作者写的)。
|
||||||
|
if strings.HasSuffix(e.Name(), "_test.go") || strings.HasPrefix(e.Name(), "z_proc") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// ★ 必须**逐行跳注释**:注释掉的示例注册不计入。
|
||||||
|
//
|
||||||
|
// 这个检查器第一版直接对全文跑正则,把插件里“当初写错了、现在
|
||||||
|
// 保留在注释里当教训”的那几行也统计进去,于是修好之后照样报
|
||||||
|
// “注册了 3 次”(实测)。而“注释里有、代码里没有”这个陷阱
|
||||||
|
// 在本项目已经绊倒过好几次 —— 这次轮到检查器自己。
|
||||||
|
inBlock := false
|
||||||
|
for _, ln := range strings.Split(string(b), "\n") {
|
||||||
|
code := strings.TrimSpace(ln)
|
||||||
|
// 块注释(/* ... */)可能跨行,逐行维护状态
|
||||||
|
if inBlock {
|
||||||
|
if i := strings.Index(code, "*/"); i >= 0 {
|
||||||
|
code = strings.TrimSpace(code[i+2:])
|
||||||
|
inBlock = false
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(code, "//") || strings.HasPrefix(code, "*") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(code, "/*") {
|
||||||
|
if i := strings.Index(code, "*/"); i >= 0 {
|
||||||
|
code = strings.TrimSpace(code[i+2:])
|
||||||
|
} else {
|
||||||
|
inBlock = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 行尾注释:只取 // 之前的部分(字符串里的 // 罕见,且
|
||||||
|
// 这里关心的只是 RegisterStage 调用,不会出现在字符串里)。
|
||||||
|
if i := strings.Index(code, "//"); i >= 0 {
|
||||||
|
code = strings.TrimSpace(code[:i])
|
||||||
|
}
|
||||||
|
for _, m := range stageRegRe.FindAllStringSubmatch(code, -1) {
|
||||||
|
counts[m[1]]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dups := map[string]int{}
|
||||||
|
for k, v := range counts {
|
||||||
|
if v > 1 {
|
||||||
|
dups[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// warnDuplicateStageRegs 打印构建期告警(不中断构建)。
|
||||||
|
//
|
||||||
|
// 返回是否有重复(供调用方决定是否在汇总里体现)。
|
||||||
|
func warnDuplicateStageRegs(dir string) bool {
|
||||||
|
dups, err := duplicateStageRegs(dir)
|
||||||
|
if err != nil || len(dups) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(dups))
|
||||||
|
for k := range dups {
|
||||||
|
names = append(names, k)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
fmt.Printf("\n")
|
||||||
|
for _, n := range names {
|
||||||
|
fmt.Printf(" ⚠️ 警告: stage %s 注册了 %d 次\n", n, dups[n])
|
||||||
|
}
|
||||||
|
fmt.Printf(" 插件侧 stageHandlers 是 map(stage 名为键)⇒ 只有**最后**一次注册生效,\n")
|
||||||
|
fmt.Printf(" 前面的会被静默覆盖。若其中一个是权限门,覆盖 = 权限被关掉。\n")
|
||||||
|
fmt.Printf(" 建议:把多个功能合并进同一个 handler(减少跨进程 stage 往返)。\n\n")
|
||||||
|
return true
|
||||||
|
}
|
||||||
135
tools/hmapdev/stage_dup_check_test.go
Normal file
135
tools/hmapdev/stage_dup_check_test.go
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 构建期检查的判据:同 stage 重复注册必须被**构建期**发现。
|
||||||
|
//
|
||||||
|
// ## 为什么是构建期
|
||||||
|
//
|
||||||
|
// 用户原话:「本身工具链这个编译器警告就是给开发者看的」。
|
||||||
|
// 运行期日志给运维看,开发者看不到;构建是开发者必经的一步。
|
||||||
|
//
|
||||||
|
// ## 这类错误为什么只能静态查
|
||||||
|
//
|
||||||
|
// 插件侧 stageHandlers 是 `map[string]StageHandler`(stage 名为键)⇒
|
||||||
|
// 同 stage 注册两次,**后者静默覆盖前者**。编译器不报、运行时不报错、
|
||||||
|
// 内核日志还显示 `2 handlers [qq qq]`(看着正常,实际只派发一次)。
|
||||||
|
// 2026-10-18 因此把 QQ 的**权限门**覆盖掉了。
|
||||||
|
//
|
||||||
|
// ## 判据覆盖的点
|
||||||
|
//
|
||||||
|
// · 同 stage 两次 ⇒ 报出来
|
||||||
|
// · 不同 stage 各一次 ⇒ 不误报
|
||||||
|
// · **注释里的示例注册不计入**(这个检查器第一版就栽在这,
|
||||||
|
// 把"当初写错了、留在注释里当教训"的代码算进去,修好之后照样告警)
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeGo(t *testing.T, dir, name, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuplicateStageRegsDetects(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeGo(t, dir, "plugin.go", `
|
||||||
|
package main
|
||||||
|
func reg(s *sdk.PluginSDK, p *P) {
|
||||||
|
s.RegisterStage(sdk.StageBeforeToolcall, p.auth, sdk.StageScopeGlobal)
|
||||||
|
s.RegisterStage(sdk.StageBeforeToolcall, p.status, sdk.StageScopeGlobal)
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
dups, err := duplicateStageRegs(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if dups["StageBeforeToolcall"] != 2 {
|
||||||
|
t.Fatalf("★ 没检出 StageBeforeToolcall 重复,实际 %v", dups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuplicateStageRegsNoFalsePositive(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeGo(t, dir, "plugin.go", `
|
||||||
|
package main
|
||||||
|
func reg(s *sdk.PluginSDK, p *P) {
|
||||||
|
s.RegisterStage(sdk.StageOnInput, p.auth, sdk.StageScopeGlobal)
|
||||||
|
s.RegisterStage(sdk.StageBeforeToolcall, p.before, sdk.StageScopeGlobal)
|
||||||
|
s.RegisterStage(sdk.StageAfterOutput, p.after, sdk.StageScopeGlobal)
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
dups, err := duplicateStageRegs(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(dups) != 0 {
|
||||||
|
t.Fatalf("★ 不同 stage 各一次不该报,实际 %v", dups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 判据:注释里的示例注册**不计入**。
|
||||||
|
//
|
||||||
|
// 这个检查器第一版直接对全文跑正则,于是把插件里
|
||||||
|
// 「当初写错了、现在留在注释里当教训」的那几行也统计进去,
|
||||||
|
// 修好之后照样报"注册了 3 次"(实测)。
|
||||||
|
func TestDuplicateStageRegsIgnoresComments(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeGo(t, dir, "plugin.go", `
|
||||||
|
package main
|
||||||
|
func reg(s *sdk.PluginSDK, p *P) {
|
||||||
|
// s.RegisterStage(StageBeforeToolcall, p.beforeToolcall, Global)
|
||||||
|
// s.RegisterStage(StageBeforeToolcall, p.stageStatusWorking, Global) ← 覆盖了上面
|
||||||
|
/*
|
||||||
|
s.RegisterStage(StageAfterOutput, p.stageStatusOnline, Global)
|
||||||
|
*/
|
||||||
|
s.RegisterStage(sdk.StageBeforeToolcall, p.merged, sdk.StageScopeGlobal)
|
||||||
|
s.RegisterStage(sdk.StageAfterOutput, p.mergedAfter, sdk.StageScopeGlobal)
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
dups, err := duplicateStageRegs(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(dups) != 0 {
|
||||||
|
t.Fatalf("★ 注释里的示例注册被当成真注册,实际 %v —— "+
|
||||||
|
"这会让\"已修好\"的插件照样告警(实测踩过)", dups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 判据:测试文件与工具链生成文件不计入。
|
||||||
|
func TestDuplicateStageRegsSkipsTestAndGenerated(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeGo(t, dir, "plugin.go", `
|
||||||
|
package main
|
||||||
|
func reg(s *sdk.PluginSDK, p *P) {
|
||||||
|
s.RegisterStage(sdk.StageBeforeToolcall, p.a, sdk.StageScopeGlobal)
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
// 测试文件里故意造重复(是判据的需要,不是真缺陷)
|
||||||
|
writeGo(t, dir, "plugin_test.go", `
|
||||||
|
package main
|
||||||
|
func t1(s *sdk.PluginSDK) {
|
||||||
|
s.RegisterStage(sdk.StageBeforeToolcall, nil)
|
||||||
|
s.RegisterStage(sdk.StageBeforeToolcall, nil)
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
// 生成文件里的注册是模板展开,不是作者写的
|
||||||
|
writeGo(t, dir, "z_proc_gen.go", `
|
||||||
|
package main
|
||||||
|
func g(s *sdk.PluginSDK) {
|
||||||
|
s.RegisterStage(sdk.StageBeforeToolcall, nil)
|
||||||
|
s.RegisterStage(sdk.StageBeforeToolcall, nil)
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
dups, err := duplicateStageRegs(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(dups) != 0 {
|
||||||
|
t.Fatalf("★ 测试/生成文件被计入,实际 %v", dups)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,109 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
// 模板判据:同一 stage 多次注册必须**合并**而不是静默覆盖。
|
|
||||||
//
|
|
||||||
// ## 修的是什么(真实事故,2026-10-18)
|
|
||||||
//
|
|
||||||
// 模板里原来是:
|
|
||||||
//
|
|
||||||
// stageHandlers[string(stage)] = handler // stage 名为键,后者覆盖前者
|
|
||||||
//
|
|
||||||
// 有人写了两次同 stage 注册:
|
|
||||||
//
|
|
||||||
// RegisterStage(BeforeToolcall, 权限门) ← 被下一条覆盖
|
|
||||||
// RegisterStage(BeforeToolcall, 切状态) ← 实际生效
|
|
||||||
//
|
|
||||||
// 后果不只是状态不切换 —— **权限门被静默关掉了**(那几版 QQ 插件里
|
|
||||||
// 任何人发消息都能调任何工具)。而内核侧日志显示 `2 handlers [qq qq]`,
|
|
||||||
// 看起来完全正常 —— 这是最难查的一类 bug。
|
|
||||||
//
|
|
||||||
// ## 为什么判据在工具链而不是 SDK
|
|
||||||
//
|
|
||||||
// 用户明确要求:「是工具链对同stage多handler注册给出告警」。
|
|
||||||
// 工具链是生成 proc_main 的地方,也就是 map 覆盖实际发生的地方;
|
|
||||||
// 在源头拦,告警直接出现在**插件启动日志**里,开发者本地就能看到。
|
|
||||||
//
|
|
||||||
// ## 判据验什么
|
|
||||||
//
|
|
||||||
// · 存在"重复注册"分支(不是无条件赋值)
|
|
||||||
// · 合并是**链式**的(prev 先跑,返回 error 中止)
|
|
||||||
// · 有告警(log.Printf + 重复注册字样)
|
|
||||||
// · 重复注册**不**再次上报 stage.register(否则内核会记两条假 handler)
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestTemplateMergesDuplicateStageRegistrations(t *testing.T) {
|
|
||||||
data, err := procTemplates.ReadFile("templates/proc_main.go.tmpl")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
src := string(data)
|
|
||||||
|
|
||||||
// 定位 stage 注册闭包:从 `func(stage sdk.Stage, handler sdk.StageHandler) {` 起
|
|
||||||
i := strings.Index(src, "func(stage sdk.Stage, handler sdk.StageHandler) {")
|
|
||||||
if i < 0 {
|
|
||||||
t.Fatal("找不到 stage 注册闭包 —— 模板结构变了,判据需同步")
|
|
||||||
}
|
|
||||||
body := src[i:]
|
|
||||||
if j := strings.Index(body[1:], "\n\t\tfunc("); j > 0 {
|
|
||||||
body = body[:j+1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ① 必须有"已存在"的判断(不是无条件赋值)
|
|
||||||
if !strings.Contains(body, "prev, dup :=") && !strings.Contains(body, "dup := false") {
|
|
||||||
t.Fatalf("★ stage 注册没有检测重复 —— 仍在静默覆盖。\n片段:\n%s", body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ② 必须是**链式**合并:prev 先跑
|
|
||||||
if !strings.Contains(body, "prev(ctx)") {
|
|
||||||
t.Fatal("★ 重复注册没有链式调用 prev —— 合并后旧 handler 会丢失")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ③ 第一个 handler 返回 error 必须中止(与内核 RunStage 语义一致)
|
|
||||||
if !strings.Contains(body, "if err := prev(ctx); err != nil {") {
|
|
||||||
t.Fatal("★ 链式合并没有在 prev 出错时中止 —— 权限门拒绝了还会继续执行后续 handler")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ④ 必须告警,且必须是**代码行**(不能靠注释里的字眼蒙混)。
|
|
||||||
//
|
|
||||||
// 第一版写成裸 `strings.Contains(body, "重复注册")` —— 把注释里的解释
|
|
||||||
// 也算进去了,于是删掉 log.Printf 后判据照样通过(变异不咬,实测)。
|
|
||||||
// 必须跳过注释行再找。
|
|
||||||
var warnCode string
|
|
||||||
for _, ln := range strings.Split(body, "\n") {
|
|
||||||
code := strings.TrimSpace(ln)
|
|
||||||
if strings.HasPrefix(code, "//") || strings.HasPrefix(code, "*") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.Contains(code, "log.Printf") && strings.Contains(code, "stage") {
|
|
||||||
warnCode = code
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if warnCode == "" {
|
|
||||||
t.Fatal("★ 重复注册没有告警代码行 —— 开发者无从知道该优化写法")
|
|
||||||
}
|
|
||||||
// 告警文案要说明“建议合并写法”(带提示,而不只是“重复了”)
|
|
||||||
if !strings.Contains(body, "建议把多个功能合并进同一个 handler") {
|
|
||||||
t.Fatal("★ 告警没有给出行动建议(应提示合并写法)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ⑤ 重复注册不能再上报一次 stage.register
|
|
||||||
// (内核会记两条 handler,但实际只调度一次 ⇒ 制造 `2 handlers [qq qq]` 假象)
|
|
||||||
dupBlock := body
|
|
||||||
if k := strings.Index(body, "if dup {"); k >= 0 {
|
|
||||||
dupBlock = body[k:]
|
|
||||||
if m := strings.Index(dupBlock, "\n\t\t\t}"); m > 0 {
|
|
||||||
dupBlock = dupBlock[:m]
|
|
||||||
}
|
|
||||||
if strings.Contains(dupBlock, "stage.register") {
|
|
||||||
t.Fatal("★ 重复注册仍然上报了 stage.register —— " +
|
|
||||||
"内核会记录两条 handler 而实际只派发一次,制造假象")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
t.Fatal("★ 找不到 `if dup {` 分支")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -708,50 +708,9 @@ func buildPluginSDK(name string) *sdk.PluginSDK {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
func(stage sdk.Stage, handler sdk.StageHandler) {
|
func(stage sdk.Stage, handler sdk.StageHandler) {
|
||||||
// ★★ 同一 stage 多次注册 ⇒ **默认合并**(按注册顺序链式),并告警。
|
|
||||||
//
|
|
||||||
// ## 原先的静默覆盖造成过真实事故(2026-10-18)
|
|
||||||
//
|
|
||||||
// 原实现是 `stageHandlers[string(stage)] = handler`(stage 名为键)⇒
|
|
||||||
// 后者**静默覆盖**前者。有人写了:
|
|
||||||
//
|
|
||||||
// RegisterStage(BeforeToolcall, 权限门) ← 被下一条覆盖
|
|
||||||
// RegisterStage(BeforeToolcall, 切状态) ← 实际生效
|
|
||||||
//
|
|
||||||
// 后果不只是状态不切换 —— **权限门被静默关掉了**。
|
|
||||||
// 而内核侧日志显示 `2 handlers [qq qq]`,看着完全正常。
|
|
||||||
//
|
|
||||||
// 三种处置的取舍:
|
|
||||||
// 禁止重复 → 存量插件可能直接起不来(它们的重复常是本意)
|
|
||||||
// 静默覆盖 → 就是当前 bug
|
|
||||||
// 默认合并 → 两件事都做,且告警让开发者知道该优化写法 ← 选这个
|
|
||||||
//
|
|
||||||
// 告警在**注册期**(插件启动时)打到 stderr,开发者在本地跑就能看见。
|
|
||||||
// 想消除告警就把多个功能写进同一个 handler(本项目 QQ 插件的做法)。
|
|
||||||
handlerMu.Lock()
|
handlerMu.Lock()
|
||||||
prev, dup := stageHandlers[string(stage)]
|
stageHandlers[string(stage)] = handler
|
||||||
if dup {
|
|
||||||
// 链式:先跑已注册的,它返回 error 就中止(与内核 RunStage 语义一致:
|
|
||||||
// 错误要能拦住后面的处理——比如权限门拒了就不该再切状态)。
|
|
||||||
stageHandlers[string(stage)] = func(ctx *sdk.StageContext) error {
|
|
||||||
if err := prev(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return handler(ctx)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
stageHandlers[string(stage)] = handler
|
|
||||||
}
|
|
||||||
handlerMu.Unlock()
|
handlerMu.Unlock()
|
||||||
|
|
||||||
if dup {
|
|
||||||
log.Printf("[plugin] ⚠️ stage %q 重复注册(已按注册顺序链式合并)。"+
|
|
||||||
"建议把多个功能合并进同一个 handler —— 每次 stage 往返都有跨进程开销。", stage)
|
|
||||||
// ★ 重复注册**不**再次上报 stage.register:内核已经登记过一条,
|
|
||||||
// 再报一条会让内核以为有两个独立 handler(它确实会记两条,
|
|
||||||
// 但派发时只调用一次 —— 这就是之前那个 `2 handlers [qq qq]` 假象)。
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := callCoreVoid("stage.register", map[string]interface{}{
|
if err := callCoreVoid("stage.register", map[string]interface{}{
|
||||||
"stage": string(stage), "scope": "global",
|
"stage": string(stage), "scope": "global",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user