Compare commits
2
Commits
6deab2e836
...
23b6e3fe1e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23b6e3fe1e | ||
|
|
d680390300 |
No files matched your search
@@ -11,17 +11,22 @@ import (
|
||||
// 本文件把记忆整理(DevDocs/design/23-记忆整理与去重 §4)接进组合根。
|
||||
//
|
||||
// 四件事只在这里定:
|
||||
// - **形态**:低峰定期后台任务(memory.TidyService 的心跳),不是会话内派
|
||||
// subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止;
|
||||
// - **形态**:定期后台任务(安静时段降频)的心跳(memory.TidyService),不是
|
||||
// 会话内派 subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止;
|
||||
// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数,
|
||||
// 因此 design §4.1「绝不碰主会话上下文」是结构性成立的,而不是靠自觉;
|
||||
// - **审计**:接到网关的 AuditEvent(design §8 的 memory.tidy.*),出口在调用
|
||||
// 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 onToolAudit
|
||||
// 同一手法。未就绪时只记日志,绝不假装写过审计;
|
||||
// - **运行期调优**(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):
|
||||
// - 不把 sess-memorytidy 加进 flowPub 的图源白名单(默认关:整理是系统任务,
|
||||
@@ -29,6 +34,12 @@ import (
|
||||
func (a *app) wireMemoryTidy() error {
|
||||
cfg := memory.TidyConfigFromConfig(a.cfg.MemoryTidy)
|
||||
a.applyMemoryTidyTuning(&cfg)
|
||||
// 启动播种(主人 2026-10-07:「后端**自动创建**独立的面板配置文件**要做**」):
|
||||
// 文件不存在/空白 ⇒ 把**此刻的生效值**写成面板的一等来源,此后 `.env` 只兜底。
|
||||
// 放在 applyMemoryTidyTuning **之后**是刻意的:那一刻的 cfg 就是
|
||||
// 「面板 > `.env` > 代码默认」的结果(面板缺失时即 `.env` + 默认),播它 ⇒ 行为
|
||||
// 零变化。文件已存在(含损坏)⇒ 一个字节都不动(幂等,见 runtimeconfig.SeedMemoryTidy)。
|
||||
a.seedMemoryTidyPanel(cfg)
|
||||
svc, err := memory.NewTidyService(cfg, memory.TidyDeps{
|
||||
Store: a.memStore,
|
||||
Embedder: a.embedder,
|
||||
@@ -42,29 +53,65 @@ func (a *app) wireMemoryTidy() error {
|
||||
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 侧的
|
||||
// TidyConfig 上(见 runtimeconfig.MemoryTidyTuning 的零值口径):
|
||||
// - 文件里**没写** autoApply ⇒ 保持代码默认(开);
|
||||
// - 写 `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) {
|
||||
tuning, path, err := a.readMemoryTidyTuning()
|
||||
log := logger.WithSource(a.log, "memory/tidy")
|
||||
if err != nil {
|
||||
logger.WithSource(a.log, "memory/tidy").Warn(
|
||||
"memory tidy tuning unreadable, falling back to code defaults",
|
||||
log.Warn(
|
||||
"memory tidy tuning unreadable, falling back to the env layer (.env values kept)",
|
||||
"path", path, "error", err)
|
||||
return
|
||||
} else {
|
||||
cfg.DisableAutoApply = tuning.AutoApplyOff()
|
||||
if tuning.AutoApplyPerRound > 0 {
|
||||
cfg.AutoApplyPerRound = tuning.AutoApplyPerRound
|
||||
}
|
||||
}
|
||||
cfg.DisableAutoApply = tuning.AutoApplyOff()
|
||||
if tuning.AutoApplyPerRound > 0 {
|
||||
cfg.AutoApplyPerRound = tuning.AutoApplyPerRound
|
||||
}
|
||||
logger.WithSource(a.log, "memory/tidy").Info(
|
||||
"memory tidy tuning loaded",
|
||||
"path", path, "auto_apply", !cfg.DisableAutoApply,
|
||||
"auto_apply_per_round", cfg.AutoApplyPerRound)
|
||||
// 调度四键在两种情形下都要过一遍:成功时按面板值覆盖,失败时 tuning 是零值
|
||||
// ⇒ 原样返回(= 保持 env 值),因此不必为它再分一条路径。
|
||||
*cfg = memory.ApplyTidyScheduleTuning(*cfg, tuning, log)
|
||||
// 这里打的是**归一化之前**的值(quiet_interval_sec 可能还是 0 = 「缺省,等于
|
||||
// intervalSec」):真正生效的两档周期由 TidyService.Start 的启动日志报。
|
||||
// panel_decoded = 「文件缺失/空白/解析成功」三者之一(LoadMemoryTidyTuning 把
|
||||
// 前两者都当作「还没配过」而不是错误),为 false 即坏文件 ⇒ 四个键都回落 `.env`。
|
||||
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`(**只读**)。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,11 @@ func TestMemoryTidyIsWired(t *testing.T) {
|
||||
// 来,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。少了这一句,
|
||||
// 那个文件就成了「写了没人读」的摆设,而自动档仍然开着 —— 静默。
|
||||
"runtimeconfig.LoadMemoryTidyTuning",
|
||||
// 启动播种(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件
|
||||
// **要做**」):少了这两句,那个文件在生产里**根本不存在** ⇒「面板优先」
|
||||
// 永远是空话,生效的仍是 `.env` 兜底那一档 —— 而不会有任何用例变红。
|
||||
"a.seedMemoryTidyPanel(cfg)",
|
||||
"runtimeconfig.SeedMemoryTidy(",
|
||||
// 依赖面里**没有**任何会话/历史读数 —— 这是「绝不碰主会话上下文」
|
||||
// 的结构性保证,而不是靠自觉。
|
||||
"Audit: a.auditMemoryTidy",
|
||||
@@ -253,3 +258,70 @@ func TestApplyMemoryTidyTuning_ReadsDataDirConfig(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -246,13 +246,17 @@ type MemoryTidyConfig struct {
|
||||
// 关闭)。整理**只产建议、绝不自动改记忆**,因此默认开启是安全的;
|
||||
// 它带来的是每轮一次的记忆向量化(召回用)与一批候选行。
|
||||
Enabled bool `env:"MEMORY_TIDY_ENABLED" default:"true"`
|
||||
// IntervalSec 是整理周期(秒),默认 86400 = 每天 1 次(design/23 §13
|
||||
// 开放点 8 的建议值)。它是下限:实际在安静时段起点跑,因此间隔取
|
||||
// 「安静时段每天一次」与它之中较长者。
|
||||
// IntervalSec 是**非安静时段**的整理周期(秒),默认 86400 = 每天 1 次
|
||||
// (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"`
|
||||
// QuietStartHour / QuietEndHour 是低峰窗口(本地钟点),默认复用
|
||||
// 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"`
|
||||
QuietEndHour int `env:"MEMORY_TIDY_QUIET_END_HOUR" default:"7"`
|
||||
// RecallThreshold 是候选召回的余弦**召回门**,默认 0.80(design/23 §5.1
|
||||
|
||||
@@ -23,17 +23,37 @@
|
||||
// 额度出口。额度必须能按现网节奏调(且不许为它开 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 同源(那是同一族的手续):
|
||||
//
|
||||
// 1. 本文件只管**文件形状 + 读取**:它不认识 `TidyConfig`,也不认识「零值回落
|
||||
// 代码默认值」那条裁决 —— 那是 `memory.normalizeTidyConfig` 的事。
|
||||
// 2. **只读**:面板写路径不在本切片内,因此刻意没有 `Save*`。改动仍要
|
||||
// **人手改这个文件 + 重启**(与 `backthink.json` 同口径:启动期读一次)。
|
||||
// 2. **没有「面板编辑」的写口**:改内容仍要**人手改这个文件 + 重启**(与
|
||||
// `backthink.json` 同口径:启动期读一次)。唯一的写路径是**启动播种**
|
||||
// (memorytidy_seed.go)—— 它只在文件缺失/空白时写一次,之后一个字节都不动;
|
||||
// 面板上的编辑口是另一片,不在本文件。
|
||||
// 3. **零新 env 键**,也不进面板的配置事实卡(那张表的键名被 `.env.example` 钉住)。
|
||||
//
|
||||
// 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**);存在但解不开
|
||||
// = 返回错误,调用方记 WARN 并回落**代码默认值**(即:坏配置绝不能把自动档的开关
|
||||
// 或上限读成「无限制」)。
|
||||
// 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**;启动时这种情形
|
||||
// 会被播种成一份「当时生效值」,见 memorytidy_seed.go);存在但解不开 = 返回错误,
|
||||
// 调用方记 WARN 并回落**代码默认值**(即:坏配置绝不能把自动档的开关或上限读成
|
||||
// 「无限制」)。
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
@@ -108,6 +128,35 @@ type MemoryTidyTuning struct {
|
||||
// `3600` 比要求主人记住 Go 的时长语法更不容易写错,也与既有
|
||||
// `$DATA_DIR/config/backthink.json` 的 `*_SEC` 口径一致。
|
||||
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 ——
|
||||
@@ -138,8 +187,12 @@ func DefaultMemoryTidyPath(dataDir string) string {
|
||||
// 文件缺失或全空白 ⇒ 零值 + nil:调用方用代码默认值(normalizeTidyConfig 会把
|
||||
// 非正的上限回落默认),因此「没配过」与「配成 0」得到同一个结果。
|
||||
//
|
||||
// 存在但解不开 ⇒ 返回错误。调用方应记一条带 source 的 WARN 并回落默认;
|
||||
// **不得覆盖**那个文件(损坏的文件可以人工救回,程序盖掉它就丢了人工写的意图)。
|
||||
// 存在但解不开 ⇒ 返回错误。调用方应记一条带 source 的 WARN 并回落**下一档**
|
||||
// (调度四键落 `.env` 值、开关类落代码默认)——注意这是**整份文件**的粒度:一个键
|
||||
// 类型写错(如 `"intervalSec": "3600"`)会让全部键一起回落,这是既有口径
|
||||
// (`TestApplyMemoryTidyTuning_ReadsDataDirConfig` 的用例③钉的就是它),而不是
|
||||
// 逐键容错。**不得覆盖**那个文件(损坏的文件可以人工救回,程序盖掉它就丢了人工
|
||||
// 写的意图)。
|
||||
//
|
||||
// 它不建目录、不读老位置:新家族没有老位置(design/13 §2.1 第 5 条)。
|
||||
func LoadMemoryTidyTuning(path string) (MemoryTidyTuning, error) {
|
||||
@@ -153,6 +206,16 @@ func LoadMemoryTidyTuning(path string) (MemoryTidyTuning, error) {
|
||||
if strings.TrimSpace(string(raw)) == "" {
|
||||
return MemoryTidyTuning{}, nil
|
||||
}
|
||||
return decodeMemoryTidy(raw, path)
|
||||
}
|
||||
|
||||
// decodeMemoryTidy 是 LoadMemoryTidyTuning 与**启动播种**(memorytidy_seed.go)共用的
|
||||
// 解码点:把「解不开」统一分类成 ErrInvalidMemoryTidy。两处对「坏文件」的判定必须
|
||||
// 出自同一行代码,否则会出现「读取说坏了、播种说没事」这种自相矛盾(播种侧要靠这个
|
||||
// 结论决定「绝不覆盖」,判错的代价是一份人工配置被盖掉)。
|
||||
//
|
||||
// 空内容由调用方先滤掉(空 = 还没配过,与「缺失」同义,不是解码失败)。
|
||||
func decodeMemoryTidy(raw []byte, path string) (MemoryTidyTuning, error) {
|
||||
var t MemoryTidyTuning
|
||||
if err := json.Unmarshal(raw, &t); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
// 使运行路径上不出现裸魔法数字(AGENTS §10),并让「设计稿默认值」
|
||||
// 只有一处定义、可被复核。
|
||||
//
|
||||
// 形态(design/23 §4 方案 A/C):整理是一件**低峰定期后台任务** —— 跑在
|
||||
// 专属隔离会话下、零上下文启动、只产候选**不写记忆**,绝不是会话内派
|
||||
// 形态(design/23 §4 方案 A/C):整理是一件**定期后台任务**(安静时段降频)——
|
||||
// 跑在专属隔离会话下、零上下文启动、只产候选**不写记忆**,绝不是会话内派
|
||||
// subagent。
|
||||
|
||||
const (
|
||||
@@ -37,8 +37,11 @@ const (
|
||||
defaultTidyIntervalSec = 86400
|
||||
|
||||
// defaultTidyQuietStartHour / defaultTidyQuietEndHour 复用 backthink 的
|
||||
// 安静时段语义(默认 23:00-07:00,窗口跨午夜;两端相等且非 0 则关闭窗口)
|
||||
// —— 整理只在低峰跑(design/23 §4)。
|
||||
// 安静时段语义(默认 23:00-07:00,窗口跨午夜;两端相等且非 0 则关闭窗口)。
|
||||
//
|
||||
// 🔴 2026-10-07 起窗口不再等于「唯一可跑时段」:它是**降频档位**的判据
|
||||
// (安静时段用 defaultTidyIntervalSec 之外的另一档,见 intervalAt),并且
|
||||
// 仍然决定「首次/未跑过的窗口要补跑一轮」(dueNow 判据 1/3)。
|
||||
defaultTidyQuietStartHour = 23
|
||||
defaultTidyQuietEndHour = 7
|
||||
|
||||
@@ -53,7 +56,7 @@ const (
|
||||
|
||||
// maxTidyCompareItems 是每分区每轮**真正参与两两比对**的条目上限的兜底默认
|
||||
// 值(可直接从 MEMORY_TIDY_COMPARE_LIMIT 调,见 TidyConfig.CompareLimit)。
|
||||
// 两两相似度是 O(n²·dim),5000 条会到十亿级乘加,低峰任务也不该跑成那样。
|
||||
// 两两相似度是 O(n²·dim),5000 条会到十亿级乘加,后台任务也不该跑成那样。
|
||||
// 2026-10-06 由 400 抬到 2000:首轮真跑 459 条的分区被旧值截到 400,
|
||||
// 于是 compared(705) < scanned(784) —— 截断确实如实登记了,但护栏成为结
|
||||
// 果的主因本身就是缺陷。超限仍按「最新优先」截断并记一条 WARN(绝不静默),
|
||||
@@ -129,14 +132,28 @@ type TidyConfig struct {
|
||||
// Enabled 是总开关。关闭时组合根不建服务、不跑任何整理
|
||||
// (design/23 §4 的 MEMORY_TIDY_ENABLED)。
|
||||
Enabled bool
|
||||
// Interval 是整理周期。为零时由 IntervalSec(或其默认值)推导。
|
||||
// Interval 是整理周期(**非安静时段**)。为零时由 IntervalSec(或其默认值)推导。
|
||||
Interval time.Duration
|
||||
// IntervalSec 是整理周期秒数,映射自 config.MemoryTidyConfig.IntervalSec。
|
||||
// 设置了 Interval 时忽略。它是**下限**:安静时段每天只有一次,
|
||||
// 因此实际间隔取两者中较长者。
|
||||
// IntervalSec 是整理周期秒数(**非安静时段**),映射自
|
||||
// config.MemoryTidyConfig.IntervalSec(env),并可由
|
||||
// `$DATA_DIR/config/memory-tidy.json` 的 `intervalSec` 覆盖(面板优先、`.env`
|
||||
// 兜底,唯一实现点是 ApplyTidyScheduleTuning)。设置了 Interval 时忽略。
|
||||
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 以本地钟点约束低峰窗口
|
||||
// (复用 backthink 语义:窗口可跨午夜,两端相等且非 0 则关闭窗口)。
|
||||
//
|
||||
// 🔴 2026-10-07 起窗口的**语义一字未改**(inQuietHours 逐字不动),但它的
|
||||
// **作用面**收窄了一格:从「整理唯一可跑的时段」变成「整理降频的时段」
|
||||
// (见 intervalAt)。理由是主人的频率口径要求白天也跑,且白天跑得比夜里勤。
|
||||
QuietStartHour int
|
||||
QuietEndHour int
|
||||
// RecallThreshold 是候选召回的余弦门(见 defaultTidyRecallThreshold)。
|
||||
@@ -165,6 +182,10 @@ type TidyConfig struct {
|
||||
|
||||
// TidyConfigFromConfig 把 config.MemoryTidyConfig(env 侧旋钮)映射成
|
||||
// TidyConfig,env 未暴露的项保持零值,交给 normalize 填默认值。
|
||||
//
|
||||
// 它是**兜底层**:组合根随后用 ApplyTidyScheduleTuning 把
|
||||
// `$DATA_DIR/config/memory-tidy.json` 的调度旋钮叠上来(面板优先、env 兜底);
|
||||
// env 没有的项(如 quietIntervalSec)在这里保持零值,由 normalize 取默认。
|
||||
func TidyConfigFromConfig(cfg config.MemoryTidyConfig) TidyConfig {
|
||||
return TidyConfig{
|
||||
Enabled: cfg.Enabled,
|
||||
@@ -186,6 +207,14 @@ func normalizeTidyConfig(cfg TidyConfig) TidyConfig {
|
||||
if cfg.Interval <= 0 {
|
||||
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 {
|
||||
cfg.QuietStartHour = defaultTidyQuietStartHour
|
||||
cfg.QuietEndHour = defaultTidyQuietEndHour
|
||||
@@ -230,6 +259,32 @@ func (c TidyConfig) inQuietHours(at time.Time) bool {
|
||||
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)的下一个低峰窗口起点:
|
||||
// 今天/明天的 QuietStartHour 整点。窗口关闭时返回零值时间,调用方据此
|
||||
// 改走纯周期路径。
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
// 本文件是记忆整理的**试算(dry-run)**入口(契约 v0.75;切片 MT1)。
|
||||
//
|
||||
// 它与低峰心跳跑的是**同一条**规则路径(扫描 → 召回 → 包含判定 → 规则否决 →
|
||||
// 它与心跳跑的是**同一条**规则路径(扫描 → 召回 → 包含判定 → 规则否决 →
|
||||
// 构造候选),只有两点刻意不同:
|
||||
//
|
||||
// 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])
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是记忆整理的**后台任务形态**(design/23 §4 方案 A/C):低峰定期跑、
|
||||
// 独立运行、零上下文启动、只产建议、绝不推送。
|
||||
// 本文件是记忆整理的**后台任务形态**(design/23 §4 方案 A/C):定期跑(安静时段
|
||||
// 降频)、独立运行、零上下文启动、只产建议、绝不推送。
|
||||
//
|
||||
// 与主人的硬约束对齐(design/23 §4.1):
|
||||
// - 零上下文:本服务的依赖里**没有任何会话/历史读数**,输入只有记忆条目
|
||||
@@ -30,8 +30,14 @@ import (
|
||||
// 重启覆盖,于是 memory_tidy_runs 只有 1 行、77 条候选全 pending。
|
||||
// - 新口径(到点即跑 + 持久事实):启动时先读**持久事实**
|
||||
// (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",
|
||||
"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),
|
||||
"recall_threshold", s.cfg.RecallThreshold,
|
||||
"scan_limit", s.cfg.ScanLimit,
|
||||
@@ -232,7 +239,14 @@ func (s *TidyService) ManualRun(ctx context.Context) (TidyStats, error) {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
defer s.wg.Done()
|
||||
s.catchUp(ctx)
|
||||
@@ -243,9 +257,11 @@ func (s *TidyService) loop(ctx context.Context) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if !s.cfg.inQuietHours(s.now()) {
|
||||
// 低峰窗口外不跑:整理不是随时可做的动作(design/23 §4/§9)。
|
||||
s.log.Debug("memory tidy skipped: outside quiet hours",
|
||||
// 到点即跑:间隔用的是**此刻**的时段(nextDelay 里现算的),因此跨过
|
||||
// 安静时段边界后,下一轮自动换档。判据再次复核是因为等待期间可能有别的
|
||||
// 一轮跑过(手动触发):那时 lastRun 已经前移,这一拍就不该再跑。
|
||||
if due, reason := s.dueNow(); !due {
|
||||
s.log.Debug("memory tidy heartbeat skipped: not due", "reason", reason,
|
||||
"now_hour", s.now().Hour())
|
||||
continue
|
||||
}
|
||||
@@ -273,12 +289,16 @@ func (s *TidyService) catchUp(ctx context.Context) {
|
||||
// memory_tidy_runs 载入)与墙上时钟。满足任一条即应立刻跑:
|
||||
//
|
||||
// 1. 从未跑过 **且** 已在安静窗内 —— 「这个窗口还没跑过」的最简情形(全新库);
|
||||
// 2. 距上次 run 已 ≥ interval —— 周期到点,窗口不再是必要条件(补跑);
|
||||
// 2. 距上次 run 已 ≥ **当时时段**的周期 —— 周期到点,窗口不再是必要条件(补跑);
|
||||
// 3. 已在安静窗内 **且** 窗口起点晚于上次 run —— 这个窗口还没跑过。
|
||||
//
|
||||
// 三条都**不**看「进程活了多久」,因此重启多少次都不会饿死整理。反过来,窗口
|
||||
// 内跑过一次之后再重启,条件 2/3 都不成立(lastRun 在窗口起点之后),因此也
|
||||
// 不会每次重启都重跑一轮扫描。
|
||||
//
|
||||
// 判据 2 用 intervalAt(now) 而不是某一个固定周期:周期现在是**分档**的(安静时段
|
||||
// quietIntervalSec / 其余 intervalSec),用哪一档取决于**现在几点**——这与
|
||||
// nextDelay 用同一个函数,因此「下一拍的时间」与「到没到点」不可能各说各话。
|
||||
func (s *TidyService) dueNow() (bool, string) {
|
||||
now := s.now()
|
||||
s.mu.Lock()
|
||||
@@ -291,7 +311,7 @@ func (s *TidyService) dueNow() (bool, string) {
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
if now.Sub(last) >= s.cfg.Interval {
|
||||
if now.Sub(last) >= s.cfg.intervalAt(now) {
|
||||
return true, "interval_elapsed"
|
||||
}
|
||||
if s.cfg.inQuietHours(now) {
|
||||
@@ -304,22 +324,33 @@ func (s *TidyService) dueNow() (bool, string) {
|
||||
|
||||
// nextDelay 返回距下一次整理开跑的等待时长。
|
||||
//
|
||||
// 口径:下一次 = 下一个低峰窗口起点(窗口关闭时 = 现在 + 周期),并且不早于
|
||||
// 「上一次运行 + 周期」—— 让配置的周期成为真实下限,而不是让安静时段每天
|
||||
// 一次把「每周一次」冲掉。等待恒 ≥ minTidyTickDelay,避免配置异常时空转。
|
||||
// 口径(2026-10-07 动态频率修订):
|
||||
//
|
||||
// - **有历史**:下一次 = 「上一次 run + **当时时段**的周期」(intervalAt);
|
||||
// - **无历史**:锚到下一个安静时段起点(design/23 §4:首次整理落在低峰),窗口
|
||||
// 被关闭时退化为「现在 + 当时间隔」——与本次改动之前逐字一致;
|
||||
// - 等待恒 ≥ minTidyTickDelay,避免配置异常(周期 ≤ 1 分钟)时空转。
|
||||
//
|
||||
// 🔴 为什么在这里现算,而不是启动时算一次:
|
||||
// 周期是**分档**的(非安静时段 = intervalSec,安静时段 = quietIntervalSec),而
|
||||
// 每一轮跑完之后「现在落在哪一档」可能已经变了(例:06:50 跑完那轮落在安静档 ⇒
|
||||
// 下一拍被推到 10:50;而 10:50 之后应当换回白天的档)。把 intervalAt(now) 放在
|
||||
// **每一次「等」之前**求值,跨过时段边界后的第一拍就用新档,不必等重启。
|
||||
// 把它挪回启动时算一次 ⇒ 白天/晚上的切换不会生效(本切片有测试钉住这条)。
|
||||
func (s *TidyService) nextDelay() time.Duration {
|
||||
now := s.now()
|
||||
next := s.cfg.nextQuietStart(now)
|
||||
if s.cfg.quietWindowDisabled() {
|
||||
next = now.Add(s.cfg.Interval)
|
||||
}
|
||||
s.mu.Lock()
|
||||
last := s.lastRun
|
||||
s.mu.Unlock()
|
||||
if !last.IsZero() {
|
||||
if floor := last.Add(s.cfg.Interval); next.Before(floor) {
|
||||
next = floor
|
||||
|
||||
var next time.Time
|
||||
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)
|
||||
if delay < minTidyTickDelay {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user