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
This commit is contained in:
JianFeeeee
2026-10-08 10:41:07 +08:00
parent e6c1bfa6bf
commit 9036db9321
10 changed files with 568 additions and 81 deletions

View File

@ -463,13 +463,31 @@ type BlockNeighbour struct {
// 返回**重定向的边数**(不含被去重丢弃的重复边)。
// 幂等:源块不存在时返回 (0, nil)。
func (g *GraphDB) MergeBlocks(sourceText, targetText string) (int, error) {
n, srcID, dstID, err := g.mergeBlocksLocked(sourceText, targetText)
// ★ 上报必须在锁释放后(见 GraphDB.onAccess 的死锁说明)。
// 合入事件驱动星图的「两节点消失又出现」:源块被删、目标块接纳,
// 所以 Removed 给 src、Blocks 给 dst(前端据此播「消失→出现」时序)。
if err == nil && srcID != "" {
g.emitAccess(AccessEvent{
Op: "merge",
Blocks: []string{dstID},
Removed: []string{srcID},
Merged: []MergePair{{From: srcID, To: dstID}},
})
}
return n, err
}
// mergeBlocksLocked 是 MergeBlocks 的实体(持 g.mu),
// 额外返回源/目标块 ID 供外部上报(上报不能在持锁时做)。
func (g *GraphDB) mergeBlocksLocked(sourceText, targetText string) (int, string, string, error) {
sourceText = strings.TrimSpace(sourceText)
targetText = strings.TrimSpace(targetText)
if sourceText == "" || targetText == "" {
return 0, fmt.Errorf("merge requires both source and target")
return 0, "", "", fmt.Errorf("merge requires both source and target")
}
if sourceText == targetText {
return 0, fmt.Errorf("merge source and target are identical: %q", sourceText)
return 0, "", "", fmt.Errorf("merge source and target are identical: %q", sourceText)
}
g.mu.Lock()
@ -477,7 +495,7 @@ func (g *GraphDB) MergeBlocks(sourceText, targetText string) (int, error) {
src, err := g.blockByTextTxLocked(sourceText)
if err != nil {
return 0, err
return 0, "", "", err
}
if src == nil {
// ★ 必须报错,不能返回 (0, nil)。
@ -495,25 +513,25 @@ func (g *GraphDB) MergeBlocks(sourceText, targetText string) (int, error) {
// 旧 MergeEntities 在这里返回 error,是对的。
// 幂等由**调用方**判断(合并后不要再合并同一个源),
// 不该由存储层替它猜。
return 0, fmt.Errorf("merge source block %q does not exist", sourceText)
return 0, "", "", fmt.Errorf("merge source block %q does not exist", sourceText)
}
dst, err := g.blockByTextTxLocked(targetText)
if err != nil {
return 0, err
return 0, "", "", err
}
if dst == nil {
// ★ 目标不存在必须报错。
// 返回 0 会让调用方以为合并成功了,而实际上什么都没做 ——
// 那正是「静默失效」的一种。
return 0, fmt.Errorf("merge target block %q does not exist", targetText)
return 0, "", "", fmt.Errorf("merge target block %q does not exist", targetText)
}
if src.ID == dst.ID {
return 0, nil
return 0, "", "", nil
}
tx, err := g.db.Begin()
if err != nil {
return 0, err
return 0, "", "", err
}
defer tx.Rollback()
@ -528,7 +546,7 @@ func (g *GraphDB) MergeBlocks(sourceText, targetText string) (int, error) {
AND COALESCE(session_id,'') != ''`,
dst.ID, src.ID)
if err != nil {
return 0, fmt.Errorf("redirect source edges: %w", err)
return 0, "", "", fmt.Errorf("redirect source edges: %w", err)
}
nOut, _ := res.RowsAffected()
@ -538,7 +556,7 @@ func (g *GraphDB) MergeBlocks(sourceText, targetText string) (int, error) {
AND COALESCE(session_id,'') != ''`,
dst.ID, src.ID)
if err != nil {
return 0, fmt.Errorf("redirect target edges: %w", err)
return 0, "", "", fmt.Errorf("redirect target edges: %w", err)
}
nIn, _ := res.RowsAffected()
@ -567,7 +585,7 @@ func (g *GraphDB) MergeBlocks(sourceText, targetText string) (int, error) {
AND COALESCE(session_id,'') != ''
AND COALESCE(status,'') != 'deleted'`,
dst.ID, dst.ID, dst.ID, dst.ID); err != nil {
return 0, fmt.Errorf("dedupe self-loops: %w", err)
return 0, "", "", fmt.Errorf("dedupe self-loops: %w", err)
}
// ── ③ 清理指向源块的结构边 ────────────────────────────────
@ -580,12 +598,12 @@ func (g *GraphDB) MergeBlocks(sourceText, targetText string) (int, error) {
WHERE (source_kind='block' AND source_id=? AND COALESCE(session_id,'')='')
OR (target_kind='block' AND target_id=? AND COALESCE(session_id,'')='')`,
src.ID, src.ID); err != nil {
return 0, fmt.Errorf("drop structural edges of source: %w", err)
return 0, "", "", fmt.Errorf("drop structural edges of source: %w", err)
}
// ── ④ 删源块 ─────────────────────────────────────────────
if _, err := tx.Exec(`DELETE FROM memory_blocks WHERE id = ?`, src.ID); err != nil {
return 0, fmt.Errorf("delete source block: %w", err)
return 0, "", "", fmt.Errorf("delete source block: %w", err)
}
// ── ⑤ 场景引用同步 ───────────────────────────────────────
@ -596,13 +614,13 @@ func (g *GraphDB) MergeBlocks(sourceText, targetText string) (int, error) {
`UPDATE scene_refs SET ref_text = ?
WHERE kind = 'block' AND ref_text = ?`,
dst.ID, src.ID); err != nil {
return 0, fmt.Errorf("repoint scene refs: %w", err)
return 0, "", "", fmt.Errorf("repoint scene refs: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, err
return 0, "", "", err
}
return int(nOut + nIn), nil
return int(nOut + nIn), src.ID, dst.ID, nil
}
// blockByTextTxLocked 按文本取最早的块(调用方已持锁)。