主人实测:memory_items 1191 行、候选 77 条全 pending、memory_tidy_runs 仅 1 行、两晚定时没跑。 根因两条(都在这片里修掉): ① **以前根本没有执行面** —— 只有只读试算,候选永远 pending ② **调度被重启饿死** —— interval=24h 的 ticker 从进程启动算起,两天重启十几次 ⇒ 24h 永远走不完 - 应用路径:逐条 / 按 run 批量(默认 100、硬顶 500、超限 truncated)/ revert;**软合并** (status + merged_into/merged_from/merge_count,**绝不改 content、行不删**)+ 审计 (memory.tidy.apply / memory.tidy.revert)+ **幂等**(候选状态条件更新 + 行 status 闸两道) - 调度:改成**到点即跑**(catchUp:无历史+窗口内 / 距上次 run ≥ interval / 窗口起点晚于上次 run), 「上次 run」以 memory_tidy_runs 为**持久事实**;注入时钟用例证明**重启不饿死** - 手动真跑端点 POST /admin/memories/tidy/run(同档鉴权 + 审计 + 回执:新增/合并/软删/耗时) - 候选表加 run_id 列 + 索引(前滚;顺序=建表→补列→建索引,否则既有库会 no such column) - 3 个变异自检(删 catchUp / 去掉两道 status 闸 / truncated 恒 false) ⚠️ 生产库零动作:1191 行真跑与 77 条候选应用**未执行**(按红线由主人确认后主会话执行) ⚠️ 契约段在报告里(candidates 列表/apply/revert/tidy.run),Docs/ 待登记
169 lines
7.5 KiB
Go
169 lines
7.5 KiB
Go
package memory
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// 本文件是记忆整理(design/23 §10)的 schema 侧:新表(候选 / 运行)的 DDL、
|
|
// memory_items 整理列的前滚,以及两者的一处装配入口。
|
|
//
|
|
// 口径与 memory_items 一致(见 store.go 末尾的说明):领域表的权威机制是
|
|
// internal/db 的迁移(0019_memory_tidy),但记忆存储必须自包含、可在任意
|
|
// *sql.DB 上使用,因此 NewStore 也幂等地创建这些表、前滚这些列。两处定义
|
|
// 必须保持一致(tidy_schema_test.go 锁定)。
|
|
|
|
// createMemoryTidyCandidatesTable 是人工确认队列(design/23 §10)。
|
|
// idempotency_key 唯一:同一批数据重复跑不重复产候选(design/23 §9)。
|
|
//
|
|
// run_id 是**本实现在设计稿列集之上补的一列**(切片 TIDY-1):设计稿 §10 的
|
|
// 候选表没有「哪一轮产的」这一列,于是「按 run 批量应用」只能靠时间窗猜 ——
|
|
// 那是把幂等键的语义换成时间戳比较。run_id 让批量应用有确定的抓手,且
|
|
// **不进幂等键**(否则同一批数据每轮都会重复产候选),因此老语义逐字不变。
|
|
const createMemoryTidyCandidatesTable = `
|
|
CREATE TABLE IF NOT EXISTS memory_tidy_candidates (
|
|
id TEXT PRIMARY KEY,
|
|
persona_id TEXT NOT NULL,
|
|
scope TEXT NOT NULL,
|
|
project_id TEXT NOT NULL DEFAULT '',
|
|
kind TEXT NOT NULL DEFAULT '',
|
|
action TEXT NOT NULL,
|
|
source_ids TEXT NOT NULL DEFAULT '[]',
|
|
target_id TEXT NOT NULL DEFAULT '',
|
|
proposed_content TEXT NOT NULL DEFAULT '',
|
|
merged_content TEXT NOT NULL DEFAULT '',
|
|
reason TEXT NOT NULL DEFAULT '',
|
|
evidence TEXT NOT NULL DEFAULT '{}',
|
|
rule_version TEXT NOT NULL DEFAULT '',
|
|
score_min REAL NOT NULL DEFAULT 0,
|
|
score_max REAL NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
idempotency_key TEXT NOT NULL UNIQUE,
|
|
run_id TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL,
|
|
decided_at TEXT NOT NULL DEFAULT '',
|
|
decided_by TEXT NOT NULL DEFAULT ''
|
|
)`
|
|
|
|
const createMemoryTidyCandidatesStatusIndex = `
|
|
CREATE INDEX IF NOT EXISTS idx_memory_tidy_candidates_status
|
|
ON memory_tidy_candidates (status, scope)`
|
|
|
|
const createMemoryTidyCandidatesRunIndex = `
|
|
CREATE INDEX IF NOT EXISTS idx_memory_tidy_candidates_run
|
|
ON memory_tidy_candidates (run_id, status)`
|
|
|
|
// createMemoryTidyRunsTable 是整理运行的留痕(design/23 §4.1 F:留痕不靠
|
|
// 会话)。vetoed / veto_reasons 是本实现在设计稿列集之上补的两列:design/23
|
|
// §9 要求「每轮记否决原因分布」,只落日志就无法查询,故一并入库。
|
|
const createMemoryTidyRunsTable = `
|
|
CREATE TABLE IF NOT EXISTS memory_tidy_runs (
|
|
id TEXT PRIMARY KEY,
|
|
started_at TEXT NOT NULL,
|
|
finished_at TEXT NOT NULL DEFAULT '',
|
|
scanned INTEGER NOT NULL DEFAULT 0,
|
|
candidates INTEGER NOT NULL DEFAULT 0,
|
|
applied INTEGER NOT NULL DEFAULT 0,
|
|
skipped INTEGER NOT NULL DEFAULT 0,
|
|
errors INTEGER NOT NULL DEFAULT 0,
|
|
vetoed INTEGER NOT NULL DEFAULT 0,
|
|
veto_reasons TEXT NOT NULL DEFAULT '{}',
|
|
rule_version TEXT NOT NULL DEFAULT ''
|
|
)`
|
|
|
|
const createMemoryTidyRunsStartedIndex = `
|
|
CREATE INDEX IF NOT EXISTS idx_memory_tidy_runs_started_at
|
|
ON memory_tidy_runs (started_at)`
|
|
|
|
// tidyMemoryColumns 是 memory_items 上记忆整理所需的列与前滚 DDL。
|
|
// 全部带默认值 ⇒ 既有行行为逐字不变(design/23 §10)。
|
|
var tidyMemoryColumns = []struct {
|
|
name string
|
|
ddl string
|
|
}{
|
|
{"status", `ALTER TABLE memory_items ADD COLUMN status TEXT NOT NULL DEFAULT 'active'`},
|
|
{"merged_into", `ALTER TABLE memory_items ADD COLUMN merged_into TEXT NOT NULL DEFAULT ''`},
|
|
{"merged_from", `ALTER TABLE memory_items ADD COLUMN merged_from TEXT NOT NULL DEFAULT '[]'`},
|
|
{"merge_count", `ALTER TABLE memory_items ADD COLUMN merge_count INTEGER NOT NULL DEFAULT 1`},
|
|
{"occurred_at", `ALTER TABLE memory_items ADD COLUMN occurred_at TEXT NOT NULL DEFAULT ''`},
|
|
{"valid_to", `ALTER TABLE memory_items ADD COLUMN valid_to TEXT NOT NULL DEFAULT ''`},
|
|
{"expires_at", `ALTER TABLE memory_items ADD COLUMN expires_at TEXT NOT NULL DEFAULT ''`},
|
|
}
|
|
|
|
const createMemoryItemsStatusIndex = `
|
|
CREATE INDEX IF NOT EXISTS idx_memory_items_status
|
|
ON memory_items (persona_id, status)`
|
|
|
|
// ensureTidySchema 幂等地建整理所需的两张新表、前滚列、再建索引。它由 NewStore
|
|
// 调用,因此整理能力在任何 *sql.DB 上都可用(与 memory_items 自身同一口径)。
|
|
//
|
|
// 顺序**必须**是「建表 → 前滚列 → 建索引」:既有库上的候选表没有 run_id,
|
|
// 若先建 run_id 上的索引,SQLite 会报 "no such column: run_id"(旧实现里
|
|
// 索引只依赖建表时就有的列,因此没暴露这个顺序问题)。
|
|
func ensureTidySchema(ctx context.Context, db *sql.DB) error {
|
|
for _, ddl := range []string{
|
|
createMemoryTidyCandidatesTable,
|
|
createMemoryTidyRunsTable,
|
|
} {
|
|
if _, err := db.ExecContext(ctx, ddl); err != nil {
|
|
return fmt.Errorf("memory: tidy schema: %w", err)
|
|
}
|
|
}
|
|
if err := ensureTidyMemoryColumns(ctx, db); err != nil {
|
|
return err
|
|
}
|
|
if err := ensureTidyCandidateRunColumn(ctx, db); err != nil {
|
|
return err
|
|
}
|
|
for _, ddl := range []string{
|
|
createMemoryTidyCandidatesStatusIndex,
|
|
createMemoryTidyCandidatesRunIndex,
|
|
createMemoryTidyRunsStartedIndex,
|
|
createMemoryItemsStatusIndex,
|
|
} {
|
|
if _, err := db.ExecContext(ctx, ddl); err != nil {
|
|
return fmt.Errorf("memory: tidy schema: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ensureTidyCandidateRunColumn 把 run_id 前滚到**既有**的候选表上(切片 TIDY-1):
|
|
// CREATE TABLE IF NOT EXISTS 只在建表时生效,既有库的表已经建好,因此新列必须
|
|
// 单独补一次。手法与 ensureTidyMemoryColumns 一致:已存在时 SQLite 报
|
|
// "duplicate column name: run_id",那是预期路径。
|
|
//
|
|
// ⚠️ 与迁移的关系:internal/db 的 0019_memory_tidy 是权威迁移,本文件是
|
|
// 「记忆存储自包含、可在任意 *sql.DB 上使用」的前滚(两处口径必须一致)。
|
|
// 本列目前**只在内存包侧前滚**,未同步进 0019(改迁移文件超出本切片边界),
|
|
// 组合根每次启动都会跑到这里,因此生产库在下次重启时即可拿到该列。
|
|
func ensureTidyCandidateRunColumn(ctx context.Context, db *sql.DB) error {
|
|
if _, err := db.ExecContext(ctx,
|
|
`ALTER TABLE memory_tidy_candidates ADD COLUMN run_id TEXT NOT NULL DEFAULT ''`); err != nil {
|
|
if strings.Contains(err.Error(), "duplicate column name") {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("memory: roll forward tidy candidate run_id: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ensureTidyMemoryColumns 把 memory_items 的整理列前滚到既有表上:列缺失时
|
|
// 补加,已存在时忽略重复列错误。它是 SQLite 特有的(记忆存储基于 SQLite)
|
|
// 且幂等,与 ensureProjectIDColumn 同一手法。
|
|
func ensureTidyMemoryColumns(ctx context.Context, db *sql.DB) error {
|
|
for _, col := range tidyMemoryColumns {
|
|
if _, err := db.ExecContext(ctx, col.ddl); err != nil {
|
|
// 该列已存在(由 CREATE TABLE 或上一次运行创建)时报
|
|
// "duplicate column name: <col>",这是预期路径。
|
|
if strings.Contains(err.Error(), "duplicate column name") {
|
|
continue
|
|
}
|
|
return fmt.Errorf("memory: roll forward tidied column %s: %w", col.name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|