主人实测: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/ 待登记
219 lines
8.0 KiB
Go
219 lines
8.0 KiB
Go
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)
|
||
}
|
||
}
|