Files
Cyrene_v2/internal/runtime/memory/tidy_service.go
T
AskaEth 91a3209c9b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 记忆整理 v1(design/23)——低峰定期扫描 + 事实包含判定 + 规则否决 + 只产建议队列(不自动应用)
2026-10-05 11:29:36 +08:00

228 lines
7.6 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"
"sync"
"time"
)
// 本文件是记忆整理的**后台任务形态**(design/23 §4 方案 A/C):低峰定期跑、
// 独立运行、零上下文启动、只产建议、绝不推送。
//
// 与主人的硬约束对齐(design/23 §4.1):
// - 零上下文:本服务的依赖里**没有任何会话/历史读数**,输入只有记忆条目
// 与本次候选数据,因此「不读会话」是结构性成立的,不靠自觉;
// - 独立运行:跑在专属隔离会话 TidySessionID(不是 history.MainSessionID),
// 因此不改主人的心情、不进主会话泳道(cmd/cyrene 的 isConversationLane
// 对它恒为 false);
// - 它不是任何角色:系统任务,不带人格提示词,也不把写入限到某个角色的域;
// - 不写会话:唯一的写入是候选表与运行表;agent 侧对 chat_messages 零写入。
//
// 心跳范式参照 internal/services/backthink(同一低峰/安静时段语义),但有一处
// **有意的不同**:它**不做启动即跑**。backthink 的启动轮是产品行为(一句
// 拟人化问候),而整理启动即跑会在每次重启时触发一次全库向量化扫描 —— 那是
// 主人没要求的开销。整理因此只在安静时段起点开跑。
// minTidyTickDelay 是心跳循环的最小等待,防止配置异常(周期/时钟跳变)导致
// 空转忙轮询。
const minTidyTickDelay = time.Minute
// TidyService 是记忆整理服务。用 NewTidyService 构建,用 Start 启动心跳、
// Stop 停止它;RunOnce 是「立即整理」入口(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
// runMu 保证同一时刻只有一轮整理(design/23 §9 单写者):心跳与手动触发
// 若撞在一起,后者排队而不是并发改写候选表。
runMu sync.Mutex
}
// 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,
}, nil
}
// 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()
go s.loop(cctx)
s.log.Info("memory tidy started",
"interval", s.cfg.Interval.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,
"candidate_limit", s.cfg.CandidateLimit,
"rule_version", TidyRuleVersion,
"session_id", TidySessionID)
}
// 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")
}
// loop 是心跳 goroutine:算出下一次低峰时刻 → 等到 →(在低峰窗口内时)跑一轮
// → 重算。ctx 被取消时退出。
func (s *TidyService) loop(ctx context.Context) {
defer s.wg.Done()
for {
delay := s.nextDelay()
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
if ctx.Err() != nil {
return
}
if !s.cfg.inQuietHours(s.tidier.now()) {
// 低峰窗口外不跑:整理不是随时可做的动作(design/23 §4/§9)。
s.log.Debug("memory tidy skipped: outside quiet hours",
"now_hour", s.tidier.now().Hour())
continue
}
if _, err := s.runRound(ctx, "heartbeat"); err != nil {
s.log.Warn("memory tidy heartbeat round failed", "error", err)
}
}
}
// nextDelay 返回距下一次整理开跑的等待时长。
//
// 口径:下一次 = 下一个低峰窗口起点(窗口关闭时 = 现在 + 周期),并且不早于
// 「上一次运行 + 周期」—— 让配置的周期成为真实下限,而不是让安静时段每天
// 一次把「每周一次」冲掉。等待恒 ≥ minTidyTickDelay,避免配置异常时空转。
func (s *TidyService) nextDelay() time.Duration {
now := s.tidier.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
}
}
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.tidier.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 scanned=%d candidates=%d vetoed=%d rejected_reasons=%s",
trigger, stats.Scanned, stats.Candidates, stats.Vetoed, tidyReasonsLabel(stats.VetoReasons))
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)
}
// tidyAuditWho 是整理审计的调用方标签:它是**系统任务**,不是任何角色
// (design/23 §4.1 C)。
const tidyAuditWho = "system/memorytidy"