diff --git a/cmd/cyrene/wire_memory_tidy.go b/cmd/cyrene/wire_memory_tidy.go index 970bd0b..c0dcdaf 100644 --- a/cmd/cyrene/wire_memory_tidy.go +++ b/cmd/cyrene/wire_memory_tidy.go @@ -11,17 +11,19 @@ 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(坏配置绝不能让自动档 +// 变成「无限制」,也不能让整理频率变成忙轮询)。 // // 刻意**不做**的一件事(design §4.1 F): // - 不把 sess-memorytidy 加进 flowPub 的图源白名单(默认关:整理是系统任务, @@ -46,25 +48,40 @@ func (a *app) wireMemoryTidy() error { // 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`(**只读**)。 diff --git a/cmd/cyrene/wire_memory_tidy_test.go b/cmd/cyrene/wire_memory_tidy_test.go index 01966eb..c94ac28 100644 --- a/cmd/cyrene/wire_memory_tidy_test.go +++ b/cmd/cyrene/wire_memory_tidy_test.go @@ -253,3 +253,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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 0feb203..6cb75e8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/config/runtimeconfig/memorytidy.go b/internal/config/runtimeconfig/memorytidy.go index 74c07e0..425c592 100644 --- a/internal/config/runtimeconfig/memorytidy.go +++ b/internal/config/runtimeconfig/memorytidy.go @@ -23,6 +23,23 @@ // 额度出口。额度必须能按现网节奏调(且不许为它开 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`,也不认识「零值回落 @@ -108,6 +125,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 +184,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) { diff --git a/internal/config/runtimeconfig/memorytidy_test.go b/internal/config/runtimeconfig/memorytidy_test.go index ee7a2ef..2027492 100644 --- a/internal/config/runtimeconfig/memorytidy_test.go +++ b/internal/config/runtimeconfig/memorytidy_test.go @@ -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) + } +} diff --git a/internal/runtime/memory/tidy_config.go b/internal/runtime/memory/tidy_config.go index d4407d5..f351192 100644 --- a/internal/runtime/memory/tidy_config.go +++ b/internal/runtime/memory/tidy_config.go @@ -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 整点。窗口关闭时返回零值时间,调用方据此 // 改走纯周期路径。 diff --git a/internal/runtime/memory/tidy_dryrun.go b/internal/runtime/memory/tidy_dryrun.go index 7c1f2a8..b90ea9a 100644 --- a/internal/runtime/memory/tidy_dryrun.go +++ b/internal/runtime/memory/tidy_dryrun.go @@ -8,7 +8,7 @@ import ( // 本文件是记忆整理的**试算(dry-run)**入口(契约 v0.75;切片 MT1)。 // -// 它与低峰心跳跑的是**同一条**规则路径(扫描 → 召回 → 包含判定 → 规则否决 → +// 它与心跳跑的是**同一条**规则路径(扫描 → 召回 → 包含判定 → 规则否决 → // 构造候选),只有两点刻意不同: // // 1. **零副作用**:候选不落库 —— 不写 memory_tidy_candidates、不写 diff --git a/internal/runtime/memory/tidy_frequency_test.go b/internal/runtime/memory/tidy_frequency_test.go new file mode 100644 index 0000000..48f29d0 --- /dev/null +++ b/internal/runtime/memory/tidy_frequency_test.go @@ -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]) + } +} diff --git a/internal/runtime/memory/tidy_service.go b/internal/runtime/memory/tidy_service.go index a3f7fef..5875c7f 100644 --- a/internal/runtime/memory/tidy_service.go +++ b/internal/runtime/memory/tidy_service.go @@ -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 { diff --git a/internal/runtime/memory/tidy_tuning.go b/internal/runtime/memory/tidy_tuning.go new file mode 100644 index 0000000..b498cde --- /dev/null +++ b/internal/runtime/memory/tidy_tuning.go @@ -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 +} diff --git a/internal/runtime/memory/tidy_tuning_test.go b/internal/runtime/memory/tidy_tuning_test.go new file mode 100644 index 0000000..9d6d30c --- /dev/null +++ b/internal/runtime/memory/tidy_tuning_test.go @@ -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) + } +}