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

@ -214,8 +214,9 @@ func (e *Extractor) Split(ctx context.Context, record string) ([]memory.Triple,
// ★ **判据能显示 0,是因为它只看得到 0。**
// 观测面不够 ⇒ 只能看到失败,看不到原因。
raw := resp.Text
if len(raw) > 400 {
raw = raw[:400] + "…"
// 按**字符**截断(raw 是模型输出的中文文本,字节切会切坏多字节字符)。
if r := []rune(raw); len(r) > 400 {
raw = string(r[:400]) + "…"
}
if resp.Truncated {
// 截断的 JSON 不可救,也不该救 —— 重试比丢弃更划算(模型是随机的)。

View File

@ -671,6 +671,15 @@ func (g *GraphDB) commitLocked(triples []Triple, sessionID string, turnID int, t
continue
}
if !validEntityName(t.Subject) || !validEntityName(t.Object) {
// ★ 拒绝必须可见:此前是静默 continue,导致"提交了 N 条但 0 写入"
// 完全无从查起(实测:doc->graph 连续 52 次 wrote no blocks,
// 而日志只说"被实体名校验拒绝",看不到到底拒了什么)。
//
// 只打**被拒的那一侧**与原文,便于直接判断是抽取质量问题
// 还是校验过严(validEntityName 只要求 2~50 字符且含中文/字母/数字)。
log.Printf("[graph] reject triple: subject=%q(%s) relation=%q object=%q(%s) "+
"— failed validEntityName (need 2..50 runes with letter/digit)",
t.Subject, t.SubjectType, t.Relation, t.Object, t.ObjectType)
continue
}