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,90 @@
package seq
// 开机序列:系统就绪时自动执行标记了 boot 的序列。
//
// ## 为什么这是 seq 最核心的用途(用户原话)
//
// 「seq 最核心的是定时复用,比如开机执行一段序列」
//
// 单次任务里编序列往往**不划算**:先 seq_create 写 JSON 再 seq_run,
// 比直接调工具多两步(e2e 实测:多文件读取场景模型直接并行 files_read
// 就是最优解,包成序列纯亏)。真正省事的是**同一套流程反复跑** ——
// 而开机是最典型的周期性触发点。
//
// ## 时序(为什么订阅 tools_ready 而不是在 Start 里跑)
//
// 插件 Start 的先后由依赖图决定,此刻:
// · 序列要调的工具可能属于还没拉起的插件
// · agent 循环还没开始,投递的输入无人接收
//
// plugin-host 在 `ks.PluginsReady = true`(全部插件含内置都已加载)
// 之后发布 tools_ready,那一刻**工具才全部可调**。
// 本文件只订阅那一个事件 —— 不需要等 agent 起来。
import (
"log"
"github.com/JianFeeeee/HologramAgent/internal/events"
)
// subscribeBoot 订阅 tools_ready,在该时刻执行所有 boot 序列。
//
// 订阅 tools_ready 而不是"agent 就绪":seq 自动运行**不依赖 agent** ——
// 它只是一段工具调用序列,返回值进队列等 agent 起来后处理即可。
// 绑在 agent 启动上会把时机人为推后,且依赖编排层的判断。
//
// 返回退订函数(Stop 时调用)。订阅失败不阻断插件启动:
// 工具本身仍可用,只是开机序列不会跑 —— 但必须留日志,
// 否则"为什么开机没执行"完全无从查起。
func (p *Plugin) subscribeBoot() func() {
if p.sdk == nil {
return func() {}
}
unsub := p.sdk.Subscribe(events.EventToolsReady, func(evt *events.Event) {
// 事件在总线 goroutine 上分发,不能阻塞它:开机序列可能跑很久。
go p.runBootSequences()
})
return unsub
}
// runBootSequences 依次执行所有 boot 序列。
//
// 串行执行(不是并发):开机序列通常彼此独立,但并发跑会让日志交错、
// 且若两条序列争用同一资源,失败原因极难定位。开机不是性能敏感路径。
//
// 单条失败不影响后续序列 —— 一条坏了不该让其余开机动作全部失效。
func (p *Plugin) runBootSequences() {
names := p.store.List()
var boots []string
for _, n := range names {
sq, err := p.store.Load(n)
if err != nil {
// 坏序列不阻断:store 里可能有历史遗留文件
log.Printf("[seq] boot scan skip %q: %v", n, err)
continue
}
if sq.Boot {
boots = append(boots, n)
}
}
if len(boots) == 0 {
return
}
log.Printf("[seq] tools_ready: running %d boot sequence(s): %v", len(boots), boots)
for _, name := range boots {
sq, err := p.store.Load(name)
if err != nil {
log.Printf("[seq] boot %q load failed: %v", name, err)
continue
}
// 开机序列无外部入参(nil):组的 when/args 按既有语义取零值。
res, err := p.runSequence(sq, nil, nil)
if err != nil {
log.Printf("[seq] boot %q failed: %v", name, err)
continue
}
// runSequence 返回渲染后的摘要(interface{}),含失败标记时如实转述。
log.Printf("[seq] boot %q done (%d groups): %v", name, len(sq.Groups), res)
}
}