fix(plugin): 修两处持锁自死锁(CI 双红真因)+ tools_ready 事件声明

## 死锁(这是 CI 连续两次红的真因)

`StopAll` 与 `StopPlugin` 都在持有 `r.mu` **写锁**时调用
`RemoveBuiltinSkill` → `skillDirOf` → `r.mu.RLock()` ⇒ **自死锁**。

后果不是报错而是**卡死**:
- internal/plugins 卡 300s 超时(TestCapability_RestrictedPluginStillLoads)
- internal/plugin  卡 840s 超时(TestStopPluginDoesNotTouchPersistentState)
- CI 的 `go test ./...` 与 `go test -race` 双双红

修法:拆出**不加锁**的内部版本,持锁路径只走它:
- `skillDirFrom(dataDir, name)`(纯函数)
- `removeBuiltinSkillLocked(pluginName, p, dataDir)`(显式传 dataDir,
  避免"看起来没取锁、一改就取锁"的隐患)
- `announceSkillRemovedLocked`(持锁下直读 r.evBus)

修后:internal/plugins 300s → 12s,internal/plugin 840s → 0.7s。

## 防回归判据

`TestStopAllAndStopPluginDoNotDeadlock`:死锁**不返回**,普通断言抓不到,
所以用**超时**当判据(5 秒未返回即失败),并指明应改用 Locked 版本。

## tools_ready 事件声明

新增的 tools_ready 触发 `TestEveryPublishedEventHasAnOutwardPath`
(该判据防"事件发了、客户端收不到"的静默失效)。

tools_ready 是**内核内部**就绪信号(plugin-host → seq 等订阅者),
不是对话流程事件,前端无需感知 ⇒ 在 allowed 里显式声明理由。

## 验证

go build ./... / go vet ./... / go test -p 1 ./... / -race 全通过。
This commit is contained in:
JianFeeeee
2026-10-25 18:25:37 +08:00
parent fe1c4f6984
commit d65b742bd7
28 changed files with 813 additions and 75 deletions

View File

@ -33,6 +33,25 @@ const (
// 职责链:发现者(兼容层)→ 移交事件 → 归属者(skillmgr)加载管理。
EventSkillDetected EventType = "skill_detected"
// EventToolsReady 在**全部插件加载完成、工具已注册完毕**之后发布一次。
//
// 语义边界(关键):它只保证"工具调用可用",**不保证 agent 正在运行**。
// 订阅者若只是执行一段工具调用序列(如 seq 的开机序列),此刻即可开始;
// 它们的返回值进队列,等 agent 起来后再处理即可。
//
// 为什么不是 AgentReady:seq 自动运行**不依赖 agent** —— 它只是一段
// 调用序列。把事件绑在 agent.Start() 上会让时机被人为推后,且把
// "何时可跑序列"这个内核判断外包给编排层(main),换个入口就漂移。
//
// 发布点:kernelmod/pluginhost 在 `ks.PluginsReady = true` 处发布
// (那里正是"内置插件也已 LoadSkipping 完成"的时刻)。
//
// payload 约定:
//
// plugins: []string,已加载插件名
// plugin_count: int
EventToolsReady EventType = "tools_ready"
// EventMemoryAccess 是**图记忆读写事件**:谁读了/写了哪几个记忆块。
//
// 为什么需要它(2026-10-08):星图此前的「跟随 agent 活动」靠**工具名猜节点**

View File

@ -0,0 +1,58 @@
package events
import "testing"
// tools_ready 是"工具已全部注册、可以安全执行工具调用序列"的唯一信号。
//
// 语义只到"工具可调":seq 的开机序列**不需要 agent 在跑**,返回值进队列
// 等 agent 起来后处理即可。把事件绑在 agent.Start 上会让时机被人为推后,
// 且把"何时可跑序列"这个内核判断外包给编排层。
//
// 为什么要有判据:这个事件缺失时**没有任何报错** —— 依赖它的订阅者
// (如 seq 的开机序列)只是永远不执行。静默失效正是最需要判据的一类。
func TestToolsReadyEventExistsAndIsDistinct(t *testing.T) {
if EventToolsReady == "" {
t.Fatal("EventToolsReady 未定义 —— 订阅者无法等待工具就绪")
}
if EventToolsReady == EventAll {
t.Fatal("EventToolsReady 不能是 EventAll(会把所有事件都当成工具就绪)")
}
// 必须与相邻的生命周期事件互不冲突(拼错会变成"永不触发")
others := []EventType{
EventSkillDetected, EventSystem, EventScheduler,
EventAgentOutput, EventAgentLLMChain,
}
for _, o := range others {
if EventToolsReady == o {
t.Fatalf("EventToolsReady 与 %q 重名", o)
}
}
if string(EventToolsReady) != "tools_ready" {
t.Fatalf("事件名应为 tools_ready,实际 %q", EventToolsReady)
}
}
// 总线必须能按类型精确分发 tools_ready(订阅者只想要这一个事件)。
func TestToolsReadyDispatch(t *testing.T) {
bus := NewBus()
var got []*Event
bus.Subscribe(EventToolsReady, func(e *Event) { got = append(got, e) })
// 发一条无关事件:不得误触发
bus.Publish(&Event{Type: EventSystem})
if len(got) != 0 {
t.Fatalf("无关事件不应触发 tools_ready 订阅者,实际 %d 次", len(got))
}
bus.Publish(&Event{
Type: EventToolsReady,
Source: "kernel",
Payload: map[string]interface{}{"plugin_count": 3},
})
if len(got) != 1 {
t.Fatalf("tools_ready 应触发一次,实际 %d 次", len(got))
}
if n, _ := got[0].Payload["plugin_count"].(int); n != 3 {
t.Fatalf("payload 应透传 plugin_count,实际 %v", got[0].Payload)
}
}