2 Commits
Author SHA1 Message Date
AskaEth 23b6e3fe1e feat(memory): 启动时自动播种面板配置文件(缺失才写、绝不覆盖)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人 2026-10-07:「后端自动创建独立的面板配置文件要做」。

- 启动路径(wireMemoryTidy → applyMemoryTidyTuning 之后)若
  $DATA_DIR/config/memory-tidy.json 不存在或全空白 => 写一份「当时生效值」
  (十键:既有六键 + 频率四键),此后面板接管
- 已存在(非空)=> 跳过,该分支没有任何写盘语句;损坏 => WARN + 按 .env 跑、
  一个字不写盘(不「修好它」)
- 幂等(第二次启动必落到跳过分支);原子写(tmp 0600 + rename)
- 落盘前完整性闸:缺键 / 非正周期 / 钟点越界 => WARN + 不落盘
- 值来自 TidyTuningSnapshot(先跑两个归一化再翻回正向键)=> 播种那一刻运行
  行为零变化,有强断言(归一化后配置逐字相等)
- 零新增依赖、零 env 键变更;5 个变异(含「让已存在走写盘」=> 红)
2026-10-07 20:38:37 +08:00
AskaEth d680390300 feat(memory): 整理频率走面板配置(面板 > env > 默认)+ 分时段动态间隔
主人 2026-10-07:「.env 只做兜底…优先从面板配置上取」+「频率必须调…或者动态
分配,比如白天间隔短一点 晚上到了静默时期稍微长一点」。

- memory-tidy.json 新增四键:intervalSec / quietIntervalSec / quietStartHour /
  quietEndHour(*int + 指针判定 => 显式 0 是合法值、缺位才回落)
- 优先级逐键三档:面板 > env(既有键,未增删)> 代码默认;坏值不覆盖 + WARN,
  且不启动失败
- 动态间隔由纯函数 intervalAt(at) 决定,调度侧每一拍现算 => 跑完按当时时段重算
- 🔴 语义变更(有意,代码注释已登记,待补设计修订):安静时段从「唯一可跑时段」
  变成「降频时段」—— 旧的「安静窗外一律 continue」被删,否则 intervalSec 永远
  是空壳(白天唤醒必被跳过);窗口仍决定档位与补跑判据
- 6 个变异(含「加回旧的那道门」=> 端到端用例红)
2026-10-07 19:21:32 +08:00
16 changed files with 1987 additions and 59 deletions

No files matched your search

+65 -18
View File
@@ -11,17 +11,22 @@ import (
// 本文件把记忆整理(DevDocs/design/23-记忆整理与去重 §4)接进组合根。 // 本文件把记忆整理(DevDocs/design/23-记忆整理与去重 §4)接进组合根。
// //
// 四件事只在这里定: // 四件事只在这里定:
// - **形态**:低峰定期后台任务(memory.TidyService 的心跳),不是会话内派 // - **形态**:定期后台任务(安静时段降频)的心跳(memory.TidyService),不是
// subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止; // 会话内派 subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止;
// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数, // - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数,
// 因此 design §4.1「绝不碰主会话上下文」是结构性成立的,而不是靠自觉; // 因此 design §4.1「绝不碰主会话上下文」是结构性成立的,而不是靠自觉;
// - **审计**:接到网关的 AuditEvent(design §8 的 memory.tidy.*),出口在调用 // - **审计**:接到网关的 AuditEvent(design §8 的 memory.tidy.*),出口在调用
// 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 onToolAudit // 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 onToolAudit
// 同一手法。未就绪时只记日志,绝不假装写过审计; // 同一手法。未就绪时只记日志,绝不假装写过审计;
// - **运行期调优**(2026-10-07 新增):从 `$DATA_DIR/config/memory-tidy.json` // - **运行期调优**(2026-10-07 新增):从 `$DATA_DIR/config/memory-tidy.json`
// 读「自动落地开关 + 每轮上限」。它是 `.env` 冻结(design/13 §2.1)之后新旋钮 // 读「自动落地开关 + 每轮上限」,以及(同日追加)**整理频率**四项
// 的唯一去处,**零新 env 键**;读不到/解不开一律回落代码默认值并记 WARN // (intervalSec / quietIntervalSec / quietStartHour / quietEndHour)。它是
// (坏配置绝不能让自动档变成「无限制」)。 // `.env` 冻结(design/13 §2.1)之后新旋钮的唯一去处,**零新 env 键**;面板优先、
// `.env` 兜底,键缺位或值不可用一律回落下一档并记 WARN(坏配置绝不能让自动档
// 变成「无限制」,也不能让整理频率变成忙轮询);
// - **启动播种**(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件**要做**」):
// 那个文件不存在时由后端自己写一份「当前生效值」进去(seedMemoryTidyPanel),
// 使「面板优先」从第一次启动起就成立,而不是靠 `.env` 一直兜着。
// //
// 刻意**不做**的一件事(design §4.1 F): // 刻意**不做**的一件事(design §4.1 F):
// - 不把 sess-memorytidy 加进 flowPub 的图源白名单(默认关:整理是系统任务, // - 不把 sess-memorytidy 加进 flowPub 的图源白名单(默认关:整理是系统任务,
@@ -29,6 +34,12 @@ import (
func (a *app) wireMemoryTidy() error { func (a *app) wireMemoryTidy() error {
cfg := memory.TidyConfigFromConfig(a.cfg.MemoryTidy) cfg := memory.TidyConfigFromConfig(a.cfg.MemoryTidy)
a.applyMemoryTidyTuning(&cfg) a.applyMemoryTidyTuning(&cfg)
// 启动播种(主人 2026-10-07:「后端**自动创建**独立的面板配置文件**要做**」):
// 文件不存在/空白 ⇒ 把**此刻的生效值**写成面板的一等来源,此后 `.env` 只兜底。
// 放在 applyMemoryTidyTuning **之后**是刻意的:那一刻的 cfg 就是
// 「面板 > `.env` > 代码默认」的结果(面板缺失时即 `.env` + 默认),播它 ⇒ 行为
// 零变化。文件已存在(含损坏)⇒ 一个字节都不动(幂等,见 runtimeconfig.SeedMemoryTidy)。
a.seedMemoryTidyPanel(cfg)
svc, err := memory.NewTidyService(cfg, memory.TidyDeps{ svc, err := memory.NewTidyService(cfg, memory.TidyDeps{
Store: a.memStore, Store: a.memStore,
Embedder: a.embedder, Embedder: a.embedder,
@@ -42,29 +53,65 @@ func (a *app) wireMemoryTidy() error {
return nil return nil
} }
// seedMemoryTidyPanel 在 `$DATA_DIR/config/memory-tidy.json` 缺失/空白时播种一份
// 「当前生效值」快照(`memory.TidyTuningSnapshot`),使面板配置从第一次启动起就自足,
// 而不是每次都靠 `.env` 兜。失败只记日志、**绝不阻断启动**:运行期仍按 `.env` +
// 代码默认跑,行为与播种前逐字相同(与 materializeModels 同一口径)。
//
// 闸与 T1 的四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec)在 `.env` 里
// **没有出处**(`.env` 已冻结,它们只由这个面板文件与代码默认决定,见
// wireMemoryReview 的三个 helper),而播种只发生在**面板文件不存在**时 —— 那正是
// 「零值 + normalizeReviewConfig」这一条路径,因此这里给零值 ReviewConfig 就是
// **当时**的生效值,不是猜的。审查开关本身(MEMORY_REVIEW_ENABLED)不在这十键里,
// 因此不必为它去读一次 env。
func (a *app) seedMemoryTidyPanel(tidy memory.TidyConfig) {
path := runtimeconfig.DefaultMemoryTidyPath(resolveDataDir(a.cfg))
log := logger.WithSource(a.log, "memory/tidy")
// 播种函数自己会把「跳过 / 无法解析」记成带路径的日志(含 WARN),这里只报
// 「该写却没写成」那一种 —— 不重复记同一条 WARN。
if _, err := runtimeconfig.SeedMemoryTidy(path, memory.TidyTuningSnapshot(tidy, memory.ReviewConfig{}), log); err != nil {
log.Warn("memory-tidy.json 播种失败(面板配置仍缺位,.env 继续兜底)", "path", path, "error", err)
}
}
// applyMemoryTidyTuning 把 `$DATA_DIR/config/memory-tidy.json` 叠到 env 侧的 // applyMemoryTidyTuning 把 `$DATA_DIR/config/memory-tidy.json` 叠到 env 侧的
// TidyConfig 上(见 runtimeconfig.MemoryTidyTuning 的零值口径): // TidyConfig 上(见 runtimeconfig.MemoryTidyTuning 的零值口径):
// - 文件里**没写** autoApply ⇒ 保持代码默认(开); // - 文件里**没写** autoApply ⇒ 保持代码默认(开);
// - 写 `false` ⇒ DisableAutoApply = true(一键回到「只产建议」,不必改代码); // - 写 `false` ⇒ DisableAutoApply = true(一键回到「只产建议」,不必改代码);
// - autoApplyPerRound ≤ 0 ⇒ 不覆盖,交给 normalizeTidyConfig 填默认上限。 // - autoApplyPerRound ≤ 0 ⇒ 不覆盖,交给 normalizeTidyConfig 填默认上限;
// - **调度四键**(intervalSec / quietIntervalSec / quietStartHour /
// quietEndHour)⇒ 交 memory.ApplyTidyScheduleTuning 逐键解析(面板 >
// `.env` > 代码默认),坏值回落 + WARN。
// //
// 读不到/解不开时只记 WARN 并保持 env 侧的值:那正是「回落代码默认值」。 // 读不到/解不开时只记 WARN 并保持 env 侧的值:那正是「回落下一档」——坏文件时
// tuning 是零值(四个调度键都是 nil),因此 ApplyTidyScheduleTuning 一个键都不覆盖,
// env 值原样留着(`.env` 也缺位时由 normalizeTidyConfig 落到代码默认)。
func (a *app) applyMemoryTidyTuning(cfg *memory.TidyConfig) { func (a *app) applyMemoryTidyTuning(cfg *memory.TidyConfig) {
tuning, path, err := a.readMemoryTidyTuning() tuning, path, err := a.readMemoryTidyTuning()
log := logger.WithSource(a.log, "memory/tidy")
if err != nil { if err != nil {
logger.WithSource(a.log, "memory/tidy").Warn( log.Warn(
"memory tidy tuning unreadable, falling back to code defaults", "memory tidy tuning unreadable, falling back to the env layer (.env values kept)",
"path", path, "error", err) "path", path, "error", err)
return } else {
cfg.DisableAutoApply = tuning.AutoApplyOff()
if tuning.AutoApplyPerRound > 0 {
cfg.AutoApplyPerRound = tuning.AutoApplyPerRound
}
} }
cfg.DisableAutoApply = tuning.AutoApplyOff() // 调度四键在两种情形下都要过一遍:成功时按面板值覆盖,失败时 tuning 是零值
if tuning.AutoApplyPerRound > 0 { // ⇒ 原样返回(= 保持 env 值),因此不必为它再分一条路径。
cfg.AutoApplyPerRound = tuning.AutoApplyPerRound *cfg = memory.ApplyTidyScheduleTuning(*cfg, tuning, log)
} // 这里打的是**归一化之前**的值(quiet_interval_sec 可能还是 0 = 「缺省,等于
logger.WithSource(a.log, "memory/tidy").Info( // intervalSec」):真正生效的两档周期由 TidyService.Start 的启动日志报。
"memory tidy tuning loaded", // panel_decoded = 「文件缺失/空白/解析成功」三者之一(LoadMemoryTidyTuning 把
"path", path, "auto_apply", !cfg.DisableAutoApply, // 前两者都当作「还没配过」而不是错误),为 false 即坏文件 ⇒ 四个键都回落 `.env`。
"auto_apply_per_round", cfg.AutoApplyPerRound) log.Info(
"memory tidy tuning applied (panel > .env > code defaults)",
"path", path, "panel_decoded", err == nil, "auto_apply", !cfg.DisableAutoApply,
"auto_apply_per_round", cfg.AutoApplyPerRound,
"interval_sec", cfg.IntervalSec, "quiet_interval_sec", cfg.QuietIntervalSec,
"quiet_hours", fmt.Sprintf("%02d:00-%02d:00", cfg.QuietStartHour, cfg.QuietEndHour))
} }
// readMemoryTidyTuning 读一次 `$DATA_DIR/config/memory-tidy.json`(**只读**)。 // readMemoryTidyTuning 读一次 `$DATA_DIR/config/memory-tidy.json`(**只读**)。
+267
View File
@@ -0,0 +1,267 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件锁定**启动播种在组合根上的接线**(wire_memory_tidy.go 的 seedMemoryTidyPanel;
// 主人 2026-10-07:「`.env` 只做兜底用 —— 后端**自动创建**独立的面板配置文件**要做**」):
//
// ① 文件不存在 ⇒ 后端自己写一份「当时生效值」(`.env` 给了的用 `.env`,没给的用代码
// 默认),面板配置自此自足;
// ② 播种之后再走一遍解析,生效配置与没播种时**逐字相同**(播种那一刻行为零变化);
// ③ 幂等:再跑一次不改字节(文件已在 ⇒ 走「绝不覆盖」那条路)。
//
// 用例全部落在临时 DataDir(`t.TempDir()`):生产那份 data/config/memory-tidy.json 是
// 主人手工建的,用例不许依赖它存在与否,也不许碰它。
// panelPath 返回某个 DataDir 下面板配置的落点(与组合根同一套路径推导)。
func panelPath(dataDir string) string {
return runtimeconfig.DefaultMemoryTidyPath(dataDir)
}
// readPanel 读回面板文件原文(幂等断言要比字节,不能只看解出来的键)。
func readPanel(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读回 %s: %v", path, err)
}
return string(raw)
}
// TestWireMemoryTidySeedsPanelOnBoot 是最接近主人场景的一条:直接调**组合根的真实
// 装配函数** `wireMemoryTidy`(它被 wireMemory → newApp 调用,即进程启动那条路),
// 断言这一次启动就**自己把面板配置文件建出来**了;再跑一次(= 第二次启动)字节不变。
//
// 为什么不用「跑一遍真二进制」来验收:那要再起一个 :8080 的同进程服务,与「后端常驻」
// 那条红线冲突(不许为验收停/重启正在跑的后端)。因此这里走的是**同一个函数**、同一套
// 路径推导,只把 DataDir 换成临时目录。
func TestWireMemoryTidySeedsPanelOnBoot(t *testing.T) {
t.Parallel()
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)
}
store, err := memory.NewStore(ctx, conn, tidyTestLog())
if err != nil {
t.Fatalf("memory store: %v", err)
}
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 3600, QuietStartHour: 23, QuietEndHour: 7}
dataDir := t.TempDir()
a := &app{
cfg: &config.Config{DataDir: dataDir, MemoryTidy: env},
memStore: store,
log: tidyTestLog(),
}
if err := a.wireMemoryTidy(); err != nil {
t.Fatalf("wireMemoryTidy: %v", err)
}
path := panelPath(dataDir)
if _, err := os.Stat(path); err != nil {
t.Fatalf("启动装配没有自动创建 %s:%v(这正是主人要求的那条「后端自动创建要做」)", path, err)
}
got, err := runtimeconfig.LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("回读播种结果: %v", err)
}
if got.IntervalSec == nil || *got.IntervalSec != 3600 ||
got.QuietIntervalSec == nil || *got.QuietIntervalSec != 3600 {
t.Fatalf("两档周期 = %v/%v, want 3600/3600(取 `.env`)", got.IntervalSec, got.QuietIntervalSec)
}
if got.QuietStartHour == nil || *got.QuietStartHour != 23 ||
got.QuietEndHour == nil || *got.QuietEndHour != 7 {
t.Fatalf("安静时段 = %v-%v, want 23-7", got.QuietStartHour, got.QuietEndHour)
}
// 第二次启动(同一个装配函数再跑一次):文件已在 ⇒ 一个字节都不动。
before := readPanel(t, path)
second := &app{
cfg: &config.Config{DataDir: dataDir, MemoryTidy: env},
memStore: store,
log: tidyTestLog(),
}
if err := second.wireMemoryTidy(); err != nil {
t.Fatalf("第二次 wireMemoryTidy: %v", err)
}
if after := readPanel(t, path); after != before {
t.Fatalf("第二次启动改动了面板文件:\n before=%s\n after =%s", before, after)
}
}
// TestSeedMemoryTidyPanel_WritesEffectiveValuesOnBoot 钉住「自动创建」这件事本身:
// 组合根启动时必须自己把面板文件建出来,内容 = 当时生效值(`.env` 档 + 代码默认)。
func TestSeedMemoryTidyPanel_WritesEffectiveValuesOnBoot(t *testing.T) {
t.Parallel()
// `.env` 层:与代码默认**故意不同**,这样「播的是生效值」与「播了一份硬编码默认值」
// 一眼可分。
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 10800, QuietStartHour: 22, QuietEndHour: 6}
dataDir := t.TempDir()
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
path := panelPath(dataDir)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("前置:%s 不该存在", path)
}
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
if _, err := os.Stat(path); err != nil {
t.Fatalf("启动播种没有把面板文件建出来:%v", err)
}
got, err := runtimeconfig.LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("回读播种结果: %v", err)
}
// `.env` 档:三键如实落进快照(quietIntervalSec 没有 env 键 ⇒ 缺省 = intervalSec)。
if got.IntervalSec == nil || *got.IntervalSec != 10800 {
t.Fatalf("intervalSec = %v, want 10800(取 `.env`)", got.IntervalSec)
}
if got.QuietIntervalSec == nil || *got.QuietIntervalSec != 10800 {
t.Fatalf("quietIntervalSec = %v, want 10800(缺省 = intervalSec)", got.QuietIntervalSec)
}
if got.QuietStartHour == nil || *got.QuietStartHour != 22 ||
got.QuietEndHour == nil || *got.QuietEndHour != 6 {
t.Fatalf("安静时段 = %v-%v, want 22-6(取 `.env`)", got.QuietStartHour, got.QuietEndHour)
}
// 代码默认档:老六键必须齐(否则「面板能管住自动档/闸/T1」仍是空话)。
if got.AutoApply == nil || !*got.AutoApply || got.AutoApplyPerRound <= 0 {
t.Fatalf("自动档两键 = %v/%d, want 显式 true 且正数上限", got.AutoApply, got.AutoApplyPerRound)
}
if got.IngestGate == nil || !*got.IngestGate || got.T1Judge == nil || !*got.T1Judge {
t.Fatalf("闸/T1 开关 = %v/%v, want 显式 true", got.IngestGate, got.T1Judge)
}
if got.T1PerWindow <= 0 || got.T1WindowSec <= 0 {
t.Fatalf("T1 限速 = %d/%d, want 正数", got.T1PerWindow, got.T1WindowSec)
}
// 幂等 + 「绝不覆盖」:再跑一次,字节一个不动。
before := readPanel(t, path)
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
if after := readPanel(t, path); after != before {
t.Fatalf("第二次播种改动了面板文件:\n before=%s\n after =%s", before, after)
}
}
// TestSeedMemoryTidyPanel_KeepsRuntimeBehaviorIdentical 钉住「播种那一刻行为零变化」:
// 播种前后各按组合根的次序解析一次,得到的生效配置必须逐字相同 —— 否则播种就成了
// 一次没人察觉的行为变更,而不是「把现状写进文件」。
func TestSeedMemoryTidyPanel_KeepsRuntimeBehaviorIdentical(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 7200, QuietStartHour: 1, QuietEndHour: 5}
dataDir := t.TempDir()
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
// 比对的是**归一化之后**的生效配置:TidyConfig 是归一化前的中间形态,面板写不写
// quietIntervalSec / autoApplyPerRound 会让它在 0 与「等于生效值」之间变化,语义却
// 相同 —— 因此必须走 normalizeTidyConfig 这一层(memory.NewTidier 的既有入口)才能
// 说「行为逐字未变」。
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)
}
store, err := memory.NewStore(ctx, conn, tidyTestLog())
if err != nil {
t.Fatalf("memory store: %v", err)
}
effective := func(t *testing.T, cfg memory.TidyConfig) memory.TidyConfig {
t.Helper()
tidier, err := memory.NewTidier(cfg, memory.TidyDeps{Store: store, Log: tidyTestLog()})
if err != nil {
t.Fatalf("NewTidier: %v", err)
}
return tidier.Config() // 已套默认值
}
// ① 播种前:面板文件不存在 ⇒ 只有 `.env` + 代码默认这一档。
before := memory.TidyConfigFromConfig(env)
a.applyMemoryTidyTuning(&before)
before = effective(t, before)
// ② 启动播种。
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
// ③ 播种后:面板文件已在,同样的 env 再解析一次。
after := memory.TidyConfigFromConfig(env)
a.applyMemoryTidyTuning(&after)
after = effective(t, after)
if before != after {
t.Fatalf("播种改变了生效配置:\n before=%+v\n after =%+v", before, after)
}
// 两档周期(intervalAt 的真判据)也要如实等价:安静时段窗口内用 quietInterval。
if after.QuietInterval != after.Interval || after.Interval != time.Duration(env.IntervalSec)*time.Second {
t.Fatalf("两档周期 = %s/%s, want 都等于 `.env` 的 %s",
after.Interval, after.QuietInterval, time.Duration(env.IntervalSec)*time.Second)
}
}
// TestSeedMemoryTidyPanel_PanelTakesOverAfterSeeding 是主人口径的终点:
// 「后端自动创建独立的面板配置文件之后就走面板配置文件」—— 播种之后,改那个文件
// 必须真的改变生效值(否则「自动创建」只是造了个没人读的文件)。
func TestSeedMemoryTidyPanel_PanelTakesOverAfterSeeding(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
dataDir := t.TempDir()
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
path := panelPath(dataDir)
if err := os.WriteFile(path, []byte(`{"intervalSec": 3600}`), 0o600); err != nil {
t.Fatalf("改写面板文件: %v", err)
}
cfg := memory.TidyConfigFromConfig(env)
a.applyMemoryTidyTuning(&cfg)
if cfg.IntervalSec != 3600 {
t.Fatalf("intervalSec = %d, want 3600(面板优先,`.env` 只兜底)", cfg.IntervalSec)
}
// 面板没写的键仍由 `.env` 兜底(面板只管它写了的那几个键)。
if cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want `.env` 的 23:00-07:00", cfg.QuietStartHour, cfg.QuietEndHour)
}
}
// TestSeedMemoryTidyPanel_LeavesExistingFileAlone 钉住组合根这一侧的「绝不覆盖」:
// 盘上已有一份主人手写的配置(哪怕缺键、哪怕带未知字段)⇒ 启动播种一个字节都不动。
func TestSeedMemoryTidyPanel_LeavesExistingFileAlone(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
dataDir := t.TempDir()
dir := filepath.Join(dataDir, "config")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
body := "{\n \"autoApply\": false,\n \"手工写的未知键\": 1\n}\n"
path := panelPath(dataDir)
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("写手写的配置: %v", err)
}
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
if got := readPanel(t, path); got != body {
t.Fatalf("已存在的面板文件被改了:\n want=%s\n got =%s", body, got)
}
}
+72
View File
@@ -57,6 +57,11 @@ func TestMemoryTidyIsWired(t *testing.T) {
// 来,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。少了这一句, // 来,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。少了这一句,
// 那个文件就成了「写了没人读」的摆设,而自动档仍然开着 —— 静默。 // 那个文件就成了「写了没人读」的摆设,而自动档仍然开着 —— 静默。
"runtimeconfig.LoadMemoryTidyTuning", "runtimeconfig.LoadMemoryTidyTuning",
// 启动播种(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件
// **要做**」):少了这两句,那个文件在生产里**根本不存在** ⇒「面板优先」
// 永远是空话,生效的仍是 `.env` 兜底那一档 —— 而不会有任何用例变红。
"a.seedMemoryTidyPanel(cfg)",
"runtimeconfig.SeedMemoryTidy(",
// 依赖面里**没有**任何会话/历史读数 —— 这是「绝不碰主会话上下文」 // 依赖面里**没有**任何会话/历史读数 —— 这是「绝不碰主会话上下文」
// 的结构性保证,而不是靠自觉。 // 的结构性保证,而不是靠自觉。
"Audit: a.auditMemoryTidy", "Audit: a.auditMemoryTidy",
@@ -253,3 +258,70 @@ func TestApplyMemoryTidyTuning_ReadsDataDirConfig(t *testing.T) {
t.Fatalf("坏配置时 cfg = %+v, want 回落零值(默认值由 normalize 填)", cfg) 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)
}
}
+8 -4
View File
@@ -246,13 +246,17 @@ type MemoryTidyConfig struct {
// 关闭)。整理**只产建议、绝不自动改记忆**,因此默认开启是安全的; // 关闭)。整理**只产建议、绝不自动改记忆**,因此默认开启是安全的;
// 它带来的是每轮一次的记忆向量化(召回用)与一批候选行。 // 它带来的是每轮一次的记忆向量化(召回用)与一批候选行。
Enabled bool `env:"MEMORY_TIDY_ENABLED" default:"true"` Enabled bool `env:"MEMORY_TIDY_ENABLED" default:"true"`
// IntervalSec 是整理周期(秒),默认 86400 = 每天 1 次(design/23 §13 // IntervalSec 是**非安静时段**的整理周期(秒),默认 86400 = 每天 1 次
// 开放点 8 的建议值)。它是下限:实际在安静时段起点跑,因此间隔取 // (design/23 §13 开放点 8 的建议值)。它是**兜底档**:`$DATA_DIR/config/
// 「安静时段每天一次」与它之中较长者。 // memory-tidy.json` 的 `intervalSec` 可覆盖它(面板优先、`.env` 兜底。
// 2026-10-07 主人口径「`.env` 只做兜底用」),安静时段的另一档是同一文件里的
// `quietIntervalSec`(缺省 = 等于它,见 memory 侧的 intervalAt)。
IntervalSec int `env:"MEMORY_TIDY_INTERVAL_SEC" default:"86400"` IntervalSec int `env:"MEMORY_TIDY_INTERVAL_SEC" default:"86400"`
// QuietStartHour / QuietEndHour 是低峰窗口(本地钟点),默认复用 // QuietStartHour / QuietEndHour 是低峰窗口(本地钟点),默认复用
// backthink 的 23:00-07:00 语义(窗口可跨午夜;两端相等且非 0 则关闭窗口, // backthink 的 23:00-07:00 语义(窗口可跨午夜;两端相等且非 0 则关闭窗口,
// 改为纯周期)。整理只在低峰跑,且**从不推送**(design/23 §4)。 // 改为纯周期)。2026-10-07 起窗口同时是**降频档位**(安静时段用
// quietIntervalSec)与**补跑判据**(窗口内没跑过就补一轮),不再表示
// 「只有低峰才跑」。整理**从不推送**(design/23 §4)。
QuietStartHour int `env:"MEMORY_TIDY_QUIET_START_HOUR" default:"23"` QuietStartHour int `env:"MEMORY_TIDY_QUIET_START_HOUR" default:"23"`
QuietEndHour int `env:"MEMORY_TIDY_QUIET_END_HOUR" default:"7"` QuietEndHour int `env:"MEMORY_TIDY_QUIET_END_HOUR" default:"7"`
// RecallThreshold 是候选召回的余弦**召回门**,默认 0.80(design/23 §5.1 // RecallThreshold 是候选召回的余弦**召回门**,默认 0.80(design/23 §5.1
+70 -7
View File
@@ -23,17 +23,37 @@
// 额度出口。额度必须能按现网节奏调(且不许为它开 env 键),因此落在这里; // 额度出口。额度必须能按现网节奏调(且不许为它开 env 键),因此落在这里;
// **零值不表示「无限制」**,只表示「没配、用代码默认值」。 // **零值不表示「无限制」**,只表示「没配、用代码默认值」。
// //
// 2026-10-07 再追加 `intervalSec` / `quietIntervalSec` / `quietStartHour` /
// `quietEndHour`(**整理频率**·主人两条口径:「**`.env` 只做兜底用** —— 后端自动
// 创建独立的面板配置文件之后就走面板配置文件,这些键优先从面板配置上取」;
// 「**频率必须调**,可以调到 1 小时或者三小时,**或者动态分配**,比如白天间隔短
// 一点,晚上到了静默时期稍微长一点」):
//
// - 这四项**逐键**走「面板 > `.env` > 代码默认」三档(唯一实现点是
// `memory.ApplyTidyScheduleTuning`):面板键在 ⇒ 用面板值;缺位 ⇒ 用 `.env`
// 的 `MEMORY_TIDY_INTERVAL_SEC` / `MEMORY_TIDY_QUIET_*_HOUR`(quietIntervalSec
// 没有 env 键,缺位 = 等于 `intervalSec`);两档都没有 ⇒ 代码默认
// (86400 / 23:00-07:00,见 memory 侧的 normalizeTidyConfig)。
// - 因此**没有新增任何 `.env` 键**(`.env` 已冻结,design/13 §2.1):上面三个
// env 键是既有的,本切片只是让面板能覆盖它们。
// - **坏值不得让整理停摆或忙轮询**:键写了非正数 ⇒ 回落下一档 + WARN
// (≤0 的周期不是「没配」,而是一个会让整理变成空转的值);整个文件解不开
// ⇒ 四个键连同其它键一起回落 `.env` 并记 WARN(既有口径,见 LoadMemoryTidyTuning)。
//
// 边界与 backthink.go 同源(那是同一族的手续): // 边界与 backthink.go 同源(那是同一族的手续):
// //
// 1. 本文件只管**文件形状 + 读取**:它不认识 `TidyConfig`,也不认识「零值回落 // 1. 本文件只管**文件形状 + 读取**:它不认识 `TidyConfig`,也不认识「零值回落
// 代码默认值」那条裁决 —— 那是 `memory.normalizeTidyConfig` 的事。 // 代码默认值」那条裁决 —— 那是 `memory.normalizeTidyConfig` 的事。
// 2. **只读**:面板写路径不在本切片内,因此刻意没有 `Save*`。改动仍要 // 2. **没有「面板编辑」的写口**:改内容仍要**人手改这个文件 + 重启**(与
// **人手改这个文件 + 重启**(与 `backthink.json` 同口径:启动期读一次)。 // `backthink.json` 同口径:启动期读一次)。唯一的写路径是**启动播种**
// (memorytidy_seed.go)—— 它只在文件缺失/空白时写一次,之后一个字节都不动;
// 面板上的编辑口是另一片,不在本文件。
// 3. **零新 env 键**,也不进面板的配置事实卡(那张表的键名被 `.env.example` 钉住)。 // 3. **零新 env 键**,也不进面板的配置事实卡(那张表的键名被 `.env.example` 钉住)。
// //
// 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**);存在但解不开 // 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**;启动时这种情形
// = 返回错误,调用方记 WARN 并回落**代码默认值**(即:坏配置绝不能把自动档的开关 // 会被播种成一份「当时生效值」,见 memorytidy_seed.go);存在但解不开 = 返回错误,
// 或上限读成「无限制」)。 // 调用方记 WARN 并回落**代码默认值**(即:坏配置绝不能把自动档的开关或上限读成
// 「无限制」)。
package runtimeconfig package runtimeconfig
import ( import (
@@ -108,6 +128,35 @@ type MemoryTidyTuning struct {
// `3600` 比要求主人记住 Go 的时长语法更不容易写错,也与既有 // `3600` 比要求主人记住 Go 的时长语法更不容易写错,也与既有
// `$DATA_DIR/config/backthink.json` 的 `*_SEC` 口径一致。 // `$DATA_DIR/config/backthink.json` 的 `*_SEC` 口径一致。
T1WindowSec int `json:"t1WindowSec"` T1WindowSec int `json:"t1WindowSec"`
// IntervalSec 是**非安静时段**的整理周期(秒)。nil = 没配 ⇒ 用 `.env` 的
// `MEMORY_TIDY_INTERVAL_SEC`;两档都没有 ⇒ 代码默认 86400(每天 1 次)。
//
// 用**指针**的理由与 AutoApply 同源,但多一层:数值键的零值(0)在这里是
// **坏值**而不是「一个合法的 0 秒周期」——`0` 会让整理退化成空转,因此
// 「键没写」(静默回落)与「写了 0」(WARN + 回落)必须可区分。
IntervalSec *int `json:"intervalSec"`
// QuietIntervalSec 是**安静时段**的整理周期(秒)——主人要的「动态分配:
// 白天间隔短一点,晚上到了静默时期稍微长一点」就落在这个键上。
//
// nil = 没配 ⇒ **等于 intervalSec**(缺省 = 不因时段换档,而不是「夜里不跑」);
// 写非正数 ⇒ 同一档回落 + WARN(理由同 IntervalSec)。
//
// 它**没有 env 键**(`.env` 已冻结,design/13 §2.1):安静时段分档是本次新增
// 的能力,`.env` 那一档只有 `MEMORY_TIDY_INTERVAL_SEC`,因此它的「下一档」就是
// 上面那个已解析出来的 intervalSec。
QuietIntervalSec *int `json:"quietIntervalSec"`
// QuietStartHour / QuietEndHour 是安静时段的两端(**本地钟点**),语义与既有
// `inQuietHours` 逐字一致:窗口可跨午夜;两端相等且非 0 = 关闭窗口。
//
// 用**指针**是必须的(不像 autoApplyPerRound 那样用零值当「没配」):**0 是
// 合法钟点**(如 `0-7` 的凌晨窗口、`23-0` 到午夜的窗口),用 int 会让「显式
// 写 0」与「没写」不可区分,于是任何只写一端的文件都会被静默改掉另一端。
//
// nil = 没配 ⇒ 用 `.env` 的 `MEMORY_TIDY_QUIET_START_HOUR` / `..._END_HOUR`;
// 两端都没配 ⇒ 代码默认 23:00-07:00。
QuietStartHour *int `json:"quietStartHour"`
QuietEndHour *int `json:"quietEndHour"`
} }
// AutoApplyOff 报告自动档是否被这份配置**显式关掉**。nil(没配)返回 false —— // AutoApplyOff 报告自动档是否被这份配置**显式关掉**。nil(没配)返回 false ——
@@ -138,8 +187,12 @@ func DefaultMemoryTidyPath(dataDir string) string {
// 文件缺失或全空白 ⇒ 零值 + nil:调用方用代码默认值(normalizeTidyConfig 会把 // 文件缺失或全空白 ⇒ 零值 + nil:调用方用代码默认值(normalizeTidyConfig 会把
// 非正的上限回落默认),因此「没配过」与「配成 0」得到同一个结果。 // 非正的上限回落默认),因此「没配过」与「配成 0」得到同一个结果。
// //
// 存在但解不开 ⇒ 返回错误。调用方应记一条带 source 的 WARN 并回落默认; // 存在但解不开 ⇒ 返回错误。调用方应记一条带 source 的 WARN 并回落**下一档**
// **不得覆盖**那个文件(损坏的文件可以人工救回,程序盖掉它就丢了人工写的意图)。 // (调度四键落 `.env` 值、开关类落代码默认)——注意这是**整份文件**的粒度:一个键
// 类型写错(如 `"intervalSec": "3600"`)会让全部键一起回落,这是既有口径
// (`TestApplyMemoryTidyTuning_ReadsDataDirConfig` 的用例③钉的就是它),而不是
// 逐键容错。**不得覆盖**那个文件(损坏的文件可以人工救回,程序盖掉它就丢了人工
// 写的意图)。
// //
// 它不建目录、不读老位置:新家族没有老位置(design/13 §2.1 第 5 条)。 // 它不建目录、不读老位置:新家族没有老位置(design/13 §2.1 第 5 条)。
func LoadMemoryTidyTuning(path string) (MemoryTidyTuning, error) { func LoadMemoryTidyTuning(path string) (MemoryTidyTuning, error) {
@@ -153,6 +206,16 @@ func LoadMemoryTidyTuning(path string) (MemoryTidyTuning, error) {
if strings.TrimSpace(string(raw)) == "" { if strings.TrimSpace(string(raw)) == "" {
return MemoryTidyTuning{}, nil return MemoryTidyTuning{}, nil
} }
return decodeMemoryTidy(raw, path)
}
// decodeMemoryTidy 是 LoadMemoryTidyTuning 与**启动播种**(memorytidy_seed.go)共用的
// 解码点:把「解不开」统一分类成 ErrInvalidMemoryTidy。两处对「坏文件」的判定必须
// 出自同一行代码,否则会出现「读取说坏了、播种说没事」这种自相矛盾(播种侧要靠这个
// 结论决定「绝不覆盖」,判错的代价是一份人工配置被盖掉)。
//
// 空内容由调用方先滤掉(空 = 还没配过,与「缺失」同义,不是解码失败)。
func decodeMemoryTidy(raw []byte, path string) (MemoryTidyTuning, error) {
var t MemoryTidyTuning var t MemoryTidyTuning
if err := json.Unmarshal(raw, &t); err != nil { if err := json.Unmarshal(raw, &t); err != nil {
return MemoryTidyTuning{}, fmt.Errorf("%w: decode %s: %w", ErrInvalidMemoryTidy, path, err) return MemoryTidyTuning{}, fmt.Errorf("%w: decode %s: %w", ErrInvalidMemoryTidy, path, err)
@@ -0,0 +1,217 @@
// memorytidy_seed.go 落地主人 2026-10-07 那句「**`.env` 只做兜底用** —— 后端**自动
// 创建**独立的面板配置文件**要做**」里的「自动创建」:进程启动时若
// `$DATA_DIR/config/memory-tidy.json` **不存在**,就按**当时生效的值**播种一份,
// 使这个家族从此有一份自足的一等来源,而不是每次都靠 `.env` 兜。
//
// 与 memorytidy.go(读取)的边界同源:本文件只管**文件形状与落盘**,不认识
// TidyConfig / ReviewConfig,也不知道任何一个默认值是多少 —— 那属于 memory 侧
// normalize 的职责。播种内容由调用方给出(`memory.TidyTuningSnapshot` 拍出的
// 「当前生效值」快照),与 materialize.go 拿到的是 `.env` 合成组同一手法。
//
// 四条纪律,前三条逐字对齐 materialize.go(那是本仓「`.env` → 面板文件」播种的既有
// 施工口径,本文件是同一套搬到 memory-tidy 家族):
//
// 1. **绝不覆盖已存在的文件**:文件在(无论内容是什么、无论解不解得开)⇒ 一个字节
// 都不动。这条是铁律 —— 主人手改过的文件被程序盖回去,就是「面板改一次、重启
// 一次被打回」那个失败形态。
// 2. **损坏不是要「修好」的状态**:解不开时只记 WARN、按 `.env` 跑、**不写盘**。
// 人工写坏的配置可以人工救回,程序盖掉它就丢了那份人工意图。
// 3. **幂等**:只有「文件不存在(或全空白)」这一次会落盘;连续启动多次,第二次起
// 是空操作(文件已存在 ⇒ 走第 1 条)。
// 4. **不阻断启动**:返回的 error 只在「该写却没写成」时非 nil(写盘失败 / 种子
// 残缺);调用方记一条 WARN 即可 —— 播种失败时运行期仍按 `.env` + 代码默认跑,
// 行为与播种前逐字相同。
//
// 日志**只打路径、原因与键名,不打值**:这十键今天都不是凭据,但「配置日志不打值」
// 是本仓既有口径(materialize.go 的「绝不打 apiKey」),这里从一开始就照做,免得
// 日后加一个敏感键时要回头改日志。
package runtimeconfig
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
const (
// memoryTidySeedReasonMissing / ...Blank / ...Exists 是播种日志的 reason 字段值
// (与 materialize.go 的 reason 口径一致:让人一眼看出是「没有」「空」还是
// 「已在、没动它」)。
memoryTidySeedReasonMissing = "面板配置不存在"
memoryTidySeedReasonBlank = "面板配置为空文件"
memoryTidySeedReasonExists = "面板配置已存在,未改动"
// memoryTidySeedFileMode 是播种文件的权限。0600 与 SaveModels / SavePlugins
// 同口径:配置目录可能放凭据,一律不给组/其他读权限。
memoryTidySeedFileMode = 0o600
// memoryTidySeedDirMode 是新建 `$DATA_DIR/config/` 时的目录权限,与 SaveModels
// 的 mkdir 口径一致。
memoryTidySeedDirMode = 0o755
)
// memoryTidySeedKeys 是播种文件应有的键名全集(顺序即 JSON 顺序)。它用于播种日志
// 的「播了哪些键」,并与结构体的 json tag 相互钉住(见
// TestMemoryTidySeedKeysMatchStruct):加了新键却忘了更新它,用例会红。
var memoryTidySeedKeys = []string{
"autoApply", "autoApplyPerRound", "ingestGate", "t1Judge",
"t1PerWindow", "t1WindowSec", "intervalSec", "quietIntervalSec",
"quietStartHour", "quietEndHour",
}
// MemoryTidySeed 是一次播种的可诊断产物(口径对齐 materialize.go 的 Result:
// Written / Warn 的含义逐字相同,Existed 用来区分「跳过」的起因)。
type MemoryTidySeed struct {
// Written 为 true 表示本次真的落盘(只在文件缺失/全空白时可能为 true)。
Written bool
// Existed 为 true 表示文件已存在(含解不开的),本次一个字节都没动。
// 它与 Written 互斥。
Existed bool
// Warn 是非致命异常:文件存在但解不开(按 `.env` 跑,且不写盘)。
Warn error
}
// SeedMemoryTidy 在 path 处播种一份面板配置 —— **仅当它缺失或全空白**。
//
// 「全空白」与「缺失」同义是既有口径(LoadMemoryTidyTuning 把两者都读成「还没配过」
// ⇒ 零值 ⇒ 走 `.env` 兜底),因此对空白文件播种,那一刻的生效值一个字都不变 ——
// 与 materialize.go 对空文件的处置一致。
//
// `null` **不算空白**:它解得出零值,但它是「盘上有一个文件」这个事实本身,按铁律
// 不动它(主人的口径是「不存在才自动创建」,不是「内容看着空就重建」)。
//
// log 由调用方给出(组合根一律传带 source 的日志器):本函数会直接往它上面写日志,
// 因此**不要传 nil**。
func SeedMemoryTidy(path string, seed MemoryTidyTuning, log *slog.Logger) (MemoryTidySeed, error) {
log = logger.WithSource(log, "config/runtimeconfig")
raw, readErr := os.ReadFile(path)
if readErr == nil {
return seedMemoryTidyExisting(log, path, raw, seed)
}
if !os.IsNotExist(readErr) {
log.Warn("memory-tidy.json 不可读,本次跳过播种(按 .env 兜底运行)", "path", path, "error", readErr)
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: 读取 %s: %w", path, readErr)
}
return writeMemoryTidySeed(log, path, seed, memoryTidySeedReasonMissing)
}
// seedMemoryTidyExisting 处理「文件已存在」的三种形态,三种处置都**不覆盖**:
//
// - 解不开 ⇒ WARN + 按 `.env` 跑(绝不当场「修好」它,见文件头第 2 条);
// - 全空白 ⇒ 按「还没配过」播种(唯一会在文件已存在时写盘的形态,理由见
// SeedMemoryTidy 的说明);
// - 其余(含 `null`)⇒ 跳过。
func seedMemoryTidyExisting(log *slog.Logger, path string, raw []byte, seed MemoryTidyTuning) (MemoryTidySeed, error) {
if strings.TrimSpace(string(raw)) == "" {
log.Info("memory-tidy.json 是空文件(按「还没配过」处理),准备播种", "path", path)
return writeMemoryTidySeed(log, path, seed, memoryTidySeedReasonBlank)
}
if _, err := decodeMemoryTidy(raw, path); err != nil {
// 解不开 ≠ 可以盖掉:只留 WARN,一个字都不写盘。这条 WARN 是主人可查的
// 唯一线索 —— 「这份配置没生效、也没被程序改过」。
log.Warn("memory-tidy.json 无法解析,本次不播种也不覆盖原文件(按 .env 兜底运行)",
"path", path, "error", err)
return MemoryTidySeed{Existed: true, Warn: err}, nil
}
// 跳过也留痕(Info 而不是 Debug:这是**每次启动一行**的交接回执 —— 主人能从日志
// 确认「后端认得我手写的那份文件、没有动它」,而不是只有提升日志级别才看得见)。
log.Info("memory-tidy.json 已存在,跳过播种(面板配置优先,绝不覆盖原文件)",
"path", path, "reason", memoryTidySeedReasonExists)
return MemoryTidySeed{Existed: true}, nil
}
// writeMemoryTidySeed 真正落盘:先把 `$DATA_DIR/config/` 建出来(不存在时),再按
// 「临时文件 + rename」原子写(同 SaveModels / SaveWebSearch:并发读者绝不会看到
// 半个 JSON),权限 0600。
//
// 落盘前先验种子**完整**(见 memoryTidySeedComplete):一份缺键的快照写下去会变成
// `"intervalSec": null` —— 读回来语义上等于「没配」,但主人打开文件会看到一堆 null,
// 且「播种 = 当时的生效值」这句话就不再成立。残缺的种子是调用方的编程错误,宁可
// WARN + 不落盘,也不留一份自相矛盾的文件。
func writeMemoryTidySeed(log *slog.Logger, path string, seed MemoryTidyTuning, reason string) (MemoryTidySeed, error) {
if err := memoryTidySeedComplete(seed); err != nil {
log.Warn("memory-tidy.json 播种内容不完整,本次不落盘(.env 继续兜底)", "path", path, "error", err)
return MemoryTidySeed{}, err
}
raw, err := json.MarshalIndent(seed, "", " ")
if err != nil {
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: marshal memory tidy seed: %w", err)
}
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, memoryTidySeedDirMode); err != nil {
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: mkdir %s: %w", dir, err)
}
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, raw, memoryTidySeedFileMode); err != nil {
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: rename %s: %w", path, err)
}
log.Info("memory-tidy.json 已按当时生效值播种落盘(此后以面板配置为准,.env 只兜底)",
"path", path, "reason", reason, "keys", memoryTidySeedKeys)
return MemoryTidySeed{Written: true}, nil
}
// memoryTidySeedComplete 报告种子是否「十键齐全且值可用」。
//
// 为什么要「齐全」而不是「非 nil 就放行」:MemoryTidyTuning 的指针语义是「nil = 没配」,
// 一份缺键的种子写下去虽然与「不写」在读取端等价,但它会让文件里出现 null —— 这份文件
// 的主人(主人 / 面板)看到的东西必须与「生效值」逐字一致。
//
// 数值键要求为**正**:`intervalSec: 0` 在 memory 侧是**坏值**(非正周期让心跳空转,
// 见 ApplyTidyScheduleTuning),播种出一份自己读回都要 WARN 的配置是自相矛盾的;
// 钟点则要求落在 0..23(越界会让 time.Date 静默滚动到别的一天)。
func memoryTidySeedComplete(seed MemoryTidyTuning) error {
missing := make([]string, 0, len(memoryTidySeedKeys))
if seed.AutoApply == nil {
missing = append(missing, "autoApply")
}
if seed.IngestGate == nil {
missing = append(missing, "ingestGate")
}
if seed.T1Judge == nil {
missing = append(missing, "t1Judge")
}
if seed.IntervalSec == nil {
missing = append(missing, "intervalSec")
}
if seed.QuietIntervalSec == nil {
missing = append(missing, "quietIntervalSec")
}
if seed.QuietStartHour == nil {
missing = append(missing, "quietStartHour")
}
if seed.QuietEndHour == nil {
missing = append(missing, "quietEndHour")
}
if len(missing) > 0 {
return fmt.Errorf("runtimeconfig: memory tidy seed missing keys: %s", strings.Join(missing, ", "))
}
if seed.AutoApplyPerRound <= 0 {
return fmt.Errorf("runtimeconfig: memory tidy seed autoApplyPerRound = %d (want > 0)", seed.AutoApplyPerRound)
}
if seed.T1PerWindow <= 0 {
return fmt.Errorf("runtimeconfig: memory tidy seed t1PerWindow = %d (want > 0)", seed.T1PerWindow)
}
if seed.T1WindowSec <= 0 {
return fmt.Errorf("runtimeconfig: memory tidy seed t1WindowSec = %d (want > 0)", seed.T1WindowSec)
}
if *seed.IntervalSec <= 0 {
return fmt.Errorf("runtimeconfig: memory tidy seed intervalSec = %d (want > 0)", *seed.IntervalSec)
}
if *seed.QuietIntervalSec <= 0 {
return fmt.Errorf("runtimeconfig: memory tidy seed quietIntervalSec = %d (want > 0)", *seed.QuietIntervalSec)
}
if *seed.QuietStartHour < 0 || *seed.QuietStartHour > 23 {
return fmt.Errorf("runtimeconfig: memory tidy seed quietStartHour = %d (want 0..23)", *seed.QuietStartHour)
}
if *seed.QuietEndHour < 0 || *seed.QuietEndHour > 23 {
return fmt.Errorf("runtimeconfig: memory tidy seed quietEndHour = %d (want 0..23)", *seed.QuietEndHour)
}
return nil
}
@@ -0,0 +1,337 @@
package runtimeconfig
import (
"bytes"
"encoding/json"
"errors"
"log/slog"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
)
// 本文件锁定**启动播种**(memorytidy_seed.go;主人 2026-10-07:「**`.env` 只做兜底
// 用** …… 后端**自动创建**独立的面板配置文件」)的四条纪律:
//
// ① 文件不存在 ⇒ 按调用方给的「当前生效值」播种(**十键齐全**,不是只播老六键);
// ② 文件已存在 ⇒ **一个字节都不动**(也不留 `.tmp`);
// ③ 文件损坏 ⇒ **不写盘**(WARN + 按 `.env` 跑),绝不「修好它」;
// ④ 幂等:连跑两次只在第一次写盘。
//
// 用例全部落在**临时目录**(`t.TempDir()`):生产那份 `data/config/memory-tidy.json`
// 是主人手工建的,播种逻辑只在文件缺失时触发,用例不许依赖它存在与否。
// completeSeedTuning 造一份「十键齐全」的播种内容。值刻意都**不是**代码默认值
// (autoApply=false / 3600 / 10800),这样「播的是调用方给的快照」与「播了一份硬编码
// 的默认值」在断言里一眼可分。
func completeSeedTuning() MemoryTidyTuning {
return MemoryTidyTuning{
AutoApply: boolSeedPtr(false),
AutoApplyPerRound: 7,
IngestGate: boolSeedPtr(true),
T1Judge: boolSeedPtr(false),
T1PerWindow: 240,
T1WindowSec: 1800,
IntervalSec: intSeedPtr(3600),
QuietIntervalSec: intSeedPtr(10800),
QuietStartHour: intSeedPtr(23),
QuietEndHour: intSeedPtr(7),
}
}
// boolSeedPtr / intSeedPtr 造「快照里显式写了这个键」的指针。
func boolSeedPtr(v bool) *bool { return &v }
func intSeedPtr(v int) *int { return &v }
// seedPath 返回一个**尚不存在**的落点(含尚不存在的 `config/` 目录)。
func seedPath(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "config", memoryTidyFileName)
}
// decodeSeed 把盘上的文件解成键集合,供「十键齐全」的断言(比结构体更贴近主人看到
// 的那份文件)。
func decodeSeed(t *testing.T, path string) map[string]json.RawMessage {
t.Helper()
var keys map[string]json.RawMessage
if err := json.Unmarshal([]byte(readRaw(t, path)), &keys); err != nil {
t.Fatalf("解 %s 的键集合: %v", path, err)
}
return keys
}
// assertNoTempLeft 断言播种没留下临时文件(原子写的中间态不该留在盘上)。
func assertNoTempLeft(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
t.Fatalf("留下了临时文件 %s.tmp", path)
}
}
// TestSeedMemoryTidy_SeedsMissingFile 是本任务的主用例:文件不存在 ⇒ 播种,且**内容
// 就是调用方给的快照**(十键齐全、含四项频率键),目录也一并建出来。
func TestSeedMemoryTidy_SeedsMissingFile(t *testing.T) {
t.Parallel()
path := seedPath(t) // 连 config/ 目录都不存在
seed := completeSeedTuning()
res, err := SeedMemoryTidy(path, seed, discardLog())
if err != nil {
t.Fatalf("SeedMemoryTidy: %v", err)
}
if !res.Written || res.Existed || res.Warn != nil {
t.Fatalf("结果 = %+v, want Written 且非 Existed 且无 Warn", res)
}
assertNoTempLeft(t, path)
// 十键齐全(含新增的四项频率键)——「别只播老六键」的断言就在这里。
keys := decodeSeed(t, path)
for _, key := range memoryTidySeedKeys {
if _, ok := keys[key]; !ok {
t.Fatalf("播种文件缺键 %q:%s", key, readRaw(t, path))
}
}
if len(keys) != len(memoryTidySeedKeys) {
t.Fatalf("播种文件的键数 = %d, want %d:%s", len(keys), len(memoryTidySeedKeys), readRaw(t, path))
}
// 回读走的是**运行期同一条**读取路径(LoadMemoryTidyTuning),因此这里同时钉住
// 「写下去的东西读回来就是那份快照」。
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if !reflect.DeepEqual(got, seed) {
t.Fatalf("回读 = %+v, want %+v(播种内容必须逐键等于调用方给的生效值)", got, seed)
}
}
// TestSeedMemoryTidy_NeverTouchesExistingFile 钉住第一条铁律:文件在 ⇒ 一个字节都不动。
// 覆盖四种盘上形态(面板值 / 空对象 / null / 手写带未知键),四种都必须原样保留 ——
// 尤其 `null`:它解得出零值(语义上等于没配),但它是「盘上有一个文件」这个事实本身。
func TestSeedMemoryTidy_NeverTouchesExistingFile(t *testing.T) {
t.Parallel()
bodies := []string{
`{"autoApply": false}`,
`{}`,
`null`,
"{\n \"intervalSec\": 42,\n \"unknownKey\": \"keep me\"\n}\n",
}
for _, body := range bodies {
path := writeMemoryTidyFile(t, body)
before := readRaw(t, path)
res, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
if err != nil {
t.Fatalf("%q: SeedMemoryTidy: %v", body, err)
}
if res.Written || !res.Existed || res.Warn != nil {
t.Fatalf("%q: 结果 = %+v, want 跳过(Existed 且非 Written 且无 Warn)", body, res)
}
if after := readRaw(t, path); after != before {
t.Fatalf("%q: 已存在的文件被改了字节:\n before=%s\n after =%s", body, before, after)
}
assertNoTempLeft(t, path)
}
}
// TestSeedMemoryTidy_BrokenFileIsNotRepaired 钉住第二条纪律:损坏 ⇒ WARN + 按 `.env`
// 跑 + **一个字都不写盘**。损坏的文件可以人工救回,程序盖掉它就丢了那份人工意图。
func TestSeedMemoryTidy_BrokenFileIsNotRepaired(t *testing.T) {
t.Parallel()
bodies := []string{
`{"autoApply": "yes"}`,
`{"intervalSec": "3600"}`,
`not json at all`,
`[1,2,3]`,
}
for _, body := range bodies {
path := writeMemoryTidyFile(t, body)
before := readRaw(t, path)
res, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
if err != nil {
// 坏文件不是「该写却没写成」:它压根不该被写,因此不是 error。
t.Fatalf("%q: 坏文件不该让播种返回错误(它只是不写盘):%v", body, err)
}
if res.Written || !res.Existed {
t.Fatalf("%q: 结果 = %+v, want 既不写也不当新文件", body, res)
}
if !errors.Is(res.Warn, ErrInvalidMemoryTidy) {
t.Fatalf("%q: Warn = %v, want ErrInvalidMemoryTidy(分类错误必须与读取侧同一个)", body, res.Warn)
}
if after := readRaw(t, path); after != before {
t.Fatalf("%q: 损坏的文件被覆盖/「修好」了:\n before=%s\n after =%s", body, before, after)
}
assertNoTempLeft(t, path)
}
}
// TestSeedMemoryTidy_IsIdempotent 钉住幂等:连跑两次,只有第一次写盘,第二次连字节
// 都不改(这是「连续启动多次只写一次」的可验证版本)。
func TestSeedMemoryTidy_IsIdempotent(t *testing.T) {
t.Parallel()
path := seedPath(t)
first, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
if err != nil || !first.Written {
t.Fatalf("第一次: Written=%v err=%v, want 落盘", first.Written, err)
}
afterFirst := readRaw(t, path)
second, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
if err != nil {
t.Fatalf("第二次: %v", err)
}
if second.Written || !second.Existed {
t.Fatalf("第二次 = %+v, want 跳过(文件已在)", second)
}
if afterSecond := readRaw(t, path); afterSecond != afterFirst {
t.Fatalf("第二次改动了文件:\n first=%s\n second=%s", afterFirst, afterSecond)
}
}
// TestSeedMemoryTidy_SeedsBlankFile 钉住「全空白」的处置:它与「缺失」同义是既有口径
// (LoadMemoryTidyTuning 把两者都读成「还没配过」),因此也播种 —— 那一刻的生效值
// 一个字都不变。
func TestSeedMemoryTidy_SeedsBlankFile(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, " \n\t")
res, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
if err != nil {
t.Fatalf("SeedMemoryTidy: %v", err)
}
if !res.Written || res.Existed {
t.Fatalf("结果 = %+v, want 按「还没配过」播种", res)
}
if len(decodeSeed(t, path)) != len(memoryTidySeedKeys) {
t.Fatalf("空白文件被播种后应当十键齐全:%s", readRaw(t, path))
}
}
// TestSeedMemoryTidy_RefusesIncompleteSeed 钉住种子的完整性契约:残缺的快照
// (缺指针 / 非正周期 / 钟点越界)宁可 WARN + 不落盘,也不写出一份自己读回都要
// WARN、或满是 null 的文件。
func TestSeedMemoryTidy_RefusesIncompleteSeed(t *testing.T) {
t.Parallel()
cases := []struct {
name string
seed MemoryTidyTuning
}{
{"缺 autoApply", func() MemoryTidyTuning { s := completeSeedTuning(); s.AutoApply = nil; return s }()},
{"缺 intervalSec", func() MemoryTidyTuning { s := completeSeedTuning(); s.IntervalSec = nil; return s }()},
{"缺 quietStartHour", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietStartHour = nil; return s }()},
{"intervalSec 为 0", func() MemoryTidyTuning { s := completeSeedTuning(); s.IntervalSec = intSeedPtr(0); return s }()},
{"quietIntervalSec 为负", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietIntervalSec = intSeedPtr(-1); return s }()},
{"autoApplyPerRound 为 0", func() MemoryTidyTuning { s := completeSeedTuning(); s.AutoApplyPerRound = 0; return s }()},
{"t1PerWindow 为 0", func() MemoryTidyTuning { s := completeSeedTuning(); s.T1PerWindow = 0; return s }()},
{"t1WindowSec 为负", func() MemoryTidyTuning { s := completeSeedTuning(); s.T1WindowSec = -60; return s }()},
{"quietStartHour 越界", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietStartHour = intSeedPtr(24); return s }()},
{"quietEndHour 越界", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietEndHour = intSeedPtr(-1); return s }()},
{"完全零值", MemoryTidyTuning{}},
}
for _, tc := range cases {
path := seedPath(t)
if _, err := SeedMemoryTidy(path, tc.seed, discardLog()); err == nil {
t.Fatalf("%s: err = nil, want 拒绝残缺种子", tc.name)
}
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
t.Fatalf("%s: 残缺种子不得落盘:%s", tc.name, readRaw(t, path))
}
assertNoTempLeft(t, path)
}
}
// TestSeedMemoryTidy_LogsPathAndReasonWithoutValues 钉住「可观测」:写盘与跳过都有
// 日志(带路径与原因、带 source),且**不打任何配置值**(本仓配置日志的既有口径)。
func TestSeedMemoryTidy_LogsPathAndReasonWithoutValues(t *testing.T) {
t.Parallel()
// ① 播种:Info 日志带路径、原因、source 与键名,但没有任何值。
// 日志级别用默认的 Info(不是 Debug):主人要在**默认日志级别**下就能看到这件事
// 发生了 —— 可观测是这条口径的一部分,不是「把级别调高才看得见」。
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
path := seedPath(t)
seed := completeSeedTuning()
seed.IntervalSec = intSeedPtr(4242) // 一眼可辨的值:它**不许**出现在日志里
if _, err := SeedMemoryTidy(path, seed, log); err != nil {
t.Fatalf("SeedMemoryTidy: %v", err)
}
out := buf.String()
if !strings.Contains(out, memoryTidyFileName) {
t.Fatalf("播种日志没带路径:%s", out)
}
if !strings.Contains(out, memoryTidySeedReasonMissing) {
t.Fatalf("播种日志没带原因:%s", out)
}
if !strings.Contains(out, "source=config/runtimeconfig") {
t.Fatalf("播种日志没标来源(AGENTS §10 禁止无来源裸日志):%s", out)
}
if !strings.Contains(out, "已按当时生效值播种落盘") {
t.Fatalf("播种日志没说明做了什么:%s", out)
}
for _, value := range []string{"4242", "10800", "1800"} {
if strings.Contains(out, value) {
t.Fatalf("日志里出现了配置值 %s(配置日志只许打键名):%s", value, out)
}
}
// ② 再次调用(文件已在):跳过也要有日志(**默认 Info 级别**可见,含路径与原因),
// 且同样不打值。
buf.Reset()
if _, err := SeedMemoryTidy(path, seed, log); err != nil {
t.Fatalf("第二次 SeedMemoryTidy: %v", err)
}
skipped := buf.String()
if !strings.Contains(skipped, "跳过播种") || !strings.Contains(skipped, memoryTidyFileName) {
t.Fatalf("跳过播种的日志缺路径或说明:%s", skipped)
}
if !strings.Contains(skipped, "绝不覆盖") {
t.Fatalf("跳过播种的日志没说明理由(绝不覆盖):%s", skipped)
}
if !strings.Contains(skipped, memoryTidySeedReasonExists) {
t.Fatalf("跳过播种的日志没带 reason 字段:%s", skipped)
}
for _, value := range []string{"4242", "10800", "1800"} {
if strings.Contains(skipped, value) {
t.Fatalf("跳过日志里出现了配置值 %s:%s", value, skipped)
}
}
// ③ 损坏:WARN 日志带路径与分类错误(主人据此知道「这份配置没生效、也没被改」)。
buf.Reset()
broken := writeMemoryTidyFile(t, `{"autoApply": "yes"}`)
if _, err := SeedMemoryTidy(broken, seed, log); err != nil {
t.Fatalf("坏文件的 SeedMemoryTidy: %v", err)
}
warned := buf.String()
if !strings.Contains(warned, "level=WARN") || !strings.Contains(warned, "无法解析") {
t.Fatalf("损坏文件必须留一条 WARN:%s", warned)
}
if !strings.Contains(warned, memoryTidyFileName) {
t.Fatalf("WARN 没带路径:%s", warned)
}
}
// TestMemoryTidySeedKeysMatchStruct 是**防漂移**断言:播种日志里列的键名与
// MemoryTidyTuning 的 json tag 必须一一对应。加了新键却忘了更新 memoryTidySeedKeys,
// 用例就会红 —— 否则「播了哪些键」的日志会安静地漏报。
func TestMemoryTidySeedKeysMatchStruct(t *testing.T) {
t.Parallel()
typ := reflect.TypeOf(MemoryTidyTuning{})
var tags []string
for i := 0; i < typ.NumField(); i++ {
tag, ok := typ.Field(i).Tag.Lookup("json")
if !ok || tag == "" {
t.Fatalf("字段 %s 没有 json tag", typ.Field(i).Name)
}
tags = append(tags, strings.Split(tag, ",")[0])
}
want := append([]string(nil), memoryTidySeedKeys...)
sort.Strings(tags)
sort.Strings(want)
if !reflect.DeepEqual(tags, want) {
t.Fatalf("结构体的键 %v 与 memoryTidySeedKeys %v 不一致(加了新键就要同步那张表)", tags, want)
}
}
@@ -211,3 +211,70 @@ func writeMemoryTidyFile(t *testing.T, body string) string {
} }
return path return path
} }
// TestLoadMemoryTidyTuning_ScheduleKeys 钉住 **整理频率**四项
// (intervalSec / quietIntervalSec / quietStartHour / quietEndHour)的读取口径。
//
// 为什么钟点必须能区分「显式 0」与「没写」:0 是**合法钟点**(0-7 的凌晨窗口、
// 23-0 到午夜的窗口),用 int 承载会让只写一端的文件静默改掉另一端。因此这里
// 断言的是「指针是否非 nil」,而不是「值是否非 0」。
//
// 值的**可用性**(非正数 ⇒ 回落)不在这里判:那是 memory 侧
// ApplyTidyScheduleTuning 的职责,本包只管文件形状与读取。
func TestLoadMemoryTidyTuning_ScheduleKeys(t *testing.T) {
t.Parallel()
// ① 文件不存在 ⇒ 四个键都是 nil(= 不覆盖,由 `.env` / 代码默认兜底)。
absent, err := LoadMemoryTidyTuning(filepath.Join(t.TempDir(), memoryTidyFileName))
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(absent): %v", err)
}
if absent.IntervalSec != nil || absent.QuietIntervalSec != nil ||
absent.QuietStartHour != nil || absent.QuietEndHour != nil {
t.Fatalf("没配过时四个调度键都该是 nil,得到 %+v", absent)
}
// ② 四个键都写了 ⇒ 逐键读出来(与 env 无关,本层不比对)。
got, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t,
`{"intervalSec": 3600, "quietIntervalSec": 10800, "quietStartHour": 1, "quietEndHour": 5}`))
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(set): %v", err)
}
if got.IntervalSec == nil || *got.IntervalSec != 3600 {
t.Fatalf("intervalSec = %v, want 3600", got.IntervalSec)
}
if got.QuietIntervalSec == nil || *got.QuietIntervalSec != 10800 {
t.Fatalf("quietIntervalSec = %v, want 10800", got.QuietIntervalSec)
}
if got.QuietStartHour == nil || *got.QuietStartHour != 1 ||
got.QuietEndHour == nil || *got.QuietEndHour != 5 {
t.Fatalf("安静时段 = %v-%v, want 01:00-05:00", got.QuietStartHour, got.QuietEndHour)
}
// ③ 钟点显式写 0 ⇒ **是值**(不是「没配」):0-7 的凌晨窗口是合法配置。
zero, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"quietStartHour": 0, "quietEndHour": 7}`))
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(zero): %v", err)
}
if zero.QuietStartHour == nil || *zero.QuietStartHour != 0 {
t.Fatalf("显式写 0 的钟点必须原样保留,得到 %v", zero.QuietStartHour)
}
if zero.QuietEndHour == nil || *zero.QuietEndHour != 7 {
t.Fatalf("quietEndHour = %v, want 7", zero.QuietEndHour)
}
// ④ 秒数写 0 ⇒ 仍是「有值」(0 由 memory 侧判为坏值并回落 + WARN,本层不拦)。
explicitZero, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"intervalSec": 0}`))
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(explicit zero): %v", err)
}
if explicitZero.IntervalSec == nil || *explicitZero.IntervalSec != 0 {
t.Fatalf("显式写 0 的 intervalSec 应当被读出来(好坏由上层判),得到 %v", explicitZero.IntervalSec)
}
// ⑤ 类型写错(字符串)⇒ 整份文件解不开,分类错误 + 不启动失败。
// 这是**既有粒度**:一个键类型写错会让全部键一起回落 `.env`。
if _, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"intervalSec": "3600"}`)); !errors.Is(err, ErrInvalidMemoryTidy) {
t.Fatalf("intervalSec 写成字符串应当返回 ErrInvalidMemoryTidy,得到 %v", err)
}
}
+64 -9
View File
@@ -10,8 +10,8 @@ import (
// 使运行路径上不出现裸魔法数字(AGENTS §10),并让「设计稿默认值」 // 使运行路径上不出现裸魔法数字(AGENTS §10),并让「设计稿默认值」
// 只有一处定义、可被复核。 // 只有一处定义、可被复核。
// //
// 形态(design/23 §4 方案 A/C):整理是一件**低峰定期后台任务** —— 跑在 // 形态(design/23 §4 方案 A/C):整理是一件**定期后台任务**(安静时段降频)——
// 专属隔离会话下、零上下文启动、只产候选**不写记忆**,绝不是会话内派 // 跑在专属隔离会话下、零上下文启动、只产候选**不写记忆**,绝不是会话内派
// subagent。 // subagent。
const ( const (
@@ -37,8 +37,11 @@ const (
defaultTidyIntervalSec = 86400 defaultTidyIntervalSec = 86400
// defaultTidyQuietStartHour / defaultTidyQuietEndHour 复用 backthink 的 // defaultTidyQuietStartHour / defaultTidyQuietEndHour 复用 backthink 的
// 安静时段语义(默认 23:00-07:00,窗口跨午夜;两端相等且非 0 则关闭窗口) // 安静时段语义(默认 23:00-07:00,窗口跨午夜;两端相等且非 0 则关闭窗口)。
// —— 整理只在低峰跑(design/23 §4)。 //
// 🔴 2026-10-07 起窗口不再等于「唯一可跑时段」:它是**降频档位**的判据
// (安静时段用 defaultTidyIntervalSec 之外的另一档,见 intervalAt),并且
// 仍然决定「首次/未跑过的窗口要补跑一轮」(dueNow 判据 1/3)。
defaultTidyQuietStartHour = 23 defaultTidyQuietStartHour = 23
defaultTidyQuietEndHour = 7 defaultTidyQuietEndHour = 7
@@ -53,7 +56,7 @@ const (
// maxTidyCompareItems 是每分区每轮**真正参与两两比对**的条目上限的兜底默认 // maxTidyCompareItems 是每分区每轮**真正参与两两比对**的条目上限的兜底默认
// 值(可直接从 MEMORY_TIDY_COMPARE_LIMIT 调,见 TidyConfig.CompareLimit)。 // 值(可直接从 MEMORY_TIDY_COMPARE_LIMIT 调,见 TidyConfig.CompareLimit)。
// 两两相似度是 O(n²·dim),5000 条会到十亿级乘加,低峰任务也不该跑成那样。 // 两两相似度是 O(n²·dim),5000 条会到十亿级乘加,后台任务也不该跑成那样。
// 2026-10-06 由 400 抬到 2000:首轮真跑 459 条的分区被旧值截到 400, // 2026-10-06 由 400 抬到 2000:首轮真跑 459 条的分区被旧值截到 400,
// 于是 compared(705) < scanned(784) —— 截断确实如实登记了,但护栏成为结 // 于是 compared(705) < scanned(784) —— 截断确实如实登记了,但护栏成为结
// 果的主因本身就是缺陷。超限仍按「最新优先」截断并记一条 WARN(绝不静默), // 果的主因本身就是缺陷。超限仍按「最新优先」截断并记一条 WARN(绝不静默),
@@ -129,14 +132,28 @@ type TidyConfig struct {
// Enabled 是总开关。关闭时组合根不建服务、不跑任何整理 // Enabled 是总开关。关闭时组合根不建服务、不跑任何整理
// (design/23 §4 的 MEMORY_TIDY_ENABLED)。 // (design/23 §4 的 MEMORY_TIDY_ENABLED)。
Enabled bool Enabled bool
// Interval 是整理周期。为零时由 IntervalSec(或其默认值)推导。 // Interval 是整理周期(**非安静时段**)。为零时由 IntervalSec(或其默认值)推导。
Interval time.Duration Interval time.Duration
// IntervalSec 是整理周期秒数,映射自 config.MemoryTidyConfig.IntervalSec。 // IntervalSec 是整理周期秒数(**非安静时段**),映射自
// 设置了 Interval 时忽略。它是**下限**:安静时段每天只有一次, // config.MemoryTidyConfig.IntervalSec(env),并可由
// 因此实际间隔取两者中较长者。 // `$DATA_DIR/config/memory-tidy.json` 的 `intervalSec` 覆盖(面板优先、`.env`
// 兜底,唯一实现点是 ApplyTidyScheduleTuning)。设置了 Interval 时忽略。
IntervalSec int IntervalSec int
// QuietIntervalSec 是**安静时段**的整理周期秒数(2026-10-07 主人「或者动态
// 分配,比如白天间隔短一点,晚上到了静默时期稍微长一点」)。
//
// 非正 ⇒ 等于 IntervalSec:**缺省 = 不因时段换档**,而不是「夜里不跑」
// (normalize 里那一条是唯一落点)。它没有 env 键(`.env` 已冻结),只由
// `$DATA_DIR/config/memory-tidy.json` 的 `quietIntervalSec` 提供。
QuietIntervalSec int
// QuietInterval 是安静时段的整理周期。为零时由 QuietIntervalSec 推导。
QuietInterval time.Duration
// QuietStartHour / QuietEndHour 以本地钟点约束低峰窗口 // QuietStartHour / QuietEndHour 以本地钟点约束低峰窗口
// (复用 backthink 语义:窗口可跨午夜,两端相等且非 0 则关闭窗口)。 // (复用 backthink 语义:窗口可跨午夜,两端相等且非 0 则关闭窗口)。
//
// 🔴 2026-10-07 起窗口的**语义一字未改**(inQuietHours 逐字不动),但它的
// **作用面**收窄了一格:从「整理唯一可跑的时段」变成「整理降频的时段」
// (见 intervalAt)。理由是主人的频率口径要求白天也跑,且白天跑得比夜里勤。
QuietStartHour int QuietStartHour int
QuietEndHour int QuietEndHour int
// RecallThreshold 是候选召回的余弦门(见 defaultTidyRecallThreshold)。 // RecallThreshold 是候选召回的余弦门(见 defaultTidyRecallThreshold)。
@@ -165,6 +182,10 @@ type TidyConfig struct {
// TidyConfigFromConfig 把 config.MemoryTidyConfig(env 侧旋钮)映射成 // TidyConfigFromConfig 把 config.MemoryTidyConfig(env 侧旋钮)映射成
// TidyConfig,env 未暴露的项保持零值,交给 normalize 填默认值。 // TidyConfig,env 未暴露的项保持零值,交给 normalize 填默认值。
//
// 它是**兜底层**:组合根随后用 ApplyTidyScheduleTuning 把
// `$DATA_DIR/config/memory-tidy.json` 的调度旋钮叠上来(面板优先、env 兜底);
// env 没有的项(如 quietIntervalSec)在这里保持零值,由 normalize 取默认。
func TidyConfigFromConfig(cfg config.MemoryTidyConfig) TidyConfig { func TidyConfigFromConfig(cfg config.MemoryTidyConfig) TidyConfig {
return TidyConfig{ return TidyConfig{
Enabled: cfg.Enabled, Enabled: cfg.Enabled,
@@ -186,6 +207,14 @@ func normalizeTidyConfig(cfg TidyConfig) TidyConfig {
if cfg.Interval <= 0 { if cfg.Interval <= 0 {
cfg.Interval = time.Duration(cfg.IntervalSec) * time.Second cfg.Interval = time.Duration(cfg.IntervalSec) * time.Second
} }
if cfg.QuietIntervalSec <= 0 {
// 缺省 = 与普通间隔同档。放在 IntervalSec 归一之后,因此「面板/env 都没给
// quietIntervalSec」时它取的是**已解析的** intervalSec,而不是魔法数字。
cfg.QuietIntervalSec = cfg.IntervalSec
}
if cfg.QuietInterval <= 0 {
cfg.QuietInterval = time.Duration(cfg.QuietIntervalSec) * time.Second
}
if cfg.QuietStartHour == 0 && cfg.QuietEndHour == 0 { if cfg.QuietStartHour == 0 && cfg.QuietEndHour == 0 {
cfg.QuietStartHour = defaultTidyQuietStartHour cfg.QuietStartHour = defaultTidyQuietStartHour
cfg.QuietEndHour = defaultTidyQuietEndHour cfg.QuietEndHour = defaultTidyQuietEndHour
@@ -230,6 +259,32 @@ func (c TidyConfig) inQuietHours(at time.Time) bool {
return h >= c.QuietStartHour || h < c.QuietEndHour return h >= c.QuietStartHour || h < c.QuietEndHour
} }
// intervalAt 按 **at 所在时段** 选整理周期:安静时段用 QuietInterval,其余用
// Interval(= design/12 §N A1 给后台思考心跳定下的「窗口内降频」口径,搬进整理)。
//
// 主人 2026-10-07 的原话是「**频率必须调**,可以调到 1 小时或者三小时,**或者动态
// 分配**,比如白天间隔短一点,晚上到了静默时期稍微长一点」——这个函数就是「动态
// 分配」的唯一判据。
//
// 它是**纯函数**(只读 cfg 与入参),因此心跳可以在**每一拍之前**重算它:跨过
// 安静时段边界之后,下一拍自然换档,而不是启动时定死一次(调度侧的用法见
// TidyService.nextDelay)。
//
// 两条口径:
// - 安静判定**直接复用** inQuietHours,跨午夜口径与其余各处以字节为单位一致,
// 这里不另写一份「越走越偏」的窗口算法;
// - 窗口被显式关闭(两端相等且非 0)时不换档 —— 关闭窗口的意思是「没有低峰
// 这回事」,而不是「全天都算低峰」。
func (c TidyConfig) intervalAt(at time.Time) time.Duration {
if c.quietWindowDisabled() {
return c.Interval
}
if c.QuietInterval > 0 && c.inQuietHours(at) {
return c.QuietInterval
}
return c.Interval
}
// nextQuietStart 返回 at 之后(严格晚于 at)的下一个低峰窗口起点: // nextQuietStart 返回 at 之后(严格晚于 at)的下一个低峰窗口起点:
// 今天/明天的 QuietStartHour 整点。窗口关闭时返回零值时间,调用方据此 // 今天/明天的 QuietStartHour 整点。窗口关闭时返回零值时间,调用方据此
// 改走纯周期路径。 // 改走纯周期路径。
+1 -1
View File
@@ -8,7 +8,7 @@ import (
// 本文件是记忆整理的**试算(dry-run)**入口(契约 v0.75;切片 MT1)。 // 本文件是记忆整理的**试算(dry-run)**入口(契约 v0.75;切片 MT1)。
// //
// 它与低峰心跳跑的是**同一条**规则路径(扫描 → 召回 → 包含判定 → 规则否决 → // 它与心跳跑的是**同一条**规则路径(扫描 → 召回 → 包含判定 → 规则否决 →
// 构造候选),只有两点刻意不同: // 构造候选),只有两点刻意不同:
// //
// 1. **零副作用**:候选不落库 —— 不写 memory_tidy_candidates、不写 // 1. **零副作用**:候选不落库 —— 不写 memory_tidy_candidates、不写
@@ -0,0 +1,187 @@
package memory
import (
"context"
"testing"
"time"
)
// 本文件锁定**分时段的动态频率**(主人 2026-10-07:「频率必须调 …… 或者动态分配,
// 比如白天间隔短一点,晚上到了静默时期稍微长一点」):
//
// ① 非安静时段用 intervalSec、安静时段用 quietIntervalSec(intervalAt);
// ② 缺省(quietIntervalSec 未配)时两者相等 —— 也就是**行为与改动前一致**;
// ③ 间隔在**每一轮跑完之后按当时时段重算**(不是在启动时算一次定死):
// 否则白天/晚上的切换不会生效;
// ④ 到点判据(dueNow)用**当时时段**的周期,与 nextDelay 同源,不会各说各话;
// ⑤ 安静时段不再是「唯一可跑时段」:窗口外也按 intervalSec 跑(①的直接推论,
// 由 ③ 的用例端到端证明)。
// quietWindowCfg 造一份分档周期配置:安静 22:00-02:00(跨午夜),
// 白天 2h 一次、夜里 6h 一次。
func quietWindowCfg(t *testing.T) TidyConfig {
t.Helper()
cfg := tidyTestCfg(0.80)
cfg.IntervalSec = 2 * 3600
cfg.QuietIntervalSec = 6 * 3600
cfg.QuietStartHour, cfg.QuietEndHour = 22, 2
return cfg
}
// TestTidyConfigIntervalAt_SwitchesByPeriod 钉住 intervalAt 的取档口径与
// inQuietHours 逐字同源(含跨午夜、边界、窗口关闭)。
func TestTidyConfigIntervalAt_SwitchesByPeriod(t *testing.T) {
t.Parallel()
cfg := normalizeTidyConfig(quietWindowCfg(t))
at := func(hour, min int) time.Time {
return time.Date(2026, 10, 7, hour, min, 0, 0, time.Local)
}
cases := []struct {
name string
at time.Time
want time.Duration
}{
{"白天(14:00)用 intervalSec", at(14, 0), 2 * time.Hour},
{"窗口前一分钟(21:59)仍用 intervalSec", at(21, 59), 2 * time.Hour},
{"窗口起点(22:00)用 quietIntervalSec", at(22, 0), 6 * time.Hour},
{"跨午夜的半夜(00:30)用 quietIntervalSec", at(0, 30), 6 * time.Hour},
{"窗口终点前(01:59)用 quietIntervalSec", at(1, 59), 6 * time.Hour},
{"窗口终点(02:00)回到 intervalSec", at(2, 0), 2 * time.Hour},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
got := cfg.intervalAt(tc.at)
if got != tc.want {
t.Fatalf("intervalAt(%s) = %s, want %s", tc.at.Format("15:04"), got, tc.want)
}
// 与既有安静判定**同源**:落在哪一档当且仅当 inQuietHours。
want := cfg.Interval
if cfg.inQuietHours(tc.at) {
want = cfg.QuietInterval
}
if got != want {
t.Fatalf("intervalAt 与 inQuietHours 不同源:%s interval=%s, 期望 %s",
tc.at.Format("15:04"), got, want)
}
})
}
// 缺省(quietIntervalSec 未配)⇒ 两档相等 = 行为与本次改动之前逐字一致。
plain := normalizeTidyConfig(tidyTestCfg(0.80))
if plain.QuietIntervalSec != plain.IntervalSec || plain.QuietInterval != plain.Interval {
t.Fatalf("缺省时两档必须相等:interval=%s quiet=%s", plain.Interval, plain.QuietInterval)
}
if got := plain.intervalAt(at(23, 30)); got != plain.Interval {
t.Fatalf("缺省时分档不得改变频率:得到 %s, want %s", got, plain.Interval)
}
// 窗口被显式关闭(两端相等且非 0)⇒ 不换档:关闭窗口的意思是「没有低峰这回事」,
// 而不是「全天都算低峰」。
closed := normalizeTidyConfig(quietWindowCfg(t))
closed.QuietStartHour, closed.QuietEndHour = 5, 5
for _, h := range []int{0, 5, 12, 23} {
if got := closed.intervalAt(at(h, 0)); got != closed.Interval {
t.Fatalf("窗口关闭时 %02d:00 的间隔 = %s, want %s", h, got, closed.Interval)
}
}
}
// TestTidyServiceDueNowUsesPeriodInterval 钉住「到点」判据用的是**当时时段**的周期。
//
// 用例刻意让「按白天档会判到点、按夜里档不会」:安静 23:00-07:00,白天 1h、
// 夜里 8h。上次跑在 23:05,此刻 01:00 ⇒ 距上次 1h55m:若拿白天的 1h 去比会判
// 「到点」(那是错的),拿夜里的 8h 才判「不到点」。
func TestTidyServiceDueNowUsesPeriodInterval(t *testing.T) {
t.Parallel()
store := newTestStore(t)
cfg := tidyTestCfg(0.80)
cfg.IntervalSec = 3600
cfg.QuietIntervalSec = 8 * 3600
cfg.QuietStartHour, cfg.QuietEndHour = 23, 7
svc := newTidyServiceForTest(t, store, cfg, nil, nil, nil)
set := func(now, last time.Time) {
svc.now = func() time.Time { return now }
svc.lastRun = last
}
last := time.Date(2026, 10, 7, 23, 5, 0, 0, time.Local)
set(time.Date(2026, 10, 8, 1, 0, 0, 0, time.Local), last)
if due, reason := svc.dueNow(); due {
t.Fatalf("夜里距上次 1h55m 不该到点(夜里的档是 8h),得到 due=true(%s)", reason)
}
set(time.Date(2026, 10, 9, 1, 0, 0, 0, time.Local), last)
if due, _ := svc.dueNow(); !due {
t.Fatal("夜里距上次已 25h55m 必须到点(≥ 8h)")
}
// 白天档:上次跑在 09:00,此刻 10:30 ⇒ 1.5h ≥ 1h ⇒ 到点。
set(time.Date(2026, 10, 7, 10, 30, 0, 0, time.Local),
time.Date(2026, 10, 7, 9, 0, 0, 0, time.Local))
if due, _ := svc.dueNow(); !due {
t.Fatal("白天距上次 1.5h 必须到点(白天的档是 1h)")
}
}
// TestTidyServiceRecomputesIntervalAfterEachRound 是**动态频率**的端到端证明:
// 安静 22:00-02:00(跨午夜),白天 2h、夜里 6h。20:00 从零启动(无历史)⇒
// 首次落在窗口起点 22:00;此后:
//
// 22:00 跑完 ⇒ 按**当时**(窗口内)的档 = 6h ⇒ 下一拍 04:00(已在窗口外)
// 04:00 跑完 ⇒ 按白天的档 = 2h ⇒ 06:00 ⇒ 08:00
//
// 断言的是**留痕的时刻序列**与其间隔:22:00 → 04:00 是 6h(夜里的档),
// 04:00 → 06:00 → 08:00 都是 2h(白天的档)。若把间隔在启动时算一次定死
// (或全程按同一个周期跑),这段序列不可能是这个形状 —— 变异用例钉住了它。
func TestTidyServiceRecomputesIntervalAfterEachRound(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, 20, 0, 0, 0, time.Local)}
probe := newTidyWaitProbe(clock, 4) // 4 拍之后退出(心跳 #5 返回 false)
svc := newTidyServiceForTest(t, store, quietWindowCfg(t), nil, clock.now, nil)
svc.wait = probe.wait
svc.Start(ctx)
probe.awaitCalls(t, 5)
if err := svc.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
first := time.Date(2026, 10, 7, 22, 0, 0, 0, time.Local)
want := []time.Time{
first.Add(10 * time.Hour), // 08:00(次日)
first.Add(8 * time.Hour), // 06:00
first.Add(6 * time.Hour), // 04:00(窗口外)
first, // 22:00(窗口起点)
}
gotRuns := tidyRunTimes(t, store)
if len(gotRuns) != len(want) {
t.Fatalf("运行留痕 = %v, want %d 轮", gotRuns, len(want))
}
for i, w := range want {
if gotRuns[i] != w.UTC().Format(time.RFC3339) {
t.Fatalf("第 %d 轮留痕 = %s, want %s(全部:%v)",
i+1, gotRuns[i], w.UTC().Format(time.RFC3339), gotRuns)
}
}
// 间隔必须**分档**:第一段(夜里)6h,其后(白天)2h。这一条同时证明窗口外也跑。
// want 是「最新在前」,因此这里按时间顺序取差。
gaps := []time.Duration{
want[2].Sub(want[3]), // 22:00 → 04:00:跨过窗口终点,用的是夜里的档
want[1].Sub(want[2]), // 04:00 → 06:00:窗口外,用白天的档
want[0].Sub(want[1]), // 06:00 → 08:00
}
if gaps[0] != 6*time.Hour {
t.Fatalf("夜里那一轮之后的间隔 = %s, want 6h(quietIntervalSec)", gaps[0])
}
if gaps[1] != 2*time.Hour || gaps[2] != 2*time.Hour {
t.Fatalf("白天间隔 = %s / %s, want 2h(intervalSec)", gaps[1], gaps[2])
}
}
+51 -20
View File
@@ -10,8 +10,8 @@ import (
"time" "time"
) )
// 本文件是记忆整理的**后台任务形态**(design/23 §4 方案 A/C):低峰定期跑、 // 本文件是记忆整理的**后台任务形态**(design/23 §4 方案 A/C):定期跑(安静时段
// 独立运行、零上下文启动、只产建议、绝不推送。 // 降频)、独立运行、零上下文启动、只产建议、绝不推送。
// //
// 与主人的硬约束对齐(design/23 §4.1): // 与主人的硬约束对齐(design/23 §4.1):
// - 零上下文:本服务的依赖里**没有任何会话/历史读数**,输入只有记忆条目 // - 零上下文:本服务的依赖里**没有任何会话/历史读数**,输入只有记忆条目
@@ -30,8 +30,14 @@ import (
// 重启覆盖,于是 memory_tidy_runs 只有 1 行、77 条候选全 pending。 // 重启覆盖,于是 memory_tidy_runs 只有 1 行、77 条候选全 pending。
// - 新口径(到点即跑 + 持久事实):启动时先读**持久事实** // - 新口径(到点即跑 + 持久事实):启动时先读**持久事实**
// (memory_tidy_runs 里最近一轮的开始时刻),满足任一条件就**立刻补跑一次**: // (memory_tidy_runs 里最近一轮的开始时刻),满足任一条件就**立刻补跑一次**:
// ① 距上次 run 已 ≥ interval;② 已在安静窗内且窗口起点晚于上次 run // ① 距上次 run 已 ≥ 当时间隔;② 已在安静窗内且窗口起点晚于上次 run
// (= 这个窗口还没跑过)。此后心跳照旧只在安静窗内跑。 // (= 这个窗口还没跑过)。此后心跳按「上一次 run + **当时时段**的周期」醒。
//
// 2026-10-07(主人「频率必须调……或者动态分配,白天间隔短一点,晚上到了静默时期
// 稍微长一点」)追加**分档周期**:非安静时段用 cfg.Interval,安静时段用
// cfg.QuietInterval(唯一判据 = TidyConfig.intervalAt)。安静时段因此从「唯一可跑
// 时段」变成「降频时段」,心跳不再有「窗口外一律跳过」这一档;而「到点即跑 +
// catch-up」那三条判据一条未动。
// //
// 这样「重启不饿死」是可证的:重启只影响内存,判据只依赖持久事实与墙上时钟。 // 这样「重启不饿死」是可证的:重启只影响内存,判据只依赖持久事实与墙上时钟。
@@ -131,6 +137,7 @@ func (s *TidyService) Start(ctx context.Context) {
s.log.Info("memory tidy started", s.log.Info("memory tidy started",
"interval", s.cfg.Interval.String(), "interval", s.cfg.Interval.String(),
"quiet_interval", s.cfg.QuietInterval.String(),
"quiet_hours", fmt.Sprintf("%02d:00-%02d:00", s.cfg.QuietStartHour, s.cfg.QuietEndHour), "quiet_hours", fmt.Sprintf("%02d:00-%02d:00", s.cfg.QuietStartHour, s.cfg.QuietEndHour),
"recall_threshold", s.cfg.RecallThreshold, "recall_threshold", s.cfg.RecallThreshold,
"scan_limit", s.cfg.ScanLimit, "scan_limit", s.cfg.ScanLimit,
@@ -232,7 +239,14 @@ func (s *TidyService) ManualRun(ctx context.Context) (TidyStats, error) {
} }
// loop 是心跳 goroutine:先做**启动补跑判定**(持久事实 + 墙上时钟),再进入 // loop 是心跳 goroutine:先做**启动补跑判定**(持久事实 + 墙上时钟),再进入
// 「等到下一个低峰起点 → 在窗口内时跑一轮 → 重算」的循环。ctx 被取消时退出。 // 「等到下一次到点 → 跑一轮 → 按**当时时段**重算下一次」的循环。ctx 被取消时退出。
//
// 🔴 2026-10-07(动态频率)**去掉了「安静窗外的唤醒一律跳过」这一档**:安静时段从
// 「整理唯一可跑的时段」变成「整理降频的时段」(design/12 §N A1 给后台思考心跳定下
// 的同一口径),因为主人的频率口径要求白天也跑、且白天跑得比夜里勤(「白天间隔短
// 一点,晚上到了静默时期稍微长一点」)。窗口与补跑**都没有消失**:窗口还决定档位
// (intervalAt)与两条补跑判据(dueNow 的 1/3),「到点即跑 + catch-up」
// (design/23 §4.0)原样保留 —— 每次唤醒仍然要过 dueNow 才跑,不是「醒了就扫」。
func (s *TidyService) loop(ctx context.Context) { func (s *TidyService) loop(ctx context.Context) {
defer s.wg.Done() defer s.wg.Done()
s.catchUp(ctx) s.catchUp(ctx)
@@ -243,9 +257,11 @@ func (s *TidyService) loop(ctx context.Context) {
if ctx.Err() != nil { if ctx.Err() != nil {
return return
} }
if !s.cfg.inQuietHours(s.now()) { // 到点即跑:间隔用的是**此刻**的时段(nextDelay 里现算的),因此跨过
// 低峰窗口外不跑:整理不是随时可做的动作(design/23 §4/§9)。 // 安静时段边界后,下一轮自动换档。判据再次复核是因为等待期间可能有别的
s.log.Debug("memory tidy skipped: outside quiet hours", // 一轮跑过(手动触发):那时 lastRun 已经前移,这一拍就不该再跑。
if due, reason := s.dueNow(); !due {
s.log.Debug("memory tidy heartbeat skipped: not due", "reason", reason,
"now_hour", s.now().Hour()) "now_hour", s.now().Hour())
continue continue
} }
@@ -273,12 +289,16 @@ func (s *TidyService) catchUp(ctx context.Context) {
// memory_tidy_runs 载入)与墙上时钟。满足任一条即应立刻跑: // memory_tidy_runs 载入)与墙上时钟。满足任一条即应立刻跑:
// //
// 1. 从未跑过 **且** 已在安静窗内 —— 「这个窗口还没跑过」的最简情形(全新库); // 1. 从未跑过 **且** 已在安静窗内 —— 「这个窗口还没跑过」的最简情形(全新库);
// 2. 距上次 run 已 ≥ interval —— 周期到点,窗口不再是必要条件(补跑); // 2. 距上次 run 已 ≥ **当时时段**的周期 —— 周期到点,窗口不再是必要条件(补跑);
// 3. 已在安静窗内 **且** 窗口起点晚于上次 run —— 这个窗口还没跑过。 // 3. 已在安静窗内 **且** 窗口起点晚于上次 run —— 这个窗口还没跑过。
// //
// 三条都**不**看「进程活了多久」,因此重启多少次都不会饿死整理。反过来,窗口 // 三条都**不**看「进程活了多久」,因此重启多少次都不会饿死整理。反过来,窗口
// 内跑过一次之后再重启,条件 2/3 都不成立(lastRun 在窗口起点之后),因此也 // 内跑过一次之后再重启,条件 2/3 都不成立(lastRun 在窗口起点之后),因此也
// 不会每次重启都重跑一轮扫描。 // 不会每次重启都重跑一轮扫描。
//
// 判据 2 用 intervalAt(now) 而不是某一个固定周期:周期现在是**分档**的(安静时段
// quietIntervalSec / 其余 intervalSec),用哪一档取决于**现在几点**——这与
// nextDelay 用同一个函数,因此「下一拍的时间」与「到没到点」不可能各说各话。
func (s *TidyService) dueNow() (bool, string) { func (s *TidyService) dueNow() (bool, string) {
now := s.now() now := s.now()
s.mu.Lock() s.mu.Lock()
@@ -291,7 +311,7 @@ func (s *TidyService) dueNow() (bool, string) {
} }
return false, "" return false, ""
} }
if now.Sub(last) >= s.cfg.Interval { if now.Sub(last) >= s.cfg.intervalAt(now) {
return true, "interval_elapsed" return true, "interval_elapsed"
} }
if s.cfg.inQuietHours(now) { if s.cfg.inQuietHours(now) {
@@ -304,22 +324,33 @@ func (s *TidyService) dueNow() (bool, string) {
// nextDelay 返回距下一次整理开跑的等待时长。 // nextDelay 返回距下一次整理开跑的等待时长。
// //
// 口径:下一次 = 下一个低峰窗口起点(窗口关闭时 = 现在 + 周期),并且不早于 // 口径(2026-10-07 动态频率修订):
// 「上一次运行 + 周期」—— 让配置的周期成为真实下限,而不是让安静时段每天 //
// 一次把「每周一次」冲掉。等待恒 ≥ minTidyTickDelay,避免配置异常时空转。 // - **有历史**:下一次 = 「上一次 run + **当时时段**的周期」(intervalAt);
// - **无历史**:锚到下一个安静时段起点(design/23 §4:首次整理落在低峰),窗口
// 被关闭时退化为「现在 + 当时间隔」——与本次改动之前逐字一致;
// - 等待恒 ≥ minTidyTickDelay,避免配置异常(周期 ≤ 1 分钟)时空转。
//
// 🔴 为什么在这里现算,而不是启动时算一次:
// 周期是**分档**的(非安静时段 = intervalSec,安静时段 = quietIntervalSec),而
// 每一轮跑完之后「现在落在哪一档」可能已经变了(例:06:50 跑完那轮落在安静档 ⇒
// 下一拍被推到 10:50;而 10:50 之后应当换回白天的档)。把 intervalAt(now) 放在
// **每一次「等」之前**求值,跨过时段边界后的第一拍就用新档,不必等重启。
// 把它挪回启动时算一次 ⇒ 白天/晚上的切换不会生效(本切片有测试钉住这条)。
func (s *TidyService) nextDelay() time.Duration { func (s *TidyService) nextDelay() time.Duration {
now := s.now() now := s.now()
next := s.cfg.nextQuietStart(now)
if s.cfg.quietWindowDisabled() {
next = now.Add(s.cfg.Interval)
}
s.mu.Lock() s.mu.Lock()
last := s.lastRun last := s.lastRun
s.mu.Unlock() s.mu.Unlock()
if !last.IsZero() {
if floor := last.Add(s.cfg.Interval); next.Before(floor) { var next time.Time
next = floor if last.IsZero() {
next = s.cfg.nextQuietStart(now)
if s.cfg.quietWindowDisabled() {
next = now.Add(s.cfg.intervalAt(now))
} }
} else {
next = last.Add(s.cfg.intervalAt(now))
} }
delay := next.Sub(now) delay := next.Sub(now)
if delay < minTidyTickDelay { if delay < minTidyTickDelay {
+85
View File
@@ -0,0 +1,85 @@
package memory
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件是「启动播种」的**值来源**:把 `$DATA_DIR/config/memory-tidy.json` 十键的
// **当前生效值**拍成一份完整快照,交给 runtimeconfig.SeedMemoryTidy 落盘。
//
// 它回应主人 2026-10-07 的两条口径:
//
// - 「**`.env` 只做兜底用** —— 后端**自动创建**独立的面板配置文件之后就走面板
// 配置文件,这些键优先从面板配置上取」⇒ 播种的时机与「此后以面板为准」;
// - 「后端**自动创建**独立的面板配置文件**要做**」⇒ 这份快照就是「自动创建」的
// 内容,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。
//
// 为什么放在 memory 而不是 runtimeconfig:runtimeconfig 只管**文件形状与读写**,
// 「缺位/坏值怎么回落、默认值是多少」属于 TidyConfig / ReviewConfig 的归一化语义
// (与 normalizeTidyConfig / normalizeReviewConfig 同一层)。
//
// 为什么快照取**归一化之后**的值:主人要的是「播种那一刻行为零变化」——把当时真正
// 生效的值写成文件,读回来叠上去之后(面板这一档等于生效值本身)结果逐字相同。
// 若快照取的是「未归一化的中间值」(比如四键还是 0),写下去的就是一份**比现状更空**
// 的配置,只是侥幸等价;取归一化后的值,等价性才是结构性的(见
// TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig)。
//
// 为什么只有一处实现:十键的默认值在 memory 侧各只有一处定义
// (tidy_config.go / preinsert_config.go 的 default* 常量),快照直接复用那两个
// normalize,**不另写一份默认值** —— 否则播种写下去的就是第二份「差不多」的默认值,
// 会与运行期悄悄分叉。
//
// 十键与它们的生效来源:
//
// 键 生效来源(无面板文件时) 快照值
// autoApply DisableAutoApply 的反向(代码默认开) true
// autoApplyPerRound defaultTidyAutoApplyPerRound 50
// ingestGate DisableIngestGate 的反向(默认开) true
// t1Judge DisableT1 的反向(默认开) true
// t1PerWindow defaultT1PerWindow 120
// t1WindowSec defaultT1Window 3600
// intervalSec TidyConfig.IntervalSec(env 或 86400) 归一化值
// quietIntervalSec 缺省 = intervalSec 归一化值
// quietStartHour TidyConfig.QuietStartHour(env 或 23) 归一化值
// quietEndHour TidyConfig.QuietEndHour(env 或 7) 归一化值
// TidyTuningSnapshot 拍下十键的**当前生效值**,返回一份可直接落盘的面板快照。
//
// 入参是**已经叠过面板层的**两个配置(组合根的次序:TidyConfigFromConfig →
// ApplyTidyScheduleTuning → 本函数;审查侧同理),本函数只做最后一步归一化并翻成
// 文件形状 —— 它不读盘、不读 env、不认识面板文件:
//
// - tidy 侧四键(intervalSec / quietIntervalSec / quietStartHour / quietEndHour)
// 与自动档两键(autoApply / autoApplyPerRound);
// - review 侧四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec):
// 这四项在 `.env` 里**没有出处**(`.env` 已冻结),只由面板文件与代码默认决定,
// 而播种只发生在**面板文件不存在**时 —— 那正是「零值 + normalizeReviewConfig」
// 这一条路径,与运行期(wireMemoryReview 的三个 helper)逐字一致。
//
// 两个开关是**反向**字段(DisableX),快照要把它们翻回正向的 JSON 键:文件里的
// `"autoApply": true` 才是「没配时到底开不开」的如实陈述。
func TidyTuningSnapshot(tidy TidyConfig, review ReviewConfig) runtimeconfig.MemoryTidyTuning {
tidy = normalizeTidyConfig(tidy)
review = normalizeReviewConfig(review)
autoApply := !tidy.DisableAutoApply
ingestGate := !review.DisableIngestGate
t1Judge := !review.DisableT1
intervalSec := tidy.IntervalSec
quietIntervalSec := tidy.QuietIntervalSec
quietStartHour := tidy.QuietStartHour
quietEndHour := tidy.QuietEndHour
return runtimeconfig.MemoryTidyTuning{
AutoApply: &autoApply,
AutoApplyPerRound: tidy.AutoApplyPerRound,
IngestGate: &ingestGate,
T1Judge: &t1Judge,
T1PerWindow: review.T1PerWindow,
T1WindowSec: int(review.T1Window.Seconds()),
IntervalSec: &intervalSec,
QuietIntervalSec: &quietIntervalSec,
QuietStartHour: &quietStartHour,
QuietEndHour: &quietEndHour,
}
}
@@ -0,0 +1,196 @@
package memory
import (
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件锁定**启动播种的值来源**(tidy_snapshot.go;主人 2026-10-07:「`.env` 只做
// 兜底用 —— 后端**自动创建**独立的面板配置文件**要做**」):
//
// ① 快照十键齐全、值可用(否则 runtimeconfig.SeedMemoryTidy 会拒绝落盘);
// ② 值取「**当时解析出来的生效值**」—— `.env` 给了就用 `.env`,没给就用代码默认
// (默认值来自 normalizeTidyConfig / normalizeReviewConfig,不另写一份);
// ③ 最关键的一条:把快照当成面板值叠回去,**归一化结果与「没有面板文件」逐字相同**
// —— 这就是「播种那一刻行为零变化,之后改文件才生效」。
// snapBoolPtr / snapIntPtr 造「快照里显式写了这个键」的指针。
// (不复用 tidy_tuning_test.go 的 intPtr:那是给**面板**用例造的,两者语义不同,
// 分开命名便于读。)
func snapBoolPtr(v bool) *bool { return &v }
func snapIntPtr(v int) *int { return &v }
// TestTidyTuningSnapshot_CodeDefaults 钉住「什么都没配」时的十键 = 归一化的代码默认值。
// 期望值直接取自默认值常量(本包唯一那处定义),因此这条用例证明的是**接线**:
// 快照确实走了 normalizeTidyConfig / normalizeReviewConfig,而不是另写一份默认值。
func TestTidyTuningSnapshot_CodeDefaults(t *testing.T) {
t.Parallel()
snap := TidyTuningSnapshot(TidyConfig{}, ReviewConfig{})
if snap.AutoApply == nil || !*snap.AutoApply {
t.Fatalf("autoApply = %v, want 显式的 true(代码默认开)", snap.AutoApply)
}
if snap.IngestGate == nil || !*snap.IngestGate {
t.Fatalf("ingestGate = %v, want 显式的 true(代码默认开)", snap.IngestGate)
}
if snap.T1Judge == nil || !*snap.T1Judge {
t.Fatalf("t1Judge = %v, want 显式的 true(代码默认开)", snap.T1Judge)
}
if snap.AutoApplyPerRound != defaultTidyAutoApplyPerRound {
t.Fatalf("autoApplyPerRound = %d, want %d", snap.AutoApplyPerRound, defaultTidyAutoApplyPerRound)
}
if snap.T1PerWindow != defaultT1PerWindow || snap.T1WindowSec != int(defaultT1Window.Seconds()) {
t.Fatalf("T1 限速 = %d/%d, want %d/%d", snap.T1PerWindow, snap.T1WindowSec,
defaultT1PerWindow, int(defaultT1Window.Seconds()))
}
if snap.IntervalSec == nil || *snap.IntervalSec != defaultTidyIntervalSec {
t.Fatalf("intervalSec = %v, want %d", snap.IntervalSec, defaultTidyIntervalSec)
}
// quietIntervalSec 缺省 = intervalSec(normalizeTidyConfig 的既有口径:不因时段换档)。
if snap.QuietIntervalSec == nil || *snap.QuietIntervalSec != defaultTidyIntervalSec {
t.Fatalf("quietIntervalSec = %v, want = intervalSec (%d)", snap.QuietIntervalSec, defaultTidyIntervalSec)
}
if snap.QuietStartHour == nil || *snap.QuietStartHour != defaultTidyQuietStartHour ||
snap.QuietEndHour == nil || *snap.QuietEndHour != defaultTidyQuietEndHour {
t.Fatalf("安静时段 = %v-%v, want %d-%d", snap.QuietStartHour, snap.QuietEndHour,
defaultTidyQuietStartHour, defaultTidyQuietEndHour)
}
}
// TestTidyTuningSnapshot_TakesEnvLayer 钉住「`.env` 给的就用 `.env`」:四键的 env 档
// (MEMORY_TIDY_INTERVAL_SEC / MEMORY_TIDY_QUIET_*_HOUR)必须原样进快照,否则播种写
// 下去的就是代码默认值,等于把主人配的 `.env` 静默抹平。
func TestTidyTuningSnapshot_TakesEnvLayer(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 22, QuietEndHour: 6}
snap := TidyTuningSnapshot(TidyConfigFromConfig(env), ReviewConfig{})
if snap.IntervalSec == nil || *snap.IntervalSec != 7200 {
t.Fatalf("intervalSec = %v, want 7200(取 `.env`)", snap.IntervalSec)
}
// env 只给了 intervalSec,quietIntervalSec 没有 env 键 ⇒ 缺省 = intervalSec。
if snap.QuietIntervalSec == nil || *snap.QuietIntervalSec != 7200 {
t.Fatalf("quietIntervalSec = %v, want 7200(缺省 = intervalSec)", snap.QuietIntervalSec)
}
if snap.QuietStartHour == nil || *snap.QuietStartHour != 22 ||
snap.QuietEndHour == nil || *snap.QuietEndHour != 6 {
t.Fatalf("安静时段 = %v-%v, want 22-6(取 `.env`)", snap.QuietStartHour, snap.QuietEndHour)
}
}
// TestTidyTuningSnapshot_KeepsExplicitZeroHour 钉住钟点的零值口径:**0 是合法钟点**
// (0-7 的凌晨窗口)。若快照把「0 起」当成「没配」而写成 23,播种就篡改了主人的窗口。
//
// 注意「两端都是 0」是**另一回事**:那是 normalize 认的「都没配」(回落到 23-07),
// 这与显式写 0-5 的凌晨窗口必须可区分。
func TestTidyTuningSnapshot_KeepsExplicitZeroHour(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{QuietStartHour: 0, QuietEndHour: 5}
snap := TidyTuningSnapshot(TidyConfigFromConfig(env), ReviewConfig{})
if snap.QuietStartHour == nil || *snap.QuietStartHour != 0 || *snap.QuietEndHour != 5 {
t.Fatalf("安静时段 = %v-%v, want 0-5(显式 0 是合法钟点)", snap.QuietStartHour, snap.QuietEndHour)
}
// 两端都是 0 = 「都没配」⇒ 归一成代码默认 23-07(既有口径,快照如实转述)。
both := TidyTuningSnapshot(TidyConfigFromConfig(config.MemoryTidyConfig{}), ReviewConfig{})
if *both.QuietStartHour != defaultTidyQuietStartHour || *both.QuietEndHour != defaultTidyQuietEndHour {
t.Fatalf("两端 0 = 都没配 ⇒ 应归一成 %d-%d,得到 %v-%v",
defaultTidyQuietStartHour, defaultTidyQuietEndHour, *both.QuietStartHour, *both.QuietEndHour)
}
}
// TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig 是**播种的全部意义**所在:
// 把快照当成面板值叠回去之后,整理与审查两侧归一化出来的配置必须与「面板文件不存在」
// 逐字相同 —— 于是播种那一刻行为零变化,之后改文件才生效。
//
// 两侧各按**运行期真实的叠加路径**走:
// - 整理侧 = 组合根那三步(TidyConfigFromConfig → ApplyTidyScheduleTuning →
// normalizeTidyConfig,即 resolveTidySchedule);
// - 审查侧 = wireMemoryReview 的写法(开关取 IngestGateOff/T1JudgeOff,数值取
// t1PerWindow/t1WindowSec,再交给 normalizeReviewConfig)。
func TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig(t *testing.T) {
t.Parallel()
envs := []struct {
name string
env config.MemoryTidyConfig
}{
{"`.env` 什么都没配", config.MemoryTidyConfig{}},
{"`.env` 配了频率与窗口", config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 1, QuietEndHour: 5}},
{"`.env` 的窗口从 0 点起(凌晨档)", config.MemoryTidyConfig{IntervalSec: 10800, QuietStartHour: 0, QuietEndHour: 6}},
{"`.env` 把窗口关掉(两端相等且非 0)", config.MemoryTidyConfig{IntervalSec: 3600, QuietStartHour: 5, QuietEndHour: 5}},
{"`.env` 只配了周期", config.MemoryTidyConfig{IntervalSec: 1800}},
}
for _, tc := range envs {
log, _ := captureTidyLog(t)
// ① 没播种前:面板缺失 ⇒ 零值 tuning(= 当前生产里 data/config 下没这个文件的形态)。
before := resolveTidySchedule(tc.env, runtimeconfig.MemoryTidyTuning{}, log)
// ② 快照(这就是要写进面板文件的内容)。
snap := TidyTuningSnapshot(TidyConfigFromConfig(tc.env), ReviewConfig{})
// ③ 播种之后:面板文件就是那份快照,逐键叠回来。
after := resolveTidySchedule(tc.env, snap, log)
if before != after {
t.Fatalf("%s: 播种改变了生效配置:\n before=%+v\n after =%+v", tc.name, before, after)
}
}
// 审查侧同理(闸/T1 四键在 `.env` 里没有出处,只由面板与代码默认决定)。
reviewBefore := normalizeReviewConfig(ReviewConfig{})
snap := TidyTuningSnapshot(TidyConfig{}, ReviewConfig{})
reviewAfter := normalizeReviewConfig(ReviewConfig{
DisableIngestGate: snap.IngestGateOff(),
DisableT1: snap.T1JudgeOff(),
T1PerWindow: snap.T1PerWindow,
T1Window: time.Duration(snap.T1WindowSec) * time.Second,
})
if reviewBefore != reviewAfter {
t.Fatalf("播种改变了审查的生效配置:\n before=%+v\n after =%+v", reviewBefore, reviewAfter)
}
}
// TestTidyTuningSnapshot_IsCompleteAndPositive 钉住快照契约:十键非 nil、数值键为正、
// 钟点在 0..23。runtimeconfig.SeedMemoryTidy 会按这条契约拒收残缺种子,因此这里先
// 在**产生方**把它钉住,免得「播种写不出去」要到启动日志里才发现。
func TestTidyTuningSnapshot_IsCompleteAndPositive(t *testing.T) {
t.Parallel()
snap := TidyTuningSnapshot(TidyConfigFromConfig(config.MemoryTidyConfig{}), ReviewConfig{})
pointers := map[string]*int{
"intervalSec": snap.IntervalSec,
"quietIntervalSec": snap.QuietIntervalSec,
"quietStartHour": snap.QuietStartHour,
"quietEndHour": snap.QuietEndHour,
}
for key, p := range pointers {
if p == nil {
t.Fatalf("%s 是 nil(缺键的种子会被拒收)", key)
}
}
if snap.AutoApply == nil || snap.IngestGate == nil || snap.T1Judge == nil {
t.Fatalf("开关指针不得为 nil:%+v", snap)
}
for key, v := range map[string]int{
"autoApplyPerRound": snap.AutoApplyPerRound,
"t1PerWindow": snap.T1PerWindow,
"t1WindowSec": snap.T1WindowSec,
"intervalSec": *snap.IntervalSec,
"quietIntervalSec": *snap.QuietIntervalSec,
} {
if v <= 0 {
t.Fatalf("%s = %d, want > 0(非正周期会被 runtimeconfig 拒收)", key, v)
}
}
for key, v := range map[string]int{"quietStartHour": *snap.QuietStartHour, "quietEndHour": *snap.QuietEndHour} {
if v < 0 || v > 23 {
t.Fatalf("%s = %d, want 0..23", key, v)
}
}
}
+87
View File
@@ -0,0 +1,87 @@
package memory
import (
"log/slog"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// 本文件是**整理频率**的解析点:把 `$DATA_DIR/config/memory-tidy.json` 的四个调度
// 旋钮叠到 env 侧的 TidyConfig 上,实现主人 2026-10-07 的两条口径:
//
// - 「**`.env` 只做兜底用** —— 后端自动创建独立的面板配置文件之后就走面板配置
// 文件,这些键优先从面板配置上取」⇒ 逐键 **面板 > `.env` > 代码默认**;
// - 「**频率必须调**,可以调到 1 小时或者三小时,**或者动态分配**」⇒ 周期分两档
// (非安静时段的 intervalSec / 安静时段的 quietIntervalSec,见 intervalAt)。
//
// 为什么解析放在 memory 而不是 runtimeconfig:runtimeconfig 只负责**文件形状与
// 读取**(它的头注释把这条边界写死了),而「缺位/坏值怎么回落」属于 TidyConfig 的
// 归一化语义 —— 与 normalizeTidyConfig 同一层,因此两者可以一起被单测覆盖。
//
// 为什么不是「读盘时逐键容错」:整份文件解不开(含某键类型写错)时**四个键一起**
// 回落 `.env` 并记 WARN,这是本文件家族既有的粒度(见
// runtimeconfig.LoadMemoryTidyTuning 与它的用例③);本文件处理的是**类型正确但值
// 不可用**(非正数)那一档。
// ApplyTidyScheduleTuning 把面板调度旋钮叠到 env 侧的 TidyConfig 上,返回叠加后的
// 配置(**未归一化**:调用方随后交给 NewTidier / normalizeTidyConfig 填代码默认值,
// 与组合根的既有顺序一致)。
//
// 逐键独立,三档优先级在四个键上各自成立:
//
// 键 面板(memory-tidy.json) 下一档(.env / 派生) 代码默认
// intervalSec intervalSec MEMORY_TIDY_INTERVAL_SEC 86400
// quietIntervalSec quietIntervalSec 派生:= intervalSec 同左
// quietStartHour quietStartHour MEMORY_TIDY_QUIET_START_HOUR 23
// quietEndHour quietEndHour MEMORY_TIDY_QUIET_END_HOUR 7
//
// 「面板缺位 ⇒ 用 `.env`」这一步在实现上是**不覆盖**:cfg 进函数时已经带着 env 的
// 值(TidyConfigFromConfig),面板没写就原样留着;「`.env` 也缺位 ⇒ 代码默认」由
// 随后的 normalizeTidyConfig 收尾(env 的 default 标签在 config.Load 那一层已经
// 兜过一道,两道都缺时落到 defaultTidyIntervalSec / 23-07)。
//
// 坏值(非正数的秒数)**绝不写进 cfg**:回落下一档 + 记一条 WARN。一个 ≤0 的周期会
// 让心跳退化成空转,因此它不是「没配」而是坏配置 —— 但也不许因此让启动失败。
func ApplyTidyScheduleTuning(cfg TidyConfig, panel runtimeconfig.MemoryTidyTuning, log *slog.Logger) TidyConfig {
log = logger.WithSource(baseTidyLog(log), "memory/tidy")
if sec := panelPositiveSec(panel.IntervalSec, "intervalSec", log); sec > 0 {
cfg.IntervalSec = sec
// Interval 与 IntervalSec 同时归零:两者是「同一旋钮的两种写法」
// (设置了 Interval 时忽略 IntervalSec),只改一个会让旧值报复性生效。
cfg.Interval = 0
}
if sec := panelPositiveSec(panel.QuietIntervalSec, "quietIntervalSec", log); sec > 0 {
cfg.QuietIntervalSec = sec
cfg.QuietInterval = 0
}
// 钟点用「指针是否有值」而不是符号判据:**0 是合法钟点**(0-7 的凌晨窗口、
// 23-0 到午夜的窗口),因此显式写的 0 必须原样生效,缺位才回落 env。
if panel.QuietStartHour != nil {
cfg.QuietStartHour = *panel.QuietStartHour
}
if panel.QuietEndHour != nil {
cfg.QuietEndHour = *panel.QuietEndHour
}
return cfg
}
// panelPositiveSec 解析一个**正数秒数**旋钮,返回 0 表示「不覆盖」(调用方原样留着
// env 值)。三种情形分得很清,因为它们该有不同的观测形状:
//
// - `nil`(键没写)⇒ 0,**静默**:没配不是错,正是「`.env` 兜底」这一档;
// - 非正数(写了 0 或负数)⇒ 0 + **WARN**:它会被读成「一直跑」而不是「不跑」,
// 属于坏配置,必须留下痕迹(否则主人以为配了 0 就等于关了,实际相反);
// - 正数 ⇒ 原值。
func panelPositiveSec(v *int, key string, log *slog.Logger) int {
if v == nil {
return 0
}
if *v <= 0 {
log.Warn("memory-tidy.json 的调度旋钮值不可用,回落 .env(非正周期会让心跳空转)",
"key", key, "value", *v)
return 0
}
return *v
}
+213
View File
@@ -0,0 +1,213 @@
package memory
import (
"bytes"
"log/slog"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件锁定**整理频率**的解析链(主人 2026-10-07 两条口径):
//
// ①「`.env` 只做兜底用 …… 这些键优先从面板配置上取」⇒ 逐键 **面板 > `.env` > 代码默认**;
// ②「频率必须调 …… 或者动态分配」⇒ 周期分两档(安静/非安静,见 tidy_config.go 的
// intervalAt),quietIntervalSec 缺省 = intervalSec。
//
// 解析点只有一处:ApplyTidyScheduleTuning。用例走的是组合根的次序
// (TidyConfigFromConfig → ApplyTidyScheduleTuning → normalizeTidyConfig),
// 因此「环境变量缺位也缺位时落到代码默认」这一档也是真跑出来的,不是声称。
// resolveTidySchedule 按组合根的次序解析一次调度旋钮。
func resolveTidySchedule(env config.MemoryTidyConfig, panel runtimeconfig.MemoryTidyTuning, log *slog.Logger) TidyConfig {
return normalizeTidyConfig(ApplyTidyScheduleTuning(TidyConfigFromConfig(env), panel, log))
}
// captureTidyLog 收集一次解析期间的日志,供「坏值必须留痕」的断言使用。
func captureTidyLog(t *testing.T) (*slog.Logger, *bytes.Buffer) {
t.Helper()
var buf bytes.Buffer
return slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})), &buf
}
// intPtr 造一个「面板里显式写了这个键」的指针。
func intPtr(v int) *int { return &v }
// TestApplyTidyScheduleTuning_PriorityChain 是**优先级链**的三档用例:面板有值 ⇒
// 用面板;面板缺位 ⇒ 用 `.env`;两档都缺位 ⇒ 用代码默认。逐键独立。
func TestApplyTidyScheduleTuning_PriorityChain(t *testing.T) {
t.Parallel()
// env 层:与面板值**故意对立**,这样「谁生效」是能一眼看出来的。
env := config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 23, QuietEndHour: 7}
panel := runtimeconfig.MemoryTidyTuning{
IntervalSec: intPtr(3600),
QuietIntervalSec: intPtr(10800),
QuietStartHour: intPtr(1),
QuietEndHour: intPtr(5),
}
t.Run("面板有值 ⇒ 四个键都用面板值", func(t *testing.T) {
log, buf := captureTidyLog(t)
got := resolveTidySchedule(env, panel, log)
if got.IntervalSec != 3600 || got.Interval != time.Hour {
t.Fatalf("intervalSec = %d / %s, want 3600 / 1h", got.IntervalSec, got.Interval)
}
if got.QuietIntervalSec != 10800 || got.QuietInterval != 3*time.Hour {
t.Fatalf("quietIntervalSec = %d / %s, want 10800 / 3h", got.QuietIntervalSec, got.QuietInterval)
}
if got.QuietStartHour != 1 || got.QuietEndHour != 5 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 01:00-05:00", got.QuietStartHour, got.QuietEndHour)
}
if buf.Len() != 0 {
t.Fatalf("正常配置不该有任何 WARN:%s", buf.String())
}
})
t.Run("面板缺位 ⇒ 用 .env", func(t *testing.T) {
log, buf := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{}, log)
if got.IntervalSec != 7200 || got.Interval != 2*time.Hour {
t.Fatalf("intervalSec = %d / %s, want 7200 / 2h(env 兜底)", got.IntervalSec, got.Interval)
}
if got.QuietStartHour != 23 || got.QuietEndHour != 7 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 23:00-07:00(env 兜底)",
got.QuietStartHour, got.QuietEndHour)
}
// quietIntervalSec 没有 env 键 ⇒ 缺省等于 intervalSec(不是「夜里不跑」)。
if got.QuietIntervalSec != 7200 || got.QuietInterval != 2*time.Hour {
t.Fatalf("quietIntervalSec = %d / %s, want = intervalSec(缺省 = 不因时段换档)",
got.QuietIntervalSec, got.QuietInterval)
}
if buf.Len() != 0 {
t.Fatalf("键没写不是错,不该有 WARN:%s", buf.String())
}
})
t.Run("两档都缺位 ⇒ 用代码默认", func(t *testing.T) {
log, buf := captureTidyLog(t)
got := resolveTidySchedule(config.MemoryTidyConfig{}, runtimeconfig.MemoryTidyTuning{}, log)
if got.IntervalSec != defaultTidyIntervalSec || got.Interval != time.Duration(defaultTidyIntervalSec)*time.Second {
t.Fatalf("intervalSec = %d / %s, want %d / 24h",
got.IntervalSec, got.Interval, defaultTidyIntervalSec)
}
if got.QuietIntervalSec != defaultTidyIntervalSec {
t.Fatalf("quietIntervalSec = %d, want = intervalSec 的代码默认", got.QuietIntervalSec)
}
if got.QuietStartHour != defaultTidyQuietStartHour || got.QuietEndHour != defaultTidyQuietEndHour {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 代码默认 %02d:00-%02d:00",
got.QuietStartHour, got.QuietEndHour, defaultTidyQuietStartHour, defaultTidyQuietEndHour)
}
if buf.Len() != 0 {
t.Fatalf("没配不是错,不该有 WARN:%s", buf.String())
}
})
t.Run("面板只写一档 ⇒ 另一档仍走 env(逐键独立)", func(t *testing.T) {
log, _ := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{
QuietIntervalSec: intPtr(10800),
QuietEndHour: intPtr(5),
}, log)
if got.IntervalSec != 7200 {
t.Fatalf("intervalSec = %d, want 7200(面板没写这一项)", got.IntervalSec)
}
if got.QuietIntervalSec != 10800 {
t.Fatalf("quietIntervalSec = %d, want 10800(面板写了这一项)", got.QuietIntervalSec)
}
if got.QuietStartHour != 23 || got.QuietEndHour != 5 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 23:00-05:00(一端面板、一端 env)",
got.QuietStartHour, got.QuietEndHour)
}
})
t.Run("面板写了 intervalSec 但没写 quietIntervalSec ⇒ 缺省跟随**面板**值", func(t *testing.T) {
log, _ := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{IntervalSec: intPtr(3600)}, log)
if got.IntervalSec != 3600 || got.QuietIntervalSec != 3600 {
t.Fatalf("intervalSec = %d, quietIntervalSec = %d, want 两者都是 3600",
got.IntervalSec, got.QuietIntervalSec)
}
})
t.Run("钟点显式写 0 ⇒ 生效(0 是合法钟点)", func(t *testing.T) {
log, _ := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{QuietStartHour: intPtr(0)}, log)
if got.QuietStartHour != 0 || got.QuietEndHour != 7 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 00:00-07:00", got.QuietStartHour, got.QuietEndHour)
}
})
t.Run("两端相等且非 0 ⇒ 关闭窗口(语义未变)", func(t *testing.T) {
log, _ := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{
QuietStartHour: intPtr(5), QuietEndHour: intPtr(5),
}, log)
if !got.quietWindowDisabled() {
t.Fatalf("5/5 应当被判定为「窗口已关闭」,得到 %02d:00-%02d:00",
got.QuietStartHour, got.QuietEndHour)
}
})
}
// TestApplyTidyScheduleTuning_BadValuesFallBackWithWarn 钉住「坏配置不得导致启动
// 失败」:非正数的秒数是**坏值**(不是「没配」),一律回落下一档 + 记一条 WARN ——
// 它会被读成「一直跑」而不是「不跑」,静默回落会让主人以为配了 0 就等于关了。
func TestApplyTidyScheduleTuning_BadValuesFallBackWithWarn(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 23, QuietEndHour: 7}
cases := []struct {
name string
panel runtimeconfig.MemoryTidyTuning
key string
}{
{"intervalSec 写成 0", runtimeconfig.MemoryTidyTuning{IntervalSec: intPtr(0)}, "intervalSec"},
{"intervalSec 写成负数", runtimeconfig.MemoryTidyTuning{IntervalSec: intPtr(-60)}, "intervalSec"},
{"quietIntervalSec 写成 0", runtimeconfig.MemoryTidyTuning{QuietIntervalSec: intPtr(0)}, "quietIntervalSec"},
{"quietIntervalSec 写成负数", runtimeconfig.MemoryTidyTuning{QuietIntervalSec: intPtr(-1)}, "quietIntervalSec"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
log, buf := captureTidyLog(t)
got := resolveTidySchedule(env, tc.panel, log)
if got.IntervalSec != 7200 {
t.Fatalf("intervalSec = %d, want 回落 env 的 7200", got.IntervalSec)
}
if got.QuietIntervalSec != 7200 {
t.Fatalf("quietIntervalSec = %d, want 回落 7200", got.QuietIntervalSec)
}
logs := buf.String()
if !strings.Contains(logs, tc.key) || !strings.Contains(logs, "level=WARN") {
t.Fatalf("坏值必须留痕(含键名 %q 的 WARN),日志 = %q", tc.key, logs)
}
})
}
// 坏值**不得**让整理停摆:解析出的周期必须是正数,且归一化后一切照旧。
log, _ := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{IntervalSec: intPtr(0)}, log)
if got.Interval <= 0 || got.QuietInterval <= 0 {
t.Fatalf("坏值之后的周期必须仍为正:interval=%s quiet=%s", got.Interval, got.QuietInterval)
}
if got.IntervalSec != env.IntervalSec {
t.Fatalf("坏值应回落 env 值 %d,得到 %d", env.IntervalSec, got.IntervalSec)
}
}
// TestApplyTidyScheduleTuning_NilLoggerIsSafe:解析点可能被没有日志器的调用方
// 直接使用(单测、将来的面板校验),nil 日志器不得 panic。
func TestApplyTidyScheduleTuning_NilLoggerIsSafe(t *testing.T) {
t.Parallel()
got := ApplyTidyScheduleTuning(TidyConfig{}, runtimeconfig.MemoryTidyTuning{
IntervalSec: intPtr(0), QuietIntervalSec: intPtr(-1),
}, nil)
if got.IntervalSec != 0 {
t.Fatalf("坏值不该写进 cfg,得到 %d", got.IntervalSec)
}
}