Files
HomeAgent/internal/events/toolsready_test.go
JianFeeeee d65b742bd7 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 全通过。
2026-10-25 18:25:37 +08:00

59 lines
2.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}