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

+64 -9
View File
@@ -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 整点。窗口关闭时返回零值时间,调用方据此
// 改走纯周期路径。
+1 -1
View File
@@ -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])
}
}
+51 -20
View File
@@ -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 {
+87
View File
@@ -0,0 +1,87 @@
package memory
import (
"log/slog"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// 本文件是**整理频率**的解析点:把 `$DATA_DIR/config/memory-tidy.json` 的四个调度
// 旋钮叠到 env 侧的 TidyConfig 上,实现主人 2026-10-07 的两条口径:
//
// - 「**`.env` 只做兜底用** —— 后端自动创建独立的面板配置文件之后就走面板配置
// 文件,这些键优先从面板配置上取」⇒ 逐键 **面板 > `.env` > 代码默认**;
// - 「**频率必须调**,可以调到 1 小时或者三小时,**或者动态分配**」⇒ 周期分两档
// (非安静时段的 intervalSec / 安静时段的 quietIntervalSec,见 intervalAt)。
//
// 为什么解析放在 memory 而不是 runtimeconfig:runtimeconfig 只负责**文件形状与
// 读取**(它的头注释把这条边界写死了),而「缺位/坏值怎么回落」属于 TidyConfig 的
// 归一化语义 —— 与 normalizeTidyConfig 同一层,因此两者可以一起被单测覆盖。
//
// 为什么不是「读盘时逐键容错」:整份文件解不开(含某键类型写错)时**四个键一起**
// 回落 `.env` 并记 WARN,这是本文件家族既有的粒度(见
// runtimeconfig.LoadMemoryTidyTuning 与它的用例③);本文件处理的是**类型正确但值
// 不可用**(非正数)那一档。
// ApplyTidyScheduleTuning 把面板调度旋钮叠到 env 侧的 TidyConfig 上,返回叠加后的
// 配置(**未归一化**:调用方随后交给 NewTidier / normalizeTidyConfig 填代码默认值,
// 与组合根的既有顺序一致)。
//
// 逐键独立,三档优先级在四个键上各自成立:
//
// 键 面板(memory-tidy.json) 下一档(.env / 派生) 代码默认
// intervalSec intervalSec MEMORY_TIDY_INTERVAL_SEC 86400
// quietIntervalSec quietIntervalSec 派生:= intervalSec 同左
// quietStartHour quietStartHour MEMORY_TIDY_QUIET_START_HOUR 23
// quietEndHour quietEndHour MEMORY_TIDY_QUIET_END_HOUR 7
//
// 「面板缺位 ⇒ 用 `.env`」这一步在实现上是**不覆盖**:cfg 进函数时已经带着 env 的
// 值(TidyConfigFromConfig),面板没写就原样留着;「`.env` 也缺位 ⇒ 代码默认」由
// 随后的 normalizeTidyConfig 收尾(env 的 default 标签在 config.Load 那一层已经
// 兜过一道,两道都缺时落到 defaultTidyIntervalSec / 23-07)。
//
// 坏值(非正数的秒数)**绝不写进 cfg**:回落下一档 + 记一条 WARN。一个 ≤0 的周期会
// 让心跳退化成空转,因此它不是「没配」而是坏配置 —— 但也不许因此让启动失败。
func ApplyTidyScheduleTuning(cfg TidyConfig, panel runtimeconfig.MemoryTidyTuning, log *slog.Logger) TidyConfig {
log = logger.WithSource(baseTidyLog(log), "memory/tidy")
if sec := panelPositiveSec(panel.IntervalSec, "intervalSec", log); sec > 0 {
cfg.IntervalSec = sec
// Interval 与 IntervalSec 同时归零:两者是「同一旋钮的两种写法」
// (设置了 Interval 时忽略 IntervalSec),只改一个会让旧值报复性生效。
cfg.Interval = 0
}
if sec := panelPositiveSec(panel.QuietIntervalSec, "quietIntervalSec", log); sec > 0 {
cfg.QuietIntervalSec = sec
cfg.QuietInterval = 0
}
// 钟点用「指针是否有值」而不是符号判据:**0 是合法钟点**(0-7 的凌晨窗口、
// 23-0 到午夜的窗口),因此显式写的 0 必须原样生效,缺位才回落 env。
if panel.QuietStartHour != nil {
cfg.QuietStartHour = *panel.QuietStartHour
}
if panel.QuietEndHour != nil {
cfg.QuietEndHour = *panel.QuietEndHour
}
return cfg
}
// panelPositiveSec 解析一个**正数秒数**旋钮,返回 0 表示「不覆盖」(调用方原样留着
// env 值)。三种情形分得很清,因为它们该有不同的观测形状:
//
// - `nil`(键没写)⇒ 0,**静默**:没配不是错,正是「`.env` 兜底」这一档;
// - 非正数(写了 0 或负数)⇒ 0 + **WARN**:它会被读成「一直跑」而不是「不跑」,
// 属于坏配置,必须留下痕迹(否则主人以为配了 0 就等于关了,实际相反);
// - 正数 ⇒ 原值。
func panelPositiveSec(v *int, key string, log *slog.Logger) int {
if v == nil {
return 0
}
if *v <= 0 {
log.Warn("memory-tidy.json 的调度旋钮值不可用,回落 .env(非正周期会让心跳空转)",
"key", key, "value", *v)
return 0
}
return *v
}
+213
View File
@@ -0,0 +1,213 @@
package memory
import (
"bytes"
"log/slog"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件锁定**整理频率**的解析链(主人 2026-10-07 两条口径):
//
// ①「`.env` 只做兜底用 …… 这些键优先从面板配置上取」⇒ 逐键 **面板 > `.env` > 代码默认**;
// ②「频率必须调 …… 或者动态分配」⇒ 周期分两档(安静/非安静,见 tidy_config.go 的
// intervalAt),quietIntervalSec 缺省 = intervalSec。
//
// 解析点只有一处:ApplyTidyScheduleTuning。用例走的是组合根的次序
// (TidyConfigFromConfig → ApplyTidyScheduleTuning → normalizeTidyConfig),
// 因此「环境变量缺位也缺位时落到代码默认」这一档也是真跑出来的,不是声称。
// resolveTidySchedule 按组合根的次序解析一次调度旋钮。
func resolveTidySchedule(env config.MemoryTidyConfig, panel runtimeconfig.MemoryTidyTuning, log *slog.Logger) TidyConfig {
return normalizeTidyConfig(ApplyTidyScheduleTuning(TidyConfigFromConfig(env), panel, log))
}
// captureTidyLog 收集一次解析期间的日志,供「坏值必须留痕」的断言使用。
func captureTidyLog(t *testing.T) (*slog.Logger, *bytes.Buffer) {
t.Helper()
var buf bytes.Buffer
return slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})), &buf
}
// intPtr 造一个「面板里显式写了这个键」的指针。
func intPtr(v int) *int { return &v }
// TestApplyTidyScheduleTuning_PriorityChain 是**优先级链**的三档用例:面板有值 ⇒
// 用面板;面板缺位 ⇒ 用 `.env`;两档都缺位 ⇒ 用代码默认。逐键独立。
func TestApplyTidyScheduleTuning_PriorityChain(t *testing.T) {
t.Parallel()
// env 层:与面板值**故意对立**,这样「谁生效」是能一眼看出来的。
env := config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 23, QuietEndHour: 7}
panel := runtimeconfig.MemoryTidyTuning{
IntervalSec: intPtr(3600),
QuietIntervalSec: intPtr(10800),
QuietStartHour: intPtr(1),
QuietEndHour: intPtr(5),
}
t.Run("面板有值 ⇒ 四个键都用面板值", func(t *testing.T) {
log, buf := captureTidyLog(t)
got := resolveTidySchedule(env, panel, log)
if got.IntervalSec != 3600 || got.Interval != time.Hour {
t.Fatalf("intervalSec = %d / %s, want 3600 / 1h", got.IntervalSec, got.Interval)
}
if got.QuietIntervalSec != 10800 || got.QuietInterval != 3*time.Hour {
t.Fatalf("quietIntervalSec = %d / %s, want 10800 / 3h", got.QuietIntervalSec, got.QuietInterval)
}
if got.QuietStartHour != 1 || got.QuietEndHour != 5 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 01:00-05:00", got.QuietStartHour, got.QuietEndHour)
}
if buf.Len() != 0 {
t.Fatalf("正常配置不该有任何 WARN:%s", buf.String())
}
})
t.Run("面板缺位 ⇒ 用 .env", func(t *testing.T) {
log, buf := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{}, log)
if got.IntervalSec != 7200 || got.Interval != 2*time.Hour {
t.Fatalf("intervalSec = %d / %s, want 7200 / 2h(env 兜底)", got.IntervalSec, got.Interval)
}
if got.QuietStartHour != 23 || got.QuietEndHour != 7 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 23:00-07:00(env 兜底)",
got.QuietStartHour, got.QuietEndHour)
}
// quietIntervalSec 没有 env 键 ⇒ 缺省等于 intervalSec(不是「夜里不跑」)。
if got.QuietIntervalSec != 7200 || got.QuietInterval != 2*time.Hour {
t.Fatalf("quietIntervalSec = %d / %s, want = intervalSec(缺省 = 不因时段换档)",
got.QuietIntervalSec, got.QuietInterval)
}
if buf.Len() != 0 {
t.Fatalf("键没写不是错,不该有 WARN:%s", buf.String())
}
})
t.Run("两档都缺位 ⇒ 用代码默认", func(t *testing.T) {
log, buf := captureTidyLog(t)
got := resolveTidySchedule(config.MemoryTidyConfig{}, runtimeconfig.MemoryTidyTuning{}, log)
if got.IntervalSec != defaultTidyIntervalSec || got.Interval != time.Duration(defaultTidyIntervalSec)*time.Second {
t.Fatalf("intervalSec = %d / %s, want %d / 24h",
got.IntervalSec, got.Interval, defaultTidyIntervalSec)
}
if got.QuietIntervalSec != defaultTidyIntervalSec {
t.Fatalf("quietIntervalSec = %d, want = intervalSec 的代码默认", got.QuietIntervalSec)
}
if got.QuietStartHour != defaultTidyQuietStartHour || got.QuietEndHour != defaultTidyQuietEndHour {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 代码默认 %02d:00-%02d:00",
got.QuietStartHour, got.QuietEndHour, defaultTidyQuietStartHour, defaultTidyQuietEndHour)
}
if buf.Len() != 0 {
t.Fatalf("没配不是错,不该有 WARN:%s", buf.String())
}
})
t.Run("面板只写一档 ⇒ 另一档仍走 env(逐键独立)", func(t *testing.T) {
log, _ := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{
QuietIntervalSec: intPtr(10800),
QuietEndHour: intPtr(5),
}, log)
if got.IntervalSec != 7200 {
t.Fatalf("intervalSec = %d, want 7200(面板没写这一项)", got.IntervalSec)
}
if got.QuietIntervalSec != 10800 {
t.Fatalf("quietIntervalSec = %d, want 10800(面板写了这一项)", got.QuietIntervalSec)
}
if got.QuietStartHour != 23 || got.QuietEndHour != 5 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 23:00-05:00(一端面板、一端 env)",
got.QuietStartHour, got.QuietEndHour)
}
})
t.Run("面板写了 intervalSec 但没写 quietIntervalSec ⇒ 缺省跟随**面板**值", func(t *testing.T) {
log, _ := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{IntervalSec: intPtr(3600)}, log)
if got.IntervalSec != 3600 || got.QuietIntervalSec != 3600 {
t.Fatalf("intervalSec = %d, quietIntervalSec = %d, want 两者都是 3600",
got.IntervalSec, got.QuietIntervalSec)
}
})
t.Run("钟点显式写 0 ⇒ 生效(0 是合法钟点)", func(t *testing.T) {
log, _ := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{QuietStartHour: intPtr(0)}, log)
if got.QuietStartHour != 0 || got.QuietEndHour != 7 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 00:00-07:00", got.QuietStartHour, got.QuietEndHour)
}
})
t.Run("两端相等且非 0 ⇒ 关闭窗口(语义未变)", func(t *testing.T) {
log, _ := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{
QuietStartHour: intPtr(5), QuietEndHour: intPtr(5),
}, log)
if !got.quietWindowDisabled() {
t.Fatalf("5/5 应当被判定为「窗口已关闭」,得到 %02d:00-%02d:00",
got.QuietStartHour, got.QuietEndHour)
}
})
}
// TestApplyTidyScheduleTuning_BadValuesFallBackWithWarn 钉住「坏配置不得导致启动
// 失败」:非正数的秒数是**坏值**(不是「没配」),一律回落下一档 + 记一条 WARN ——
// 它会被读成「一直跑」而不是「不跑」,静默回落会让主人以为配了 0 就等于关了。
func TestApplyTidyScheduleTuning_BadValuesFallBackWithWarn(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 23, QuietEndHour: 7}
cases := []struct {
name string
panel runtimeconfig.MemoryTidyTuning
key string
}{
{"intervalSec 写成 0", runtimeconfig.MemoryTidyTuning{IntervalSec: intPtr(0)}, "intervalSec"},
{"intervalSec 写成负数", runtimeconfig.MemoryTidyTuning{IntervalSec: intPtr(-60)}, "intervalSec"},
{"quietIntervalSec 写成 0", runtimeconfig.MemoryTidyTuning{QuietIntervalSec: intPtr(0)}, "quietIntervalSec"},
{"quietIntervalSec 写成负数", runtimeconfig.MemoryTidyTuning{QuietIntervalSec: intPtr(-1)}, "quietIntervalSec"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
log, buf := captureTidyLog(t)
got := resolveTidySchedule(env, tc.panel, log)
if got.IntervalSec != 7200 {
t.Fatalf("intervalSec = %d, want 回落 env 的 7200", got.IntervalSec)
}
if got.QuietIntervalSec != 7200 {
t.Fatalf("quietIntervalSec = %d, want 回落 7200", got.QuietIntervalSec)
}
logs := buf.String()
if !strings.Contains(logs, tc.key) || !strings.Contains(logs, "level=WARN") {
t.Fatalf("坏值必须留痕(含键名 %q 的 WARN),日志 = %q", tc.key, logs)
}
})
}
// 坏值**不得**让整理停摆:解析出的周期必须是正数,且归一化后一切照旧。
log, _ := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{IntervalSec: intPtr(0)}, log)
if got.Interval <= 0 || got.QuietInterval <= 0 {
t.Fatalf("坏值之后的周期必须仍为正:interval=%s quiet=%s", got.Interval, got.QuietInterval)
}
if got.IntervalSec != env.IntervalSec {
t.Fatalf("坏值应回落 env 值 %d,得到 %d", env.IntervalSec, got.IntervalSec)
}
}
// TestApplyTidyScheduleTuning_NilLoggerIsSafe:解析点可能被没有日志器的调用方
// 直接使用(单测、将来的面板校验),nil 日志器不得 panic。
func TestApplyTidyScheduleTuning_NilLoggerIsSafe(t *testing.T) {
t.Parallel()
got := ApplyTidyScheduleTuning(TidyConfig{}, runtimeconfig.MemoryTidyTuning{
IntervalSec: intPtr(0), QuietIntervalSec: intPtr(-1),
}, nil)
if got.IntervalSec != 0 {
t.Fatalf("坏值不该写进 cfg,得到 %d", got.IntervalSec)
}
}