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:
1 parent
6deab2e836
commit
d680390300
11 files changed
+832
-54
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user