Files
HomeAgent/internal/plugins/seq/boot_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

71 lines
2.2 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 seq
// 开机序列的判据。
//
// 背景(用户裁定):seq 的核心价值不是"单次任务省几次调用",而是
// **定时/开机复用**。e2e 实测证明前者不成立(一次性多文件读直接并行
// files_read 就是最优解),所以 Boot 才是这个插件存在的理由。
import (
"strings"
"testing"
)
// boot 必须能从 JSON 里反序列化出来(序列文件手写 "boot": true 的路径)。
func TestBootMarkerParsesFromJSON(t *testing.T) {
text := `{
"name": "boot-check",
"boot": true,
"groups": [
{"name": "g1", "parallel": true, "out": {"r": "string"},
"tools": "{\"tool\":\"cmd_run\",\"args\":{},\"as\":\"r\"};"}
]
}`
sq, err := Parse([]byte(text))
if err != nil {
t.Fatalf("parse: %v", err)
}
if !sq.Boot {
t.Fatal("boot:true 未被解析(开机序列会静默不执行)")
}
}
// 未标 boot 的序列默认不参与开机执行 —— 否则每条序列都会在启动时乱跑。
func TestBootDefaultsToFalse(t *testing.T) {
text := `{"name":"normal","groups":[{"name":"g","parallel":true,"out":{"r":"string"},
"tools":"{\"tool\":\"cmd_run\",\"args\":{},\"as\":\"r\"};"}]}`
sq, err := Parse([]byte(text))
if err != nil {
t.Fatalf("parse: %v", err)
}
if sq.Boot {
t.Fatal("未声明 boot 的序列不应标记为开机序列")
}
}
// runBootSequences 必须能在没有任何 boot 序列时安静返回(不能 panic、不能乱跑)。
func TestRunBootSequencesNoopWhenNone(t *testing.T) {
p := New("seq")
p.store = NewStore(t.TempDir())
// 不 panic、不报错即为通过
p.runBootSequences()
}
// 工具描述必须告知模型 boot 的语义(否则没人会想到标它)。
func TestCreateToolAdvertisesBoot(t *testing.T) {
defs := New("seq").toolDefs()
def, ok := defs["seq_create"]
if !ok {
t.Fatal("缺 seq_create 定义")
}
props, _ := def.Parameters["properties"].(map[string]interface{})
boot, ok := props["boot"].(map[string]interface{})
if !ok {
t.Fatal("seq_create 必须暴露 boot 参数(否则模型无从知道可标记开机序列)")
}
desc, _ := boot["description"].(string)
if !strings.Contains(desc, "启动") {
t.Fatalf("boot 描述应说明「系统就绪/启动时执行」,实际:%q", desc)
}
}