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

@ -0,0 +1,50 @@
package pluginhost
// tools_ready 的发布判据。
//
// 语义边界(用户裁定):seq 自动运行**不依赖 agent** —— 它只是一段工具调用
// 序列,返回值排队等 agent 起来处理即可。所以事件的正确时机是
// "全部插件加载完、工具都注册了"(ks.PluginsReady = true 处),
// 而不是 agent.Start() 之后。
//
// 放在 kernelmod 而不是 main:时机语义属于内核;外包给编排层会让
// 换个入口(e2e / 测试 / 别的发行版 main)就不一致。
import (
"testing"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
)
// 没有 EventBus 时必须**响亮报错而不是静默返回** ——
// 静默失败会让"为什么开机序列没跑"完全无从查起。
func TestPublishToolsReadyWithoutBusDoesNotPanic(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("publishToolsReady 在 nil EventBus 下不能 panic:%v", r)
}
}()
publishToolsReady(nil)
publishToolsReady(&kernelboot.KernelState{})
}
// 有 EventBus 时必须真的发布一条 tools_ready,且带 plugin_count。
func TestPublishToolsReadyPublishes(t *testing.T) {
bus := events.NewBus()
var got []*events.Event
bus.Subscribe(events.EventToolsReady, func(e *events.Event) { got = append(got, e) })
ks := &kernelboot.KernelState{EventBus: bus}
publishToolsReady(ks)
if len(got) != 1 {
t.Fatalf("应发布恰好一条 tools_ready,实际 %d 条", len(got))
}
if got[0].Source != "plugin-host" {
t.Fatalf("Source 应为 plugin-host,实际 %q", got[0].Source)
}
if _, ok := got[0].Payload["plugin_count"]; !ok {
t.Fatal("payload 应含 plugin_count")
}
}