Files
Cyrene_v2/internal/runtime/memory/tidy_service_test.go
T
AskaEth 454ddb0b70
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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/ 待登记
2026-10-06 22:39:35 +08:00

219 lines
8.0 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 (
"context"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件锁定记忆整理的**运行隔离与心跳口径**(design/23 §4/§4.1/§9):
// 独立会话标识、低峰调度、可开关、手动可触发、审计留痕。
// fixedTidyClock 返回固定时刻的时钟,好让调度断言不依赖真实时间。
func fixedTidyClock(at time.Time) func() time.Time {
return func() time.Time { return at }
}
// newTidyServiceForTest 构建带固定时钟与可选审计出口的整理服务。
func newTidyServiceForTest(t *testing.T, store *Store, cfg TidyConfig, emb embedding.Embedder,
clock func() time.Time, audit func(who, what, result string)) *TidyService {
t.Helper()
svc, err := NewTidyService(cfg, TidyDeps{
Store: store, Embedder: emb, Audit: audit, Log: testLogger(), Now: clock,
})
if err != nil {
t.Fatalf("NewTidyService: %v", err)
}
return svc
}
// TestTidyServiceNilStoreRejected:没有记忆存储就没有整理(依赖面收窄到最小)。
func TestTidyServiceNilStoreRejected(t *testing.T) {
t.Parallel()
if _, err := NewTidyService(TidyConfig{Enabled: true}, TidyDeps{Log: testLogger()}); err == nil {
t.Fatal("缺 Store 应当报错")
}
}
// TestTidySessionIDIsNotAConversationLane 锁定 design/23 §4.1 B:整理跑在
// 专属隔离会话,绝不是主人的对话车道(cmd/cyrene 的 isConversationLane
// 对 "web"/"web:*" 才为 true)。
func TestTidySessionIDIsNotAConversationLane(t *testing.T) {
t.Parallel()
if TidySessionID == "web" || strings.HasPrefix(TidySessionID, "web:") {
t.Fatalf("整理会话 %q 落在主人对话车道上", TidySessionID)
}
svc := newTidyServiceForTest(t, newTestStore(t), tidyTestCfg(0.80), nil, time.Now, nil)
if svc.SessionID() != TidySessionID {
t.Fatalf("SessionID() = %q, want %q", svc.SessionID(), TidySessionID)
}
}
// TestTidyServiceNextDelay 锁定低峰调度:下一个低峰窗口起点、周期下限、
// 窗口关闭时退回纯周期,且等待恒有下界(不空转)。
func TestTidyServiceNextDelay(t *testing.T) {
t.Parallel()
store := newTestStore(t)
// 15:00 起跑,低峰 23:00-07:00 ⇒ 等 8 小时。
at := time.Date(2026, 10, 5, 15, 0, 0, 0, time.Local)
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, fixedTidyClock(at), nil)
if got, want := svc.nextDelay(), 8*time.Hour; got != want {
t.Fatalf("nextDelay = %s, want %s", got, want)
}
// 23:30 起跑 ⇒ 下一个窗口起点是明天 23:00(约 23.5 小时),且不早于
// 「上次运行 + 周期(24h)」。
late := time.Date(2026, 10, 5, 23, 30, 0, 0, time.Local)
svc = newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, fixedTidyClock(late), nil)
svc.lastRun = time.Date(2026, 10, 5, 23, 0, 0, 0, time.Local)
if got := svc.nextDelay(); got < 23*time.Hour || got > 24*time.Hour {
t.Fatalf("周期下限未生效:nextDelay = %s, want ≈ 23.5h 且 ≤ 24h", got)
}
// 窗口关闭(两端相等且非 0)⇒ 纯周期路径。
cfg := tidyTestCfg(0.80)
cfg.QuietStartHour, cfg.QuietEndHour = 9, 9
svc = newTidyServiceForTest(t, store, cfg, nil, fixedTidyClock(at), nil)
if got, want := svc.nextDelay(), 24*time.Hour; got != want {
t.Fatalf("窗口关闭时 nextDelay = %s, want %s", got, want)
}
}
// TestTidyServiceStartOutsideQuietWindowDoesNotRunImmediately 锁定补跑判据的
// 一条边界(切片 TIDY-1):**无历史**且启动在安静窗**之外**时不做启动即跑 ——
// 新装的库不该在任意时刻烧一次全库向量化扫描,等窗口起点那一轮即可。
// 时钟是注入的固定值,因此这条断言与用例真的跑在几点无关(旧实现用
// time.Now,在 23:00–07:00 之间跑就会抖动)。
func TestTidyServiceStartOutsideQuietWindowDoesNotRunImmediately(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
clock := &schedClock{at: time.Date(2026, 10, 7, 15, 0, 0, 0, time.Local)}
probe := newTidyWaitProbe(clock, 0) // 绝不真的等到窗口起点
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil)
svc.wait = probe.wait
svc.Start(ctx)
probe.awaitCalls(t, 1)
if err := svc.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
runs, err := store.ListTidyRuns(ctx, 10)
if err != nil {
t.Fatalf("ListTidyRuns: %v", err)
}
if len(runs) != 0 {
t.Fatalf("窗口外无历史时不该启动即跑:%+v", runs)
}
}
// TestTidyServiceDisabledStartIsNoop:关闭是显式选择 —— 不跑、不报错。
func TestTidyServiceDisabledStartIsNoop(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
cfg := tidyTestCfg(0.80)
cfg.Enabled = false
svc := newTidyServiceForTest(t, store, cfg, nil, time.Now, nil)
if svc.Enabled() {
t.Fatal("Enabled() 应为 false")
}
svc.Start(ctx)
if err := svc.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
runs, err := store.ListTidyRuns(ctx, 10)
if err != nil {
t.Fatalf("ListTidyRuns: %v", err)
}
if len(runs) != 0 {
t.Fatalf("关闭时仍跑了整理:%+v", runs)
}
}
// TestTidyServiceManualRunAndAudit:「立即整理」入口可手动触发一轮,
// 并写一条 memory.tidy.run 审计(design/23 §8;审计只带计数、不带正文)。
func TestTidyServiceManualRunAndAudit(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
type auditRow struct{ who, what, result string }
var rows []auditRow
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80),
stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
fixtureHabitLong: simVector(0.95),
}}, time.Now, func(who, what, result string) {
rows = append(rows, auditRow{who, what, result})
})
stats, err := svc.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.Candidates != 1 {
t.Fatalf("手动一轮候选 = %d, want 1", stats.Candidates)
}
if len(rows) != 1 {
t.Fatalf("审计行数 = %d, want 1", len(rows))
}
if rows[0].what != "memory.tidy.run" || rows[0].who != tidyAuditWho {
t.Fatalf("审计 who/what = %q/%q", rows[0].who, rows[0].what)
}
if strings.Contains(rows[0].result, fixtureHabitShort) {
t.Fatal("审计里不得出现记忆正文")
}
runs, err := store.ListTidyRuns(ctx, 10)
if err != nil {
t.Fatalf("ListTidyRuns: %v", err)
}
if len(runs) != 1 || runs[0].RuleVersion != TidyRuleVersion || runs[0].Applied != 0 {
t.Fatalf("运行留痕 = %+v, want 1 条、规则版本 %s、applied=0(不自动应用)", runs, TidyRuleVersion)
}
}
// TestTidyRunRecordedVetoDistribution:否决原因分布进运行表(可查询,不只在日志里)。
func TestTidyRunRecordedVetoDistribution(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, habitItem(fixtureHabitPlain, base, 0.9))
seedTidyItem(t, store, habitItem(fixtureHabitVacation, base.Add(time.Minute), 0.9))
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitPlain: {1, 0},
fixtureHabitVacation: simVector(0.97),
}}, time.Now, nil)
if _, err := svc.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
runs, err := store.ListTidyRuns(ctx, 1)
if err != nil {
t.Fatalf("ListTidyRuns: %v", err)
}
if len(runs) != 1 {
t.Fatalf("运行留痕 = %d 条, want 1", len(runs))
}
if runs[0].VetoReasons[string(VetoTemporal)] != 1 {
t.Fatalf("运行表的否决分布 = %+v, want temporal=1", runs[0].VetoReasons)
}
if runs[0].ID == "" {
t.Fatal("运行留痕缺 id(日志里的 run_id 也必须指向同一条记录)")
}
if runs[0].Scanned != 2 {
t.Fatalf("运行表 scanned = %d, want 2", runs[0].Scanned)
}
}