Files
HomeAgent/internal/memory/access_hook_test.go
JianFeeeee 9036db9321 feat(memory): 图数据库自报真实读写事件,取代「按工具名猜节点」
## 问题(生产实证)

星图的「跟随 agent 活动」此前靠**工具名词元猜节点**:把 `memory_recall`
拆成 [memory, recall] 去匹配实体名。而图谱里的实体名是**中文概念**
(小宅/对话/待命),工具名是英文 —— 两套命名空间永不交集。

实测(生产库)每个工具名命中 **0** 个节点:

    hint=memory_recall  → memory/recall  → 0
    hint=files_read     → files/read     → 0
    hint=knowledge_search → knowledge/search → 0

匹配失败后走「补齐」分支,按 mention_count 取前 8 个 ⇒ **无论 agent 在干什么,
点亮的都是同一批无关节点**(小宅/对话/待命/投递/图片/文档/插件/老大)。
这就是「星图没有读写反馈」的真身。

## 改法:让图数据库在真正读写处上报

新增 `events.EventMemoryAccess` + `GraphDB.AccessHook`,在读写入口埋点上报
**真实块 ID**(不再猜):

| 操作 | 埋点 | 上报 |
|---|---|---|
| 读 | RecallSorted | blocks |
| 写 | commit | blocks + **created**(真正新建的) |
| 删 | DeleteEntity | removed |
| 合 | MergeBlocks | merged(from→to) + removed |

三处关键设计:

1. **钩子用独立锁** `hookMu`。用 `g.mu` 会**自死锁** —— 埋点方法已持该锁,
   而 Go 的 RWMutex 不可重入。
2. **上报在锁外**。拆 `commit`→`commitLocked` 两层解决 `defer Unlock` 的时序:
   钩子内部可能要回调内存系统(如 SSE 订阅者补数据),持锁调用必死锁。
3. **区分「新建」与「更新」**(`putBlockTx` 返回 isNew)。用 SELECT 判存在性
   而非 `RETURNING (xmax = 0)` —— 后者在**事务 + ON CONFLICT** 下报
   `no such column: xmax`(实测:autocommit 可用、包进 tx 就不可用),
   而本函数只在事务里被调用。重复提交同一事实不会重复播「生长」动画。

接线:`bootstrap.newMainAgent` 里 `memDB.SetAccessHook(...)` → `events.Bus`。
只在 `evBus != nil` 时接,把「内核进程」与「独立 CLI 工具」
(homed-graph-migrate / memgc 也用 NewGraphDB)区分开 —— 后者不该为一次
批量迁移向不存在的订阅者发几万条事件。

## 验证

- `access_hook_test.go`:钉住 commit/recall/delete 三类埋点真的触发、
  且重复提交不误报新建
- `access_hook_race_test.go`:4 写 + 4 读 + 钩子热替换并发,`-race` 下无竞态、
  无死锁;**断言事件数 > 0**(否则「钩子从未触发」也会假通过)
- 该测试起初是 flaky 的(读者可能在写者落库前查询 ⇒ 空结果不报事件 ⇒
  断言随机失败)。修法是先播种确定数据再并发读,已连跑 3 次稳定
- 生产实例端到端:CLI 注入一句话 → SSE 收到 6 条 memory_access,
  带真实块 ID 与 op/created
2026-10-08 10:41:07 +08:00

84 lines
2.1 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 memory
import (
"path/filepath"
"testing"
)
// 钩子必须真的在 commit / recall / delete / merge 上触发,且 ID 真实。
func TestAccessHookFires(t *testing.T) {
dir := t.TempDir()
g, err := NewGraphDB(filepath.Join(dir, "g.db"))
if err != nil {
t.Fatal(err)
}
defer g.Close()
var got []AccessEvent
g.SetAccessHook(func(ev AccessEvent) { got = append(got, ev) })
// commit:应上报 written + created
if _, _, err := g.Commit([]Triple{{
Subject: "张三", Relation: "喜欢", Object: "咖啡",
SentenceText: "张三喜欢咖啡",
}}, "s1", 1); err != nil {
t.Fatal(err)
}
if len(got) == 0 || got[len(got)-1].Op != "commit" {
t.Fatalf("commit 未触发钩子: %+v", got)
}
last := got[len(got)-1]
t.Logf("commit: blocks=%d created=%d", len(last.Blocks), len(last.Created))
if len(last.Blocks) == 0 {
t.Fatal("commit 应上报写入的块 ID")
}
if len(last.Created) == 0 {
t.Fatal("首次写入应有新建块(驱动生长动画)")
}
// 重复提交同一三元组:不应再报 created(否则动画重复播)
got = nil
if _, _, err := g.Commit([]Triple{{
Subject: "张三", Relation: "喜欢", Object: "咖啡",
SentenceText: "张三喜欢咖啡",
}}, "s1", 1); err != nil {
t.Fatal(err)
}
if len(got) > 0 && len(got[len(got)-1].Created) != 0 {
t.Fatalf("重复写入不应报新建,实际 =%v", got[len(got)-1].Created)
}
t.Log("重复 commit 未误报新建 ✓")
// recall:应上报读到的块
got = nil
if _, err := g.Recall([]string{"咖啡"}, nil, 1, ""); err != nil {
t.Fatal(err)
}
found := false
for _, ev := range got {
if ev.Op == "recall" && len(ev.Blocks) > 0 {
found = true
t.Logf("recall: blocks=%v", ev.Blocks)
}
}
if !found {
t.Fatalf("recall 未上报块 ID: %+v", got)
}
// delete:应上报 removed
got = nil
if _, err := g.DeleteEntity("张三"); err != nil {
t.Fatal(err)
}
found = false
for _, ev := range got {
if ev.Op == "delete" && len(ev.Removed) > 0 {
found = true
t.Logf("delete: removed=%v", ev.Removed)
}
}
if !found {
t.Fatalf("delete 未上报被删块: %+v", got)
}
}