Files
Cyrene_v2/cmd/cyrene/wire_memory_tidy_test.go
T
AskaEth 44c25eb03b refactor(memory): 到期清扫挂点从 wait 注入缝改为 TidyService.Start
塔裁定:清扫不许挂在注入缝上 —— 包一层 s.wait 会让「清扫是否存活」依赖于
「loop 每一轮都调 s.wait」这个**没有任何契约固定**的假设;将来谁换了等待原语、
或把循环改成 select + 自己的 timer,清扫会**静默消失**(不报错、不红测试,
只是临时期限再也删不掉)。后台件起在 Start 里,读者一眼看到它们同生共死。

- tidy_service.go:Start 里紧跟 go s.loop(cctx) 起 go s.expirySweepLoop(cctx),
  wg 计数与既有那一笔同处(同一把锁内加,避免 Add 与 Stop 的 wg.Wait 相撞)。
  `if !s.cfg.Enabled { … return }` 仍在其前 ⇒ 语义与改动前逐字相同:整理被关掉
  ⇒ 清扫不跑(与 preinsert_gc 随审查服务启停同口径)。
- expire_loop.go:删掉 AttachExpirySweep 与对 s.wait 的包裹;文件头写明
  「挂点必须是 Start 里那处显式的 go」,防止后人改回注入缝。
- expire_loop_test.go:那条运行时对照改写成 TestTidyServiceStart_清扫随服务起停
  —— 真 Start、真 Stop(Stop 不超时即证明清扫随 ctx 退出且登记在同一个 wg)。
- wire_memory_tidy.go:去掉组合根的挂载调用,注释改成「起在 Start 里,组合根零接线」。
- wire_memory_tidy_test.go:把 go s.expirySweepLoop(cctx) 钉进 TestMemoryTidyIsWired
  的 seams(摘掉那一行 ⇒ 用例红,已实测)。
2026-10-13 21:43:27 +08:00

340 lines
14 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 main
import (
"context"
"database/sql"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件锁定记忆整理(design/23 §4.1)在**组合根**上的三件事:
// 1. 跑在专属隔离会话,绝不是主人的对话车道(isConversationLane 对它恒 false);
// 2. 接线存在(服务被装配、被启动、被停止,env 键同步),少一处就会静默不工作;
// 3. 「绝不碰主会话上下文」是**可验证**的:跑完一轮整理后,主会话的消息条数、
// 最后一条消息、已读游标逐字不变,且对 chat_messages 零写入。
// tidyTestLog 返回不写任何地方的 logger,保持测试输出干净。
func tidyTestLog() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// TestMemoryTidySessionIsNotConversationLane 是 design/23 §4.1 B 的对外断言:
// 整理会话不落在主人的对话车道("web" / "web:<uuid>")上。
func TestMemoryTidySessionIsNotConversationLane(t *testing.T) {
t.Parallel()
if isConversationLane(memory.TidySessionID) {
t.Fatalf("整理会话 %q 被判成主人对话回合(会改写她的心情、进主会话泳道)", memory.TidySessionID)
}
// 对照组:主人车道本身必须仍然为 true(避免把断言写成「函数恒 false」)。
if !isConversationLane(history.MainSessionID) || !isConversationLane(history.MainSessionID+":sub-1") {
t.Fatal("主人车道判定被改坏了")
}
}
// TestMemoryTidyIsWired 是接线存在性防回归:组合根少任何一处,整理都会**静默
// 不工作**(不报错、不产候选、日志里一句都没有),而这不会让别的用例变红。
func TestMemoryTidyIsWired(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_runtime.go": {
// 记忆栈建好后就装配整理(它只需要 store + embedder)。
"a.wireMemoryTidy()",
"a.embedder = embedder",
},
"wire_memory_tidy.go": {
"memory.NewTidyService(cfg, memory.TidyDeps{",
// 调优(自动落地开关 + 每轮上限)从 $DATA_DIR/config/memory-tidy.json
// 来,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。少了这一句,
// 那个文件就成了「写了没人读」的摆设,而自动档仍然开着 —— 静默。
"runtimeconfig.LoadMemoryTidyTuning",
// 启动播种(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件
// **要做**」):少了这两句,那个文件在生产里**根本不存在** ⇒「面板优先」
// 永远是空话,生效的仍是 `.env` 兜底那一档 —— 而不会有任何用例变红。
"a.seedMemoryTidyPanel(cfg)",
"runtimeconfig.SeedMemoryTidy(",
// 依赖面里**没有**任何会话/历史读数 —— 这是「绝不碰主会话上下文」
// 的结构性保证,而不是靠自觉。
"Audit: a.auditMemoryTidy",
},
"main.go": {
"a.memoryTidy.Start(ctx)",
},
// 到期清扫(切片 M3,design/23 §19 的第 ② / ③ 步):临时记忆到期后要
// **真删掉**,而它起在 TidyService.Start 里(与心跳同一个 cctx / wg)。
// 少了这一句,过期行只会被读侧时效闸「挡在门外」而**永远不删** ——
// 不报错、不红任何别的用例,只是库里的临时层无界留存。因此这里把它
// 钉成 seam:`TestTidyServiceStart_清扫随服务起停` 是它的运行时对照。
"../../internal/runtime/memory/tidy_service.go": {
"go s.expirySweepLoop(cctx)",
},
"../../internal/runtime/memory/expire_loop.go": {
"func (s *TidyService) expirySweepLoop(ctx context.Context)",
"DefaultExpireSweepInterval",
},
"wire_services.go": {
// 关库之前先停整理(它每轮要用连接池)。
"a.memoryTidy.Stop(ctx)",
},
}
for file, wants := range seams {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, want := range wants {
if !strings.Contains(string(src), want) {
t.Fatalf("%s 里找不到 %q —— 记忆整理没有被接上组合根", file, want)
}
}
}
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
for _, key := range []string{
"MEMORY_TIDY_ENABLED=", "MEMORY_TIDY_INTERVAL_SEC=", "MEMORY_TIDY_QUIET_START_HOUR=",
"MEMORY_TIDY_QUIET_END_HOUR=", "MEMORY_TIDY_RECALL_THRESHOLD=",
"MEMORY_TIDY_SCAN_LIMIT=", "MEMORY_TIDY_CANDIDATE_LIMIT=",
} {
if !strings.Contains(string(raw), key) {
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10 协同 E6)", key)
}
}
}
// TestMemoryTidyRunLeavesChatHistoryUntouched 是 design/23 §4.1 E 的可验证版本:
// 整理跑完一轮后,主会话的消息条数、最后一条消息、read_at 游标**逐字不变**,
// 且 chat_messages 里没有任何属于整理会话的行。
//
// 用例让整理真的干活(有可合并的一对),否则断言会是空的。
func TestMemoryTidyRunLeavesChatHistoryUntouched(t *testing.T) {
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", tidyTestLog())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer func() { _ = conn.Close() }()
if err := db.MigrateUp(ctx, conn, tidyTestLog()); err != nil {
t.Fatalf("migrate: %v", err)
}
hist, err := history.NewStore(ctx, conn, tidyTestLog())
if err != nil {
t.Fatalf("history store: %v", err)
}
if _, err := hist.AppendFull(ctx, history.Message{
SessionID: history.MainSessionID, Role: history.RoleUser, Content: "今晚又要加班到很晚", CreatedAt: 1_700_000_000_000,
}); err != nil {
t.Fatalf("append user message: %v", err)
}
last, err := hist.AppendFull(ctx, history.Message{
SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "别熬太晚,我陪你", CreatedAt: 1_700_000_001_000,
})
if err != nil {
t.Fatalf("append assistant message: %v", err)
}
if _, err := hist.MarkSessionRead(ctx, history.MainSessionID, time.UnixMilli(1_700_000_000_500)); err != nil {
t.Fatalf("mark read: %v", err)
}
before := readConversationFingerprint(t, ctx, conn, history.MainSessionID)
store, err := memory.NewStore(ctx, conn, tidyTestLog())
if err != nil {
t.Fatalf("memory store: %v", err)
}
seed := []string{"主人常在深夜与助手聊天,有熬夜习惯", "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚"}
for i, content := range seed {
if _, err := store.Add(ctx, memory.MemoryItem{
PersonaID: "persona.a", Scope: memory.ScopePersona, Kind: memory.KindRelationship,
Content: content, Confidence: 0.9,
CreatedAt: time.UnixMilli(1_700_000_000_000 + int64(i)*60_000),
}); err != nil {
t.Fatalf("seed memory %d: %v", i, err)
}
}
svc, err := memory.NewTidyService(memory.TidyConfigFromConfig(config.MemoryTidyConfig{
Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7,
RecallThreshold: 0.80, ScanLimit: 100, CandidateLimit: 20,
}), memory.TidyDeps{Store: store, Log: tidyTestLog()})
if err != nil {
t.Fatalf("tidy service: %v", err)
}
stats, err := svc.RunOnce(ctx)
if err != nil {
t.Fatalf("tidy RunOnce: %v", err)
}
if stats.Scanned != 2 || stats.Candidates == 0 {
t.Fatalf("整理没真干活,断言会落空:%+v", stats)
}
after := readConversationFingerprint(t, ctx, conn, history.MainSessionID)
if before != after {
t.Fatalf("整理改动了主会话上下文:\n before=%+v\n after=%+v", before, after)
}
if after.lastContent != last.Content || after.lastID != last.ID {
t.Fatalf("主会话最后一条消息变了:%+v", after)
}
var tidyMsgs int
if err := conn.QueryRowContext(ctx,
"SELECT count(*) FROM chat_messages WHERE session_id = ?", memory.TidySessionID).Scan(&tidyMsgs); err != nil {
t.Fatalf("count tidy messages: %v", err)
}
if tidyMsgs != 0 {
t.Fatalf("整理向 chat_messages 写入了 %d 行(design §4.1 D 要求零写入)", tidyMsgs)
}
}
// conversationFingerprint 是主会话的「逐字不变」指纹:消息条数、最后一条消息
// 的 id 与正文、以及会话已读游标。
type conversationFingerprint struct {
messages int
lastID string
lastContent string
readAt int64
}
// readConversationFingerprint 直接读 chat_messages / chat_sessions(主会话只由
// 消息写入路径触碰,因此这是「整理有没有碰会话」最直接的证据)。
func readConversationFingerprint(t *testing.T, ctx context.Context, conn *sql.DB, sessionID string) conversationFingerprint {
t.Helper()
var fp conversationFingerprint
if err := conn.QueryRowContext(ctx,
"SELECT count(*) FROM chat_messages WHERE session_id = ?", sessionID).Scan(&fp.messages); err != nil {
t.Fatalf("count messages: %v", err)
}
if err := conn.QueryRowContext(ctx,
"SELECT id, content FROM chat_messages WHERE session_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1",
sessionID).Scan(&fp.lastID, &fp.lastContent); err != nil {
t.Fatalf("read last message: %v", err)
}
if err := conn.QueryRowContext(ctx,
"SELECT read_at FROM chat_sessions WHERE id = ?", sessionID).Scan(&fp.readAt); err != nil {
t.Fatalf("read cursor: %v", err)
}
return fp
}
// TestApplyMemoryTidyTuning_ReadsDataDirConfig 钉住「自动档开关确实来自
// $DATA_DIR/config/memory-tidy.json」这条接线:它没有 env 键(`.env` 已冻结),
// 因此若组合根没读那个文件,「一键回到只产建议」就是一句空话 —— 文件写了没人看。
func TestApplyMemoryTidyTuning_ReadsDataDirConfig(t *testing.T) {
t.Parallel()
// ① 文件不存在 ⇒ 保持代码默认(自动档开着)。
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
cfg := memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
empty.applyMemoryTidyTuning(&cfg)
if cfg.DisableAutoApply || cfg.AutoApplyPerRound != 0 {
t.Fatalf("没配过时 cfg = %+v, want 保持零值(由 normalize 填代码默认)", cfg)
}
// ② 写 {"autoApply": false, "autoApplyPerRound": 5} ⇒ 关掉 + 抬高上限。
dataDir := t.TempDir()
dir := filepath.Join(dataDir, "config")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, "memory-tidy.json")
if err := os.WriteFile(path, []byte(`{"autoApply": false, "autoApplyPerRound": 5}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
off := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
cfg = memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
off.applyMemoryTidyTuning(&cfg)
if !cfg.DisableAutoApply || cfg.AutoApplyPerRound != 5 {
t.Fatalf("cfg = %+v, want DisableAutoApply=true 且 AutoApplyPerRound=5", cfg)
}
// ③ 坏文件 ⇒ 记 WARN 并回落代码默认(绝不把自动档读成「无限制」或「关掉」)。
if err := os.WriteFile(path, []byte(`{"autoApply": "nope"}`), 0o600); err != nil {
t.Fatalf("write broken: %v", err)
}
broken := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
cfg = memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
broken.applyMemoryTidyTuning(&cfg)
if cfg.DisableAutoApply || cfg.AutoApplyPerRound != 0 {
t.Fatalf("坏配置时 cfg = %+v, want 回落零值(默认值由 normalize 填)", cfg)
}
}
// TestApplyMemoryTidyTuning_ScheduleKnobs 钉住**整理频率**四键在组合根上的接线:
// 面板($DATA_DIR/config/memory-tidy.json)优先、`.env` 兜底 —— 主人 2026-10-07
// 口径「.env 只做兜底用 …… 这些键优先从面板配置上取」。
//
// 这里只断言「叠完之后 cfg 里是什么」,不重复 memory 包的优先级链用例:组合根这一层
// 要证明的是**那个文件真的被读了、真的落到了 cfg 上**(少这一句,面板改了频率也无效,
// 而不会有任何用例变红)。
func TestApplyMemoryTidyTuning_ScheduleKnobs(t *testing.T) {
t.Parallel()
// env 层:与面板值故意对立。
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 7200, QuietStartHour: 23, QuietEndHour: 7}
newApp := func(dataDir string) *app {
return &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
}
apply := func(dataDir string) memory.TidyConfig {
cfg := memory.TidyConfigFromConfig(env)
newApp(dataDir).applyMemoryTidyTuning(&cfg)
return cfg
}
// ① 面板四键都写了 ⇒ 四个都用面板值(覆盖 `.env`)。
dataDir := t.TempDir()
dir := filepath.Join(dataDir, "config")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, "memory-tidy.json")
body := `{"intervalSec": 3600, "quietIntervalSec": 10800, "quietStartHour": 1, "quietEndHour": 5}`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
cfg := apply(dataDir)
if cfg.IntervalSec != 3600 {
t.Fatalf("intervalSec = %d, want 3600(面板优先)", cfg.IntervalSec)
}
if cfg.QuietIntervalSec != 10800 {
t.Fatalf("quietIntervalSec = %d, want 10800(面板优先)", cfg.QuietIntervalSec)
}
if cfg.QuietStartHour != 1 || cfg.QuietEndHour != 5 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 01:00-05:00(面板优先)",
cfg.QuietStartHour, cfg.QuietEndHour)
}
// ② 文件不存在 ⇒ 四键保持 `.env` 值(面板只是「能覆盖」,不是「必须有」)。
empty := t.TempDir()
cfg = apply(empty)
if cfg.IntervalSec != 7200 || cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
t.Fatalf("没配过时应保持 env 值,得到 %+v", cfg)
}
// ③ 坏文件(类型写错)⇒ 整份回落 `.env`,且**不启动失败**:组合根照旧拿到
// 可用的 cfg(一个坏键不得让整理停摆)。
if err := os.WriteFile(path, []byte(`{"intervalSec": "3600"}`), 0o600); err != nil {
t.Fatalf("write broken: %v", err)
}
cfg = apply(dataDir)
if cfg.IntervalSec != 7200 || cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
t.Fatalf("坏文件时应回落 env 值,得到 %+v", cfg)
}
if cfg.QuietIntervalSec != 0 {
t.Fatalf("坏文件时 quietIntervalSec 应当是零值(由 normalize 取 = intervalSec),得到 %d",
cfg.QuietIntervalSec)
}
}