Files
Cyrene_v2/internal/runtime/memory/tidy_service.go
T
AskaEth d680390300 feat(memory): 整理频率走面板配置(面板 > env > 默认)+ 分时段动态间隔
主人 2026-10-07:「.env 只做兜底…优先从面板配置上取」+「频率必须调…或者动态
分配,比如白天间隔短一点 晚上到了静默时期稍微长一点」。

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

418 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"context"
"fmt"
"log/slog"
"sort"
"strings"
"sync"
"time"
)
// 本文件是记忆整理的**后台任务形态**(design/23 §4 方案 A/C):定期跑(安静时段
// 降频)、独立运行、零上下文启动、只产建议、绝不推送。
//
// 与主人的硬约束对齐(design/23 §4.1):
// - 零上下文:本服务的依赖里**没有任何会话/历史读数**,输入只有记忆条目
// 与本次候选数据,因此「不读会话」是结构性成立的,不靠自觉;
// - 独立运行:跑在专属隔离会话 TidySessionID(不是 history.MainSessionID),
// 因此不改主人的心情、不进主会话泳道(cmd/cyrene 的 isConversationLane
// 对它恒为 false);
// - 它不是任何角色:系统任务,不带人格提示词,也不把写入限到某个角色的域;
// - 不写会话:唯一的写入是候选表与运行表;agent 侧对 chat_messages 零写入。
//
// 心跳范式参照 internal/services/backthink(同一低峰/安静时段语义),但在
// 「启动即跑」这一点上与本包旧版**有意不同**(切片 TIDY-1 修掉的正是旧口径):
//
// - 旧口径:进程启动时只算「下一个安静时段起点」,且「上次 run」只存在进程
// 内存里。后端两天重启十几次 ⇒ 24h 周期永远走不完、安静窗起点永远被下一次
// 重启覆盖,于是 memory_tidy_runs 只有 1 行、77 条候选全 pending。
// - 新口径(到点即跑 + 持久事实):启动时先读**持久事实**
// (memory_tidy_runs 里最近一轮的开始时刻),满足任一条件就**立刻补跑一次**:
// ① 距上次 run 已 ≥ 当时间隔;② 已在安静窗内且窗口起点晚于上次 run
// (= 这个窗口还没跑过)。此后心跳按「上一次 run + **当时时段**的周期」醒。
//
// 2026-10-07(主人「频率必须调……或者动态分配,白天间隔短一点,晚上到了静默时期
// 稍微长一点」)追加**分档周期**:非安静时段用 cfg.Interval,安静时段用
// cfg.QuietInterval(唯一判据 = TidyConfig.intervalAt)。安静时段因此从「唯一可跑
// 时段」变成「降频时段」,心跳不再有「窗口外一律跳过」这一档;而「到点即跑 +
// catch-up」那三条判据一条未动。
//
// 这样「重启不饿死」是可证的:重启只影响内存,判据只依赖持久事实与墙上时钟。
// minTidyTickDelay 是心跳循环的最小等待,防止配置异常(周期/时钟跳变)导致
// 空转忙轮询。
const minTidyTickDelay = time.Minute
// TidyService 是记忆整理服务。用 NewTidyService 构建,用 Start 启动心跳、
// Stop 停止它;RunOnce / ManualRun 是「立即整理」入口(design/23 §4 的手动可触发)。
type TidyService struct {
tidier *Tidier
cfg TidyConfig
log *slog.Logger
mu sync.Mutex
running bool
cancel context.CancelFunc
lastRun time.Time
wg sync.WaitGroup
// now 是调度用的时钟(默认取整理器的注入时钟)。测试注入它以模拟
// 「启动 → 跨过安静窗起点」而不用真等一整夜。
now func() time.Time
// wait 是心跳的等待原语:返回 false 表示 ctx 已取消。生产是 time.Timer;
// 测试注入假等待(推进假时钟后立刻返回),因此补跑判据可以确定性验证。
wait func(ctx context.Context, d time.Duration) bool
// runMu 保证同一时刻只有一轮整理(design/23 §9 单写者):心跳与手动触发
// 若撞在一起,后者排队而不是并发改写候选表。
runMu sync.Mutex
// runs 是**异步试算**结果的进程内注册表(契约 v0.76 §4.12.1;见
// tidy_dryrun_async.go):不落库、不新开端口、重启即空。
runs *tidyRunRegistry
}
// NewTidyService 由 cfg 与 deps 构建整理服务。Store 为 nil 时返回 ErrNilStore。
func NewTidyService(cfg TidyConfig, deps TidyDeps) (*TidyService, error) {
tidier, err := NewTidier(cfg, deps)
if err != nil {
return nil, err
}
return &TidyService{
tidier: tidier,
cfg: tidier.Config(),
log: tidier.log,
now: tidier.now,
wait: waitTidyTimer,
runs: newTidyRunRegistry(defaultTidyRunTTL),
}, nil
}
// waitTidyTimer 是生产等待:真定时器 + ctx 取消感知。
func waitTidyTimer(ctx context.Context, d time.Duration) bool {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
// SessionID 返回整理运行的专属隔离会话标识(design/23 §4.1 B)。
func (s *TidyService) SessionID() string { return TidySessionID }
// Enabled 报告整理是否被配置开启(design/23 §4 的 MEMORY_TIDY_ENABLED)。
func (s *TidyService) Enabled() bool { return s.cfg.Enabled }
// Start 启动心跳。它是幂等的:第二次调用是空操作。配置关闭时只记一条日志
// 并立即返回(关闭是显式选择,绝不静默)。ctx(可为 nil)是所有内部工作的
// 父级:取消它会像 Stop 一样停止服务。
func (s *TidyService) Start(ctx context.Context) {
if !s.cfg.Enabled {
s.log.Info("memory tidy disabled by configuration", "env", "MEMORY_TIDY_ENABLED")
return
}
if ctx == nil {
ctx = context.Background()
}
s.mu.Lock()
if s.running {
s.mu.Unlock()
return
}
s.running = true
cctx, cancel := context.WithCancel(ctx)
s.cancel = cancel
s.wg.Add(1)
s.mu.Unlock()
// 持久事实先读进来:判定「要不要补跑」只看它,不看进程内状态。
s.loadPersistedLastRun(ctx)
go s.loop(cctx)
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,
"compare_limit", s.cfg.CompareLimit,
"candidate_limit", s.cfg.CandidateLimit,
"auto_apply", s.cfg.autoApplyEnabled(),
"auto_apply_per_round", s.cfg.AutoApplyPerRound,
"rule_version", TidyRuleVersion,
"session_id", TidySessionID,
"last_run_at", s.lastRunLabel())
}
// loadPersistedLastRun 把「上次 run 的时刻」从 memory_tidy_runs 读进内存
// (切片 TIDY-1 的持久事实)。读不到时只记 WARN 并保持零值:那是「没有历史」
// 这一正常状态,调度会退到「安静窗内且未跑过 ⇒ 补跑」那一档。
//
// 零值会**保守地**让补跑更容易发生(窗口外无历史时不跑、窗口内直接跑一次),
// 这正是「宁可多跑一轮,不可永远不跑」的取向 —— 与旧口径相反,因为旧口径的
// 「省一次扫描」代价是两天一条没应用。
func (s *TidyService) loadPersistedLastRun(ctx context.Context) {
last, err := s.tidier.deps.Store.LastTidyRunStartedAt(ctx)
if err != nil {
s.log.Warn("memory tidy last run unreadable, catch-up will rely on the quiet window",
"error", err)
return
}
if last.IsZero() {
return
}
s.mu.Lock()
s.lastRun = last
s.mu.Unlock()
}
// lastRunLabel 渲染上次 run 的日志标签(零值 = 从未跑过)。
func (s *TidyService) lastRunLabel() string {
s.mu.Lock()
last := s.lastRun
s.mu.Unlock()
if last.IsZero() {
return "never"
}
return last.UTC().Format(time.RFC3339)
}
// Stop 停止心跳并等待进行中的一轮跑完。它是幂等的;若 ctx 在排空完成前被
// 取消则返回包装后的 context 错误,而不是泄漏 goroutine。
func (s *TidyService) Stop(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
s.mu.Lock()
if !s.running {
s.mu.Unlock()
return nil
}
s.running = false
cancel := s.cancel
s.mu.Unlock()
cancel()
done := make(chan struct{})
go func() {
s.wg.Wait()
close(done)
}()
select {
case <-done:
s.log.Info("memory tidy stopped")
return nil
case <-ctx.Done():
return fmt.Errorf("memory/tidy: stop: %w", ctx.Err())
}
}
// RunOnce 立即跑一轮整理(「立即整理」入口,design/23 §4 的手动可触发)。
// 它不改变心跳节奏,也不依赖上一次的结果 —— 幂等由 rule_version + 数据指纹
// 保证(design/23 §4.1 A)。
func (s *TidyService) RunOnce(ctx context.Context) (TidyStats, error) {
return s.runRound(ctx, "manual")
}
// ManualRun 是**手动真跑**入口(切片 TIDY-1;契约的
// POST /admin/memories/tidy/run)。它与心跳跑的是同一条规则路径、同一份配置,
// 差别只有两处:
//
// 1. **尊重配置开关**:关闭时如实返回 ErrTidyDisabled,不退化成空结果;
// 2. **触发标签是 manual**:审计里能区分「人点的」与「心跳/补跑的」。
//
// 它写候选与运行留痕(与试算 DryRun 的零副作用相反),并会跑**轮末自动档**:
// 规则层判为高置信的候选(字面重复 / 事实等价 ∧ ≥0.95 / 一次性事件且不像习惯)
// 由 tidy_auto.go 直接落地,其余仍等人点(候选 apply / 按 run 批量 apply)。
// 关掉自动档只需 `$DATA_DIR/config/memory-tidy.json` 的 `{"autoApply": false}`。
func (s *TidyService) ManualRun(ctx context.Context) (TidyStats, error) {
if !s.cfg.Enabled {
return TidyStats{}, ErrTidyDisabled
}
return s.runRound(ctx, "manual")
}
// loop 是心跳 goroutine:先做**启动补跑判定**(持久事实 + 墙上时钟),再进入
// 「等到下一次到点 → 跑一轮 → 按**当时时段**重算下一次」的循环。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)
for {
if !s.wait(ctx, s.nextDelay()) {
return
}
if ctx.Err() != nil {
return
}
// 到点即跑:间隔用的是**此刻**的时段(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
}
if _, err := s.runRound(ctx, "heartbeat"); err != nil {
s.log.Warn("memory tidy heartbeat round failed", "error", err)
}
}
}
// catchUp 做启动补跑(切片 TIDY-1 的调度修复):到点即跑,绝不让「进程重启」
// 把 24h 周期或安静窗永远推掉。
func (s *TidyService) catchUp(ctx context.Context) {
due, reason := s.dueNow()
if !due {
return
}
s.log.Info("memory tidy catch-up run starting",
"reason", reason, "session_id", TidySessionID, "last_run_at", s.lastRunLabel())
if _, err := s.runRound(ctx, "catchup"); err != nil {
s.log.Warn("memory tidy catch-up round failed", "reason", reason, "error", err)
}
}
// dueNow 是**到点即跑**的判据,只依赖两样东西:持久事实(s.lastRun,由
// memory_tidy_runs 载入)与墙上时钟。满足任一条即应立刻跑:
//
// 1. 从未跑过 **且** 已在安静窗内 —— 「这个窗口还没跑过」的最简情形(全新库);
// 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()
last := s.lastRun
s.mu.Unlock()
if last.IsZero() {
if s.cfg.inQuietHours(now) {
return true, "no_history_in_quiet_window"
}
return false, ""
}
if now.Sub(last) >= s.cfg.intervalAt(now) {
return true, "interval_elapsed"
}
if s.cfg.inQuietHours(now) {
if start := s.cfg.currentQuietStart(now); last.Before(start) {
return true, "quiet_window_not_run"
}
}
return false, ""
}
// nextDelay 返回距下一次整理开跑的等待时长。
//
// 口径(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()
s.mu.Lock()
last := s.lastRun
s.mu.Unlock()
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 {
return minTidyTickDelay
}
return delay
}
// runRound 跑一轮并把结果与审计留痕。单写者:runMu 串行化心跳与手动触发。
func (s *TidyService) runRound(ctx context.Context, trigger string) (TidyStats, error) {
s.runMu.Lock()
defer s.runMu.Unlock()
s.log.Info("memory tidy round starting", "trigger", trigger, "session_id", TidySessionID)
stats, err := s.tidier.RunOnce(ctx)
s.mu.Lock()
s.lastRun = s.now()
s.mu.Unlock()
s.audit(stats, trigger)
if err != nil {
s.log.Warn("memory tidy round failed", "trigger", trigger, "error", err)
}
return stats, err
}
// audit 写一条审计(design/23 §8 的 memory.tidy.* 口径)。审计出口由组合根
// 接到网关的 AuditEvent;未接线时只记日志(绝不静默)。审计只带计数,
// **不带任何记忆正文**。
func (s *TidyService) audit(stats TidyStats, trigger string) {
hook := s.tidier.deps.Audit
result := fmt.Sprintf(
"trigger=%s run_id=%s scanned=%d candidates=%d duplicates=%d vetoed=%d kept=%d duration_ms=%d by_action=%s rejected_reasons=%s auto_applied=%d auto_merge=%d auto_archive=%d auto_reclassify=%d auto_eligible=%d auto_truncated=%t",
trigger, stats.RunID, stats.Scanned, stats.Candidates, stats.Duplicates, stats.Vetoed,
stats.Kept, stats.DurationMS, tidyActionLabel(stats.ByAction), tidyReasonsLabel(stats.VetoReasons),
stats.AutoApplied, stats.AutoMerged, stats.AutoArchived, stats.AutoReclassified,
stats.AutoEligible, stats.AutoTruncated)
if hook == nil {
s.log.Info("memory tidy audit (no sink configured)", "what", "memory.tidy.run", "result", result)
return
}
hook(tidyAuditWho, "memory.tidy.run", result)
}
// tidyActionLabel 把「新建候选按动作的分布」渲染成稳定字符串(按动作名字典序)。
// 与 tidyReasonsLabel 同一手法,供审计与日志使用。
func tidyActionLabel(byAction map[string]int) string {
if len(byAction) == 0 {
return "none"
}
keys := make([]string, 0, len(byAction))
for k := range byAction {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%d", k, byAction[k]))
}
return strings.Join(parts, ",")
}
// tidyAuditWho 是整理审计的调用方标签:它是**系统任务**,不是任何角色
// (design/23 §4.1 C)。
const tidyAuditWho = "system/memorytidy"