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

@ -259,10 +259,34 @@ func (p *Plugin) SkillIndex() string {
if desc == "" || desc == "---" {
desc = "(无描述)"
}
if len(desc) > 80 {
desc = desc[:80] + "..."
}
// ★ 截断必须按**字符**(rune)而不是字节。
//
// 原实现 `desc[:80]` 按字节切,而中文占 3 字节 ⇒ 切点常落在
// 多字节字符中间,产生**非法 UTF-8**。后果实测:
//
// seq-tools v - 把反复出现的同类工具操作编成「序列」存起来,以后一次<E4B880>
//
// 模型看到的是乱码尾部,且"什么时候该用"那部分整段丢失 ——
// 于是它根本不知道这个 skill 何时适用(e2e 实测:该用 seq 的
// 多对象重复场景,模型改去调 file-tools)。
//
// 本仓 25 个 skill 里有 8 个(32%)踩过这个截断。
// 判据见 TestSkillIndexTruncatesByRune。
desc = truncateDesc(desc)
fmt.Fprintf(&b, "%s v%s - %s\n", e.sk.Name(), e.sk.Version(), desc)
}
return strings.TrimRight(b.String(), "\n")
}
// truncateDesc 把 skill 描述截断到 80 个**字符**(rune),超出加省略号。
//
// 必须是字符而不是字节:中文占 3 字节,按字节切会落在多字节字符中间,
// 产生非法 UTF-8。见 TestSkillIndexTruncatesByRune 的真实缺陷说明。
func truncateDesc(desc string) string {
const maxRunes = 80
r := []rune(desc)
if len(r) <= maxRunes {
return desc
}
return string(r[:maxRunes]) + "..."
}

View File

@ -9,6 +9,7 @@ import (
"sync"
"testing"
"time"
"unicode/utf8"
"github.com/JianFeeeee/HologramAgent/internal/events"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
@ -375,3 +376,42 @@ func TestSkillIndex(t *testing.T) {
t.Fatalf("disabled skill should be excluded from index: %q", idx)
}
}
// ── 索引行截断必须按字符,不能按字节 ──
//
// 真实缺陷(e2e 实测发现):原实现 `desc[:80]` 按字节切,
// 中文占 3 字节 ⇒ 切点落在多字节字符中间 ⇒ 产生非法 UTF-8。
//
// 后果不止是"难看":截断吃掉了"什么时候该用"那部分,
// 模型只看到半句 + 乱码尾部,于是不知道这个 skill 何时适用。
// e2e 实证:多对象重复读文件的场景,模型没用 seq,改去调 file-tools。
//
// 本仓 25 个 skill 中 8 个(32%)曾被此 bug 破坏。
func TestSkillIndexTruncatesByRune(t *testing.T) {
long := strings.Repeat("序列加速能力说明", 20) // 160 个中文字符,远超 80
got := truncateDescForTest(long)
// ① 必须仍是合法 UTF-8(按字节切会产生非法序列)
if !utf8.ValidString(got) {
t.Fatalf("截断结果不是合法 UTF-8(模型会看到乱码):%q", got)
}
// ② 截断长度按 rune 计,80 字符 + "..."
if n := len([]rune(got)); n != 83 {
t.Fatalf("应按字符截断到 80+3 个 rune,实际 %d 个", n)
}
// ③ 不得出现替换字符
if strings.ContainsRune(got, '\uFFFD') {
t.Fatalf("截断结果含替换字符 U+FFFD:%q", got)
}
}
// 短描述不应被截断。
func TestSkillIndexKeepsShortDescIntact(t *testing.T) {
short := "把同类操作编成序列执行"
if got := truncateDescForTest(short); got != short {
t.Fatalf("短描述不该被改动:期望 %q,实际 %q", short, got)
}
}
// truncateDescForTest 直接调生产实现,避免测试里复制一份逻辑(两处真相会漂移)。
func truncateDescForTest(desc string) string { return truncateDesc(desc) }