Files
Cyrene_v2/internal/runtime/memory/tidy_service.go
T
AskaEth 6e28b38b08
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 护栏抬高+时间预算闸+试算加性异步 与 D3 入库前审查(耦合两片一起落)
【MT-A:护栏 + 诚实截断 + 试算异步】
- maxTidyCompareItems 400(写死)→2000 并开放为 MEMORY_TIDY_COMPARE_LIMIT;
  MEMORY_TIDY_CANDIDATE_LIMIT 默认 20→200(语义不变,仍每分区)
- 依据是首跑实测:459 条的分区被 400 截断 ⇒ compared(705) < scanned(784),
  候选数(77)由上限而不是规则决定;三处上限的 WARN 都补齐了总数/上限/分区名
- 新增「上限 + 时间预算」双闸(实测 2000 条/分区 ≈234s > 3 分钟墙钟):
  整分区跳过 / 两两比对中途收手,一律如实记 (budget) + WARN
- ?async=1 ⇒ 202 {runId,status};GET …/{runId} 轮询(writeAuth 同档);
  done 的 result 与同步 data 逐字节同形;并发=复用既有 runId、TTL 30min 自完成起算、
  重启即如实 404 tidy_run_not_found;不带 async 时同步路径逐字不变

【MT-B:D3 入库前审查(design/23 §15.1)】
- 新记忆经 Store.Add(仓库里唯一 INSERT INTO memory_items)落库后,由独立记忆管理
  会话(sess-memorytidy)在有界后台队列异步判定 skip/merge/modify/create
- 软合并回写(status + merged_into/merged_from/merge_count,可 revert、绝不改 content)
- 隔离来源(群聊/陌生人)**概不审查**、召回/超时失败**零改写**(fail-safe 结构性成立)
- 写入侧仅 +62.7µs/条(非阻塞入队);默认 ENABLED=true(主人裁定:入库前先审查)

【耦合说明(重要)】两片同包且互相依赖:MT-B 的 preinsert_recall.go 用 MT-A 的
collectTidyPairs 新签名 + tidyBudget ⇒ **各自单独都 build 不过**,故一并落地。
MT-A 为让整包编译,最小幅度适配了 MT-B 的 preinsert_recall.go / preinsert_test.go
(零值预算=行为不变;符号重名改名)。

【我的验收】隔离 worktree(37 文件)build/vet/linux/darwin 全过,切片包全绿;
我亲手打红两个变异:① 去掉 (compare) 截断标记 ⇒ TestTidyCompareLimit_HitIsLoud 红;
② 中和隔离来源守卫 ⇒ TestReviewRestrictedSourceIsNeverTouched 红。
唯一红是 internal/runtime/tool 的两条超时用例(该包无人改)⇒ 主仓库 -count=8 全绿,
判定为负载敏感 flake(当时两个整树验收 + 子代理并发),已记入开发习惯与约定 §7。

.env.example 补 MEMORY_REVIEW_* 四键(MT-B 无该文件权限,由我按其代码语义补写)。
2026-10-06 08:04:12 +08:00

234 lines
7.8 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
// 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,
runs: newTidyRunRegistry(defaultTidyRunTTL),
}, 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,
"compare_limit", s.cfg.CompareLimit,
"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"