mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 23:07:30 +00:00
## 死锁(这是 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 全通过。
209 lines
7.6 KiB
Go
209 lines
7.6 KiB
Go
package plugin
|
||
|
||
// 内置插件随包 SKILL 的**统一生命周期管理**。
|
||
//
|
||
// ## 为什么放在内核(而不是让每个插件自己写)
|
||
//
|
||
// 第一版我让 seq 自己在 Start() 里写盘 + 发事件。那是错的,有两个硬伤:
|
||
//
|
||
// ① 每个内置插件都要重复实现一遍「释放 / 清理 / 幂等」,
|
||
// 而且**禁用插件后 skill 还留着** —— 模型看得到 skill,点进去工具却不存在
|
||
// ② 内置插件是按需加载的:未 `plugin_start` 时它的工具本就不在,
|
||
// skill 却已经出现在索引里,等于对模型说谎
|
||
//
|
||
// 正确的语义:**skill 是插件能力的一部分,生命周期必须与插件同生共死**。
|
||
// 所以由内核在插件启停处统一切换,插件作者一行都不用写。
|
||
//
|
||
// ## 挂载点
|
||
//
|
||
// StartPlugin / loadOne 成功 → InstallBuiltinSkill
|
||
// StopPlugin / StopAll → RemoveBuiltinSkill
|
||
// 持久禁用(plugin_disable) → RemoveBuiltinSkill
|
||
//
|
||
// ## 来源
|
||
//
|
||
// 源码树里约定 `<插件源码目录>/skill/<skillName>/SKILL.md`(与 SDK 示例
|
||
// third_party/homeagent-sdk/example/editdoc/skill/office-tools 一致)。
|
||
// 但内置插件是**编译期内置**,运行时没有源码树,所以 SKILL 正文必须
|
||
// 编进二进制(见各插件包里的 embed),由插件通过 SkillProvider 暴露。
|
||
//
|
||
// 现阶段用一个最小接口从插件实例上取:**插件只需实现一个方法**,
|
||
// 不需要自己碰文件系统、不需要发事件、不需要管清理。
|
||
|
||
import (
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"github.com/JianFeeeee/HologramAgent/internal/events"
|
||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||
)
|
||
|
||
// SkillProvider 是**可选**接口:内置插件实现它即可让自己的 SKILL 随启停出入。
|
||
//
|
||
// 只需返回 SKILL 文档集合(名字 → SKILL.md 全文),其余全由内核处理:
|
||
// 写盘位置、幂等、事件通知、停用时清理。
|
||
//
|
||
// 不实现该接口的插件不受任何影响(绝大多数插件没有 SKILL)。
|
||
type SkillProvider interface {
|
||
// BuiltinSkills 返回本插件随包的 SKILL 文档。
|
||
//
|
||
// map 的键是 skill 名(**必须**与 SKILL.md frontmatter 的 name 一致,
|
||
// 也用作目录名);值是 SKILL.md 全文(含 frontmatter)。
|
||
BuiltinSkills() map[string]string
|
||
}
|
||
|
||
// skillDirOf 返回某个内置 skill 在磁盘上的约定位置。
|
||
//
|
||
// 与 skillmgr 的扫描口径对齐:它扫 <data_dir>/skills/*,每个目录里要有 SKILL.md。
|
||
func (r *Registry) skillDirOf(skillName string) string {
|
||
r.mu.RLock()
|
||
dataDir := r.dataDir
|
||
r.mu.RUnlock()
|
||
return skillDirFrom(dataDir, skillName)
|
||
}
|
||
|
||
// skillDirFrom 是 skillDirOf 的**不加锁**版本(纯函数)。
|
||
//
|
||
// ★ 为什么必须拆出来:StopAll 持有 r.mu **写锁**时调用 RemoveBuiltinSkill,
|
||
// 而 skillDirOf 要取 RLock ⇒ 自死锁。实测后果:internal/plugins 的
|
||
// TestCapability_RestrictedPluginStillLoads 卡在 cleanup 300s 超时,
|
||
// CI 的 go test ./... 与 -race 双双红(2026-10-25)。
|
||
//
|
||
// 规则:已持锁的调用路径只能用不加锁版本。
|
||
func skillDirFrom(dataDir, skillName string) string {
|
||
if dataDir == "" {
|
||
return ""
|
||
}
|
||
return filepath.Join(dataDir, "skills", skillName)
|
||
}
|
||
|
||
// InstallBuiltinSkill 把某个插件的随包 SKILL 释放到 <data_dir>/skills/ 并通知 skillmgr。
|
||
//
|
||
// 幂等:
|
||
// - 目录已存在且 SKILL.md 非空 ⇒ 视为已安装,只补发一次事件(让 skillmgr 接管)
|
||
// - 内容为空/读取失败 ⇒ 重写
|
||
//
|
||
// 失败只记日志、不返回错误:skill 装不上不该阻止插件启动(工具本身仍可用)。
|
||
// 但必须**可见** —— 没有 skill 的插件模型基本不会去用,这是静默失效。
|
||
func (r *Registry) InstallBuiltinSkill(pluginName string, p sdk.Plugin) {
|
||
provider, ok := p.(SkillProvider)
|
||
if !ok {
|
||
return // 绝大多数插件没有 SKILL,这是常态,不是错误
|
||
}
|
||
skills := provider.BuiltinSkills()
|
||
if len(skills) == 0 {
|
||
return
|
||
}
|
||
|
||
for name, body := range skills {
|
||
if strings.TrimSpace(body) == "" {
|
||
log.Printf("[plugin] %s: skill %q has empty body, skipping (an empty SKILL gives the model nothing to read)", pluginName, name)
|
||
continue
|
||
}
|
||
dir := r.skillDirOf(name)
|
||
if dir == "" {
|
||
log.Printf("[plugin] %s: data_dir not set, cannot install skill %q", pluginName, name)
|
||
continue
|
||
}
|
||
file := filepath.Join(dir, "SKILL.md")
|
||
|
||
// 已存在且非空:保留(用户可能改过措辞)。但仍要发事件,
|
||
// 否则重启后 skillmgr 若没扫到,这条 skill 就永久消失。
|
||
cur, readErr := os.ReadFile(file)
|
||
if readErr != nil || len(cur) == 0 {
|
||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||
log.Printf("[plugin] %s: creating skill dir failed %s: %v", pluginName, dir, err)
|
||
continue
|
||
}
|
||
if err := os.WriteFile(file, []byte(body), 0644); err != nil {
|
||
log.Printf("[plugin] %s: writing skill failed %s: %v", pluginName, file, err)
|
||
continue
|
||
}
|
||
log.Printf("[plugin] %s: installed skill %q -> %s", pluginName, name, dir)
|
||
}
|
||
|
||
r.announceSkill(pluginName, dir)
|
||
}
|
||
}
|
||
|
||
// RemoveBuiltinSkill 在插件停用/禁用时移除其随包 SKILL。
|
||
//
|
||
// 为什么必须移除:否则模型在 skill 索引里仍看得到它,点进去照做,
|
||
// 而工具已被停用 —— 模型会拿到一连串"工具不存在"的错误,
|
||
// 且它无从知道原因是"插件被关了"。
|
||
func (r *Registry) RemoveBuiltinSkill(pluginName string, p sdk.Plugin) {
|
||
r.mu.RLock()
|
||
dataDir := r.dataDir
|
||
r.mu.RUnlock()
|
||
r.removeBuiltinSkillLocked(pluginName, p, dataDir)
|
||
}
|
||
|
||
// removeBuiltinSkillLocked 是 RemoveBuiltinSkill 的**不加锁**版本。
|
||
//
|
||
// 调用方必须已持有 r.mu(且不能再调任何会取锁的方法)。
|
||
// 由 StopAll 使用 —— 那里持写锁,走加锁版会自死锁。
|
||
// 显式传入 dataDir 而不是在里面读 r.dataDir:避免"看起来没取锁、
|
||
// 实际上一改就取锁"的隐患。
|
||
func (r *Registry) removeBuiltinSkillLocked(pluginName string, p sdk.Plugin, dataDir string) {
|
||
provider, ok := p.(SkillProvider)
|
||
if !ok {
|
||
return
|
||
}
|
||
for name := range provider.BuiltinSkills() {
|
||
dir := skillDirFrom(dataDir, name)
|
||
if dir == "" {
|
||
continue
|
||
}
|
||
if err := os.RemoveAll(dir); err != nil {
|
||
log.Printf("[plugin] %s: removing skill %q failed %s: %v", pluginName, name, dir, err)
|
||
continue
|
||
}
|
||
log.Printf("[plugin] %s: removed skill %q (plugin is stopped)", pluginName, name)
|
||
// 通知 skillmgr 从索引里摘掉(复用同一事件,负载带 removed 标记)。
|
||
r.announceSkillRemovedLocked(pluginName, dir)
|
||
}
|
||
}
|
||
|
||
// announceSkill 发布 skill_detected,让 skillmgr **当场**接管。
|
||
//
|
||
// 不能只依赖 skillmgr 的启动全扫:它启动早于 plugin-host 层,
|
||
// 首次部署/停止后重启插件时都扫不到(实测:skillmgr 在内核第 4 层装配后
|
||
// 10s 完成扫描,此时插件才刚被加载)。
|
||
func (r *Registry) announceSkill(pluginName, dir string) {
|
||
r.mu.RLock()
|
||
bus := r.evBus
|
||
r.mu.RUnlock()
|
||
if bus == nil {
|
||
return
|
||
}
|
||
bus.Publish(&events.Event{
|
||
Type: events.EventSkillDetected,
|
||
Source: pluginName,
|
||
Payload: map[string]interface{}{
|
||
"path": dir,
|
||
"from_plugin": pluginName,
|
||
"builtin": true,
|
||
},
|
||
})
|
||
}
|
||
|
||
// announceSkillRemovedLocked 发布"skill 已移除"(**不加锁**,供持锁路径调用)。
|
||
func (r *Registry) announceSkillRemovedLocked(pluginName, dir string) {
|
||
bus := r.evBus
|
||
if bus == nil {
|
||
return
|
||
}
|
||
bus.Publish(&events.Event{
|
||
Type: events.EventSkillDetected,
|
||
Source: pluginName,
|
||
Payload: map[string]interface{}{
|
||
"path": dir,
|
||
"from_plugin": pluginName,
|
||
"builtin": true,
|
||
"removed": true,
|
||
},
|
||
})
|
||
}
|