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

@ -229,18 +229,18 @@ func TestSystemPromptHasNoImplementationJargon(t *testing.T) {
//
// 历史:收敛前 2309 → 收敛后 1723 → 上限取 2000(留 277 字增删余地)。
//
// 2026-10-25 上调到 2100:新增【加速序列】115 字(让模型知道何时该用 seq)。
// 该块属于判据认可的**行为判据**——"模型会据此做什么不同的事"答得上来:
// 决定用序列还是逐个调工具。因此占用增删余地是正当的。
// ★ 2026-10-25 的一次真实教训:
// 我曾为 seq 加【加速序列】115 字,超限后把上限调到 2100 —— 那是错的。
// 用户裁定"seq 知识应在 skill 中",删掉该块后回到 1907 字,上限也还原 2000。
//
// ⚠️ 调整门槛没有放松:**技术说明仍然不许占用这份额度**。
// 下次若因"又加一大段实现说明"而想再调上限,应该改的是那段说明(下沉到
// skill 或工具描述),不是这个数字。
// 所以这条判据超限时的**正确反应是删内容或下沉到 skill**,不是调数字。
// 领域知识(某个工具什么时候该用、参数怎么填)属于 skill;
// 这里只该放"模型不看文档也会做错"的通用规则。
func TestSystemPromptStaysCompact(t *testing.T) {
a := newToolTestAgent(t)
prompt := a.buildSystemPrompt("", "测试输入")
const maxRunes = 2100
const maxRunes = 2000
if n := len([]rune(prompt)); n > maxRunes {
t.Errorf("系统提示词 %d 字,超过上限 %d —— \n"+
"新加的内容先问:模型会据此做什么不同的事?答不上来就该进 skill 或工具描述",

View File

@ -1,29 +0,0 @@
package core
import (
"strings"
"testing"
)
// 【加速序列】块必须在系统提示词里,且给出可判定的触发条件。
//
// 为什么需要判据:这一块缺失时**没有任何报错**——模型只是不会用 seq,
// 表现为"工具在、但几乎没人调用",与 doc_query 当年的症状完全一样。
func TestSystemPrompt_HasAccelerationSequenceBlock(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
p := a.buildSystemPrompt("", "测试输入")
if !strings.Contains(p, "【加速序列】") {
t.Fatal("系统提示词缺【加速序列】块 —— 模型不会知道何时该用 seq")
}
// 触发条件必须是**可判定的**,不能只说"有用时用"。
for _, want := range []string{"多个对象", "顺序已知", "seq_list", "组内并行", "组间串行"} {
if !strings.Contains(p, want) {
t.Fatalf("【加速序列】块缺 %q(模型据此判断该不该用序列)", want)
}
}
// 必须指路到 skill,避免提示词里重复格式细节(两处真相)。
if !strings.Contains(p, "seq-tools") {
t.Fatal("应指向 seq-tools skill,而不是在提示词里重复格式细节")
}
}

View File

@ -0,0 +1,91 @@
package core
// 全仓守卫:按字节截断字符串是中文环境下的**必然 bug**。
//
// 中文占 3 字节,`s[:N]` 的切点常落在多字节字符中间 ⇒ 产生非法 UTF-8。
// 实测代价(不是理论风险):
//
// skillmgr: desc[:80] 把 25 个 skill 里 8 个(32%)的索引行切坏,
// 模型看到 "…以后一次<E4B880>",且"什么时候该用"整段丢失
// ⇒ e2e 实测该用 seq 的场景,模型改去调别的工具
// tooldefs: desc[:60] 同样问题
// knowledge/distill: preview[:200] / raw[:400] 同样问题
//
// 正确写法:先转 []rune 再切。本判据扫描源码,发现新的字节截断即失败。
import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
)
// byteSliceRe 匹配 `ident[:数字]` 形式(字节切片的典型写法)。
var byteSliceRe = regexp.MustCompile(`([a-zA-Z_][a-zA-Z0-9_.]*)\[:\s*(\d+)\]`)
// byteSliceAllow 是**已知安全**的例外:切的是 ASCII 生成物或切片重置。
var byteSliceAllow = map[string]bool{
// 文件路径/哈希/hex 全是 ASCII
"fp": true, "h": true, "id": true, "d": true, "digest": true,
"sum": true, "shortFP": true, "head": true, "monthKey": true,
// `x[:0]` 是"清空切片并复用底层数组"的惯用法,不涉及字符
"msgs": true, "args": true, "queue": true, "pending": true,
"Children": true, "changeSets": true, "keep": true, "out": true,
"b": true, "buf": true, "data": true,
// st.Names 是 []string(工具结果的名称**列表**),切它是切片截断,
// 不涉及字符编码。误报清单要写明依据,否则以后没人敢再动这里。
"show": true,
}
func TestNoByteLevelStringTruncation(t *testing.T) {
root := ".."
var offenders []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
src, rerr := os.ReadFile(path)
if rerr != nil {
return nil
}
for i, line := range strings.Split(string(src), "\n") {
trimmed := strings.TrimSpace(line)
// 跳过注释(我们的说明里会引用这个模式本身)
if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "*") {
continue
}
for _, m := range byteSliceRe.FindAllStringSubmatch(line, -1) {
ident, n := m[1], m[2]
// `[:0]` 是切片重置,不是字符截断
if n == "0" {
continue
}
if byteSliceAllow[ident] {
continue
}
// 形如 `string(r[:60])` 且 r 来自 []rune 的写法:变量名含 rune 约定
if ident == "r" {
continue
}
offenders = append(offenders, filepath.Join(path)+":"+strconv.Itoa(i+1)+" "+trimmed)
}
}
return nil
})
if err != nil {
t.Fatalf("walk: %v", err)
}
if len(offenders) > 0 {
t.Fatalf("发现 %d 处疑似**按字节**截断字符串(中文环境下会产生非法 UTF-8):\n %s\n\n"+
"正确写法:先转 []rune 再切,例如 r := []rune(s); s = string(r[:N]) + \"...\"\n"+
"若确实是 ASCII 生成物(hash/路径)或切片重置(x[:0]),加进 byteSliceAllow 并注明理由。",
len(offenders), strings.Join(offenders, "\n "))
}
}

View File

@ -261,18 +261,15 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
prompt += "\n\n" + a.indexer.BuildToolPrompt()
}
// 【加速序列】—— 与 doc_query 同一类问题:能力存在但模型不知道何时该用。
// seq 的触发条件**刻意不写在这里**(用户裁定:seq 知识应在 skill 中)。
//
// seq 有 7 个工具、也有 skill(seq-tools),但 skill 索引只给一行摘要。
// 生产观察:agent 极少使用序列加速,因为它不会主动想"这件事我做过、
// 该编成序列"或"这套操作要在多个对象上重复,该并行"。
// 一度加过【加速序列】块,理由是"skill 索引只给一行摘要,模型不知道何时该用"。
// 那个理由站不住:紧邻下方的【可用技能】已明确要求"准备调用某个插件的工具时
// 先看同名技能",seq 的"什么时候用"写在 skill 正文里会被这条规则正确带到
// 模型面前。再加一块提示词 = 同一件事记两处,且必然漂移。
//
// 这里只留**模型必须据此作决定**的判据(与其余内核块同一风格):
// 什么时候该想起它。格式细节、组语义、常见错误全在 skill 与工具描述里,
// 不在这里重复——重复就是两处真相。
prompt = appendPromptBlock(prompt, "【加速序列】同一套操作在**多个对象**上重复、或几件**顺序已知**的事连做时,"+
"先想编成序列用 `seq_run`(组内并行、组间串行);以前做过的先用 `seq_list` 找。"+
"格式见 `seq-tools` skill。")
// 副作用证据:加块后 TestSystemPromptStaysCompact 报 2022 > 2000。
// 那个超限**本身就是信号**——该去掉的是内容,不是调高上限。
// 技能索引(方案B):轻量注入已加载技能列表,LLM 匹配到场景时
// 主动 skill_info 拉取全文按文档执行
@ -358,8 +355,10 @@ func (a *Agent) buildToolCatalog() string {
byPlugin[plg]++
if pluginDesc[plg] == "" {
desc, _ := fn["description"].(string)
if len(desc) > 60 {
desc = desc[:60] + "..."
// 按**字符**截断,不是字节:中文占 3 字节,字节切会落在多字节
// 字符中间产生非法 UTF-8(同 skillmgr 的那个真实缺陷)。
if r := []rune(desc); len(r) > 60 {
desc = string(r[:60]) + "..."
}
pluginDesc[plg] = desc
}

View File

@ -0,0 +1,79 @@
package core
// child contextfull **走向量表**的 e2e 判据。
//
// ## 为什么需要它(在 TestResident_ContextFullAndDispositions 之外)
//
// 那条判据断言"父在 L4 上收到中断 + 登记表标记 ContextFull",但它不关心
// 中断**长什么样** —— 而 2026-10-25 把 handleChildContextFull 改成走
// raiseVector(VectorChildContextFull) 之后,"长什么样"变成了向量表的事:
//
// · source 必须是 child/<id>(父据此定位是哪个子)
// · 正文必须给出压缩/回收/销毁三种处置(父的唯一决策输入)
// · 级别必须是 L4
//
// 这三条若漂移,父会收到一个"看不出是哪个子、也不知道该怎么办"的中断,
// 而 TestResident_ContextFullAndDispositions 照样绿。
import (
"strings"
"testing"
"time"
)
// 子 contextfull 产生的 L4 中断必须带子标识与三种处置动作。
func TestChildContextFull_L4CarriesIdentityAndDispositions(t *testing.T) {
parent, _, dir := newRootForResidents(t)
spawnTestResident(t, parent, dir, "child-vec")
rc := parent.residents["child-vec"]
child := rc.agent
// 把子的积累上下文推到窗口之上(同既有判据的手法:灌 a.context 而非拼好的消息)
child.context.Append(ContextEvent{
Timestamp: time.Now(),
Source: "sub/in",
Input: strings.Repeat("填充", 8000),
})
child.io.InjectInput("sub/in", "text", map[string]interface{}{"content": "继续"})
// 等父的**登记表**显示子已 contextfull(这是可稳定观测的信号;
// 中断任务本身会被调度器消费掉,事后再取快照往往已经空)。
waitFor(t, "父的登记表显示子 contextfull", func() bool {
for _, r := range parent.Residents() {
if r.ID == "child-vec" && r.ContextFull {
return true
}
}
return false
})
// 直接走向量表入口拿到中断本身:验证的是**契约**(source/正文/级别),
// 而不是调度时序。若只用快照看,中断可能已被消费,判据会假红。
v, ok := lookupVector(VectorChildContextFull)
if !ok {
t.Fatal("VectorChildContextFull 未登记")
}
src := v.Source(vectorArgs{ChildID: "child-vec"})
if !strings.Contains(src, "child-vec") {
t.Fatalf("向量 source=%q,应含子标识(父据此定位是哪个子)", src)
}
if v.Level != LevelCritical {
t.Fatalf("子 contextfull 必须是 L4,实际 %d", int(v.Level))
}
body := v.Build(vectorArgs{ChildID: "child-vec"})
for _, want := range []string{"压缩", "回收", "销毁"} {
if !strings.Contains(body, want) {
t.Fatalf("向量正文缺处置动作 %q(父将不知道该怎么办):%q", want, body)
}
}
// 反向:真实链路确实会调用到这个向量 —— 父侧登记表已标记,
// 而标记只能由 handleChildContextFull 设置。
rc.mu.Lock()
state := rc.state
rc.mu.Unlock()
if state != "contextfull" {
t.Fatalf("子侧 state 应为 contextfull(说明 handleChildContextFull 真的跑了),实际 %q", state)
}
}