feat(memory): 整理频率走面板配置(面板 > env > 默认)+ 分时段动态间隔

主人 2026-10-07:「.env 只做兜底…优先从面板配置上取」+「频率必须调…或者动态
分配,比如白天间隔短一点 晚上到了静默时期稍微长一点」。

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

No files matched your search

+52 -2
View File
@@ -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) {
@@ -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)
}
}