feat(memory): 记忆整理**应用路径 + 调度修复 + 手动真跑**(TIDY-1,主人投诉的根因)
主人实测: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/ 待登记
This commit is contained in:
1 parent
57d0e8c543
commit
454ddb0b70
18 files changed
+3181
-71
No files matched your search
@@ -2,7 +2,9 @@ package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -55,21 +57,47 @@ const countTidyPartitionItemsSQL = `SELECT COUNT(*) FROM memory_items
|
||||
const insertTidyCandidateSQL = `INSERT OR IGNORE INTO memory_tidy_candidates
|
||||
(id, persona_id, scope, project_id, kind, action, source_ids, target_id,
|
||||
proposed_content, merged_content, reason, evidence, rule_version,
|
||||
score_min, score_max, status, idempotency_key, created_at, decided_at, decided_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '')`
|
||||
score_min, score_max, status, idempotency_key, run_id, created_at, decided_at, decided_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '')`
|
||||
|
||||
const insertTidyRunSQL = `INSERT INTO memory_tidy_runs
|
||||
(id, started_at, finished_at, scanned, candidates, applied, skipped, errors,
|
||||
vetoed, veto_reasons, rule_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
const listTidyCandidatesSQL = `SELECT id, persona_id, scope, project_id, kind, action,
|
||||
// listTidyCandidatesByRunSQL 按 **run_id + status** 取候选(最新在前)。run_id
|
||||
// 传空串表示「不按轮次筛」—— 那正是「全部待定应用」这条粒度(切片 TIDY-1
|
||||
// 三种粒度里最粗的一档)。
|
||||
const listTidyCandidatesByRunSQL = `SELECT id, persona_id, scope, project_id, kind, action,
|
||||
source_ids, target_id, proposed_content, merged_content, reason, evidence,
|
||||
rule_version, score_min, score_max, status, idempotency_key, created_at
|
||||
rule_version, score_min, score_max, status, idempotency_key, run_id, created_at
|
||||
FROM memory_tidy_candidates
|
||||
WHERE status = ?
|
||||
WHERE status = ? AND (? = '' OR run_id = ?)
|
||||
ORDER BY created_at DESC, rowid DESC LIMIT ?`
|
||||
|
||||
const countTidyCandidatesByRunSQL = `SELECT COUNT(*) FROM memory_tidy_candidates
|
||||
WHERE status = ? AND (? = '' OR run_id = ?)`
|
||||
|
||||
const selectTidyCandidateSQL = `SELECT id, persona_id, scope, project_id, kind, action,
|
||||
source_ids, target_id, proposed_content, merged_content, reason, evidence,
|
||||
rule_version, score_min, score_max, status, idempotency_key, run_id, created_at
|
||||
FROM memory_tidy_candidates WHERE id = ?`
|
||||
|
||||
const selectTidyRunSQL = `SELECT id, started_at, finished_at, scanned, candidates,
|
||||
applied, skipped, errors, vetoed, veto_reasons, rule_version
|
||||
FROM memory_tidy_runs WHERE id = ?`
|
||||
|
||||
// selectLastTidyRunSQL 取最近一轮整理的开始时刻(切片 TIDY-1 的调度判据):
|
||||
// 「上次 run 的时刻」是**持久事实**,不是进程内记忆 —— 后端两天重启十几次
|
||||
// 正是旧实现(只看内存 lastRun)永远走不完 24h 周期的根因。
|
||||
const selectLastTidyRunSQL = `SELECT started_at FROM memory_tidy_runs
|
||||
ORDER BY started_at DESC, rowid DESC LIMIT 1`
|
||||
|
||||
// incrementTidyRunAppliedSQL 在批量应用之后把该轮的 applied 计数累加上去
|
||||
// (design/23 §10 的 memory_tidy_runs.applied)。运行时那一轮写的是 0(当轮
|
||||
// 绝不自动应用,§7),这里补的是「事后来应用了几条」。
|
||||
const incrementTidyRunAppliedSQL = `UPDATE memory_tidy_runs SET applied = applied + ? WHERE id = ?`
|
||||
|
||||
const listTidyRunsSQL = `SELECT id, started_at, finished_at, scanned, candidates,
|
||||
applied, skipped, errors, vetoed, veto_reasons, rule_version
|
||||
FROM memory_tidy_runs ORDER BY started_at DESC, rowid DESC LIMIT ?`
|
||||
@@ -141,7 +169,7 @@ func (s *Store) insertTidyCandidate(ctx context.Context, c TidyCandidate) (bool,
|
||||
c.ID, c.PersonaID, string(c.Scope), c.ProjectID, string(c.Kind), string(c.Action),
|
||||
string(sourceJSON), c.TargetID, c.ProposedContent, c.MergedContent, c.Reason,
|
||||
string(evidenceJSON), c.RuleVersion, c.ScoreMin, c.ScoreMax, c.Status,
|
||||
c.IdempotencyKey, formatTime(c.CreatedAt))
|
||||
c.IdempotencyKey, c.RunID, formatTime(c.CreatedAt))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("memory: insert tidy candidate: %w", err)
|
||||
}
|
||||
@@ -174,13 +202,25 @@ func (s *Store) persistTidyRun(ctx context.Context, run TidyRun) error {
|
||||
// 非正 limit 取 defaultTidyCandidateLimit)。它是读侧入口:候选正文含隐私,
|
||||
// 只应由 owner-only 的读侧使用(design/23 §11)。
|
||||
func (s *Store) ListTidyCandidates(ctx context.Context, status string, limit int) ([]TidyCandidate, error) {
|
||||
return s.listTidyCandidates(ctx, status, "", limit)
|
||||
}
|
||||
|
||||
// ListTidyCandidatesByRun 返回某一轮整理产出的候选(status 默认 pending)。
|
||||
// runID 为空串表示**不按轮次筛** —— 即「全部待定应用」那一档(切片 TIDY-1)。
|
||||
func (s *Store) ListTidyCandidatesByRun(ctx context.Context, runID, status string, limit int) ([]TidyCandidate, error) {
|
||||
return s.listTidyCandidates(ctx, status, runID, limit)
|
||||
}
|
||||
|
||||
// listTidyCandidates 是上面两个读侧入口的共用实现:runID 为空串时走「不筛轮次」
|
||||
// 的 SQL 分支(`? = ” OR run_id = ?`),因此只有一个查询形状要维护。
|
||||
func (s *Store) listTidyCandidates(ctx context.Context, status, runID string, limit int) ([]TidyCandidate, error) {
|
||||
if strings.TrimSpace(status) == "" {
|
||||
status = tidyCandidatePending
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = defaultTidyCandidateLimit
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, listTidyCandidatesSQL, status, limit)
|
||||
rows, err := s.db.QueryContext(ctx, listTidyCandidatesByRunSQL, status, runID, runID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: list tidy candidates: %w", err)
|
||||
}
|
||||
@@ -200,6 +240,73 @@ func (s *Store) ListTidyCandidates(ctx context.Context, status string, limit int
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetTidyCandidate 按 id 读一条候选(含已决定的行)。不存在时返回 ErrNotFound。
|
||||
func (s *Store) GetTidyCandidate(ctx context.Context, id string) (TidyCandidate, error) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return TidyCandidate{}, ErrNotFound
|
||||
}
|
||||
row := s.db.QueryRowContext(ctx, selectTidyCandidateSQL, id)
|
||||
c, err := scanTidyCandidate(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return TidyCandidate{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return TidyCandidate{}, fmt.Errorf("memory: get tidy candidate: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// countTidyCandidates 数某状态下(可选按轮次)的候选总数。批量应用用它并把
|
||||
// 「真总数 vs 本轮应用上限」如实回报给调用方(超限绝不静默)。
|
||||
func (s *Store) countTidyCandidates(ctx context.Context, status, runID string) (int, error) {
|
||||
if strings.TrimSpace(status) == "" {
|
||||
status = tidyCandidatePending
|
||||
}
|
||||
var total int
|
||||
if err := s.db.QueryRowContext(ctx, countTidyCandidatesByRunSQL, status, runID, runID).
|
||||
Scan(&total); err != nil {
|
||||
return 0, fmt.Errorf("memory: count tidy candidates: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// GetTidyRun 按 id 读一条运行留痕。不存在时返回 ErrTidyRunRecordNotFound ——
|
||||
// 面板拿一个拼错的 runId 来批量应用时,必须是「查不到这一轮」而不是「这一轮
|
||||
// 没有待定候选」。
|
||||
func (s *Store) GetTidyRun(ctx context.Context, id string) (TidyRun, error) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return TidyRun{}, ErrTidyRunRecordNotFound
|
||||
}
|
||||
row := s.db.QueryRowContext(ctx, selectTidyRunSQL, id)
|
||||
r, err := scanTidyRun(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return TidyRun{}, ErrTidyRunRecordNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return TidyRun{}, fmt.Errorf("memory: get tidy run: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// LastTidyRunStartedAt 返回最近一轮整理的开始时刻;一次都没跑过时返回零值
|
||||
// 时间与 nil(**不是**错误:那是「还没有历史」这一正常状态,调度据此判断
|
||||
// 是否需要补跑)。
|
||||
func (s *Store) LastTidyRunStartedAt(ctx context.Context) (time.Time, error) {
|
||||
var startedAt string
|
||||
err := s.db.QueryRowContext(ctx, selectLastTidyRunSQL).Scan(&startedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("memory: read last tidy run: %w", err)
|
||||
}
|
||||
t, err := parseTime(startedAt)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ListTidyRuns 返回整理运行留痕(最新在前,最多 limit 条)。非正 limit 取
|
||||
// 一个保守的默认条数。
|
||||
func (s *Store) ListTidyRuns(ctx context.Context, limit int) ([]TidyRun, error) {
|
||||
@@ -214,26 +321,10 @@ func (s *Store) ListTidyRuns(ctx context.Context, limit int) ([]TidyRun, error)
|
||||
|
||||
out := make([]TidyRun, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
r TidyRun
|
||||
startedAt string
|
||||
finishedAt string
|
||||
reasonsJSON string
|
||||
)
|
||||
if err := rows.Scan(&r.ID, &startedAt, &finishedAt, &r.Scanned, &r.Candidates,
|
||||
&r.Applied, &r.Skipped, &r.Errors, &r.Vetoed, &reasonsJSON, &r.RuleVersion); err != nil {
|
||||
r, err := scanTidyRun(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: scan tidy run: %w", err)
|
||||
}
|
||||
var err error
|
||||
if r.StartedAt, err = parseTime(startedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.FinishedAt, err = parseTime(finishedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(reasonsJSON), &r.VetoReasons); err != nil {
|
||||
return nil, fmt.Errorf("memory: decode tidy veto reasons: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -242,6 +333,31 @@ func (s *Store) ListTidyRuns(ctx context.Context, limit int) ([]TidyRun, error)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// scanTidyRun 读取一行运行留痕:veto_reasons 以 JSON 存储,时间戳为定宽 UTC 文本。
|
||||
func scanTidyRun(sc rowScanner) (TidyRun, error) {
|
||||
var (
|
||||
r TidyRun
|
||||
startedAt string
|
||||
finishedAt string
|
||||
reasonsJSON string
|
||||
)
|
||||
if err := sc.Scan(&r.ID, &startedAt, &finishedAt, &r.Scanned, &r.Candidates,
|
||||
&r.Applied, &r.Skipped, &r.Errors, &r.Vetoed, &reasonsJSON, &r.RuleVersion); err != nil {
|
||||
return TidyRun{}, err
|
||||
}
|
||||
var err error
|
||||
if r.StartedAt, err = parseTime(startedAt); err != nil {
|
||||
return TidyRun{}, err
|
||||
}
|
||||
if r.FinishedAt, err = parseTime(finishedAt); err != nil {
|
||||
return TidyRun{}, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(reasonsJSON), &r.VetoReasons); err != nil {
|
||||
return TidyRun{}, fmt.Errorf("memory: decode tidy veto reasons: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// scanTidyCandidate 读取一行候选:source_ids 与 evidence 以 JSON 存储。
|
||||
func scanTidyCandidate(sc rowScanner) (TidyCandidate, error) {
|
||||
var (
|
||||
@@ -255,7 +371,7 @@ func scanTidyCandidate(sc rowScanner) (TidyCandidate, error) {
|
||||
if err := sc.Scan(&c.ID, &c.PersonaID, &scope, &c.ProjectID, &kind, &action,
|
||||
&sourceJSON, &c.TargetID, &c.ProposedContent, &c.MergedContent, &c.Reason,
|
||||
&evidenceRaw, &c.RuleVersion, &c.ScoreMin, &c.ScoreMax, &c.Status,
|
||||
&c.IdempotencyKey, &createdAt); err != nil {
|
||||
&c.IdempotencyKey, &c.RunID, &createdAt); err != nil {
|
||||
return TidyCandidate{}, err
|
||||
}
|
||||
c.Scope = Scope(scope)
|
||||
|
||||
Reference in new issue
Block a user