228 lines
7.6 KiB
Go
228 lines
7.6 KiB
Go
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"
|