feat(memory): N2a 第一块砖 —— 主图记忆的受限句柄(query_only),子是"读得到写不进"

按用户确认的形态:**独立存储实例**(不是给共享记忆层加 space 列)。

## 结构性保证

新增 `OpenGraphDBReadOnly(path)`:以**受限句柄**打开图库 ——
连接保持正常打开能力(可读、可恢复 WAL),但 `PRAGMA query_only=1` 让
任何 INSERT/UPDATE/DELETE 被 SQLite **直接拒绝**。

为什么不用 DSN 的 `mode=ro`:只读连接在 WAL 库上无法自行恢复 -wal,
而主库在父 agent 手里是持续写入的。query_only 只堵写、不堵读,语义正好。

⇒ "子改不了主记忆"是**结构性**的,不靠调用方自觉;也不建表、不迁移
(库由父建好,受限句柄不会凭空造出一个空主库)。

## 设计文档

新增 §5.6「实现形态:独立存储实例(不做 space 列)」,写明轻量内核的记忆装配:

    子的轻量内核
    ├─ temp 图记忆实例(独立存储,读写)  ← 与子同生共死
    └─ 主图记忆的受限句柄(只读)
    子的查询 = 两个实例各查一次 + 应用层合并(并集)
    回收时由父读 temp、选记录、写进主图记忆

并记录用户给的备选简化:`AllowTempGraphWrite` 开关(默认开);设为 false 时
子对图记忆完全只读,没有 temp 实例、没有合入。

## 验收

`internal/memory/graph_readonly_test.go`(2 项):
- 受限句柄读得到、写被拒(Commit/Purge 双双报错),且**主库不留痕迹**
- 库不存在时受限句柄的查询报错,而不是凭空建表后返回空结果

全仓 go test ./... 37 包 ok / 0 FAIL。
This commit is contained in:
JianFeeeee
2026-09-13 09:32:29 +08:00
parent 36e7556a02
commit 818ce2698f
3 changed files with 124 additions and 0 deletions

View File

@ -71,6 +71,29 @@ func NewGraphDB(dbPath string) (*GraphDB, error) {
return g, nil
}
// OpenGraphDBReadOnly 以**受限句柄**打开图库:可读、可恢复 WAL但**一切写入被拒**。
//
// 这是"子 agent 改不了主记忆"的**结构性**保证(设计 docs/zh/resident-subagent-design.md
// §5不是靠调用方自觉不写而是把写入在 SQLite 这一层就关掉
// `PRAGMA query_only=1` —— 任何 INSERT/UPDATE/DELETE 都会直接报错)。
//
// 为什么不用 DSN 的 `mode=ro`:只读模式的连接在 WAL 库上无法自行恢复 -wal
// 而主库在父 agent 手里是持续写入的。query_only 让连接保持正常打开能力,
// 同时**只堵写**,语义正是我们要的。
//
// 注意:不建表、不迁移 —— 受限句柄假定库已存在(由父 agent 建好)。
func OpenGraphDBReadOnly(dbPath string) (*GraphDB, error) {
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=on")
if err != nil {
return nil, fmt.Errorf("open graph db (readonly): %w", err)
}
if _, err := db.Exec("PRAGMA query_only=1"); err != nil {
_ = db.Close()
return nil, fmt.Errorf("set query_only: %w", err)
}
return &GraphDB{db: db, dbPath: dbPath}, nil
}
func (g *GraphDB) initSchema() error {
g.mu.Lock()
defer g.mu.Unlock()