feat(memory): 记忆整理**应用路径 + 调度修复 + 手动真跑**(TIDY-1,主人投诉的根因)
主人实测:memory_items 1191 行、候选 77 条全 pending、memory_tidy_runs 仅 1 行、两晚定时没跑。 根因两条(都在这片里修掉): ① **以前根本没有执行面** —— 只有只读试算,候选永远 pending ② **调度被重启饿死** —— interval=24h 的 ticker 从进程启动算起,两天重启十几次 ⇒ 24h 永远走不完 - 应用路径:逐条 / 按 run 批量(默认 100、硬顶 500、超限 truncated)/ revert;**软合并** (status + merged_into/merged_from/merge_count,**绝不改 content、行不删**)+ 审计 (memory.tidy.apply / memory.tidy.revert)+ **幂等**(候选状态条件更新 + 行 status 闸两道) - 调度:改成**到点即跑**(catchUp:无历史+窗口内 / 距上次 run ≥ interval / 窗口起点晚于上次 run), 「上次 run」以 memory_tidy_runs 为**持久事实**;注入时钟用例证明**重启不饿死** - 手动真跑端点 POST /admin/memories/tidy/run(同档鉴权 + 审计 + 回执:新增/合并/软删/耗时) - 候选表加 run_id 列 + 索引(前滚;顺序=建表→补列→建索引,否则既有库会 no such column) - 3 个变异自检(删 catchUp / 去掉两道 status 闸 / truncated 恒 false) ⚠️ 生产库零动作:1191 行真跑与 77 条候选应用**未执行**(按红线由主人确认后主会话执行) ⚠️ 契约段在报告里(candidates 列表/apply/revert/tidy.run),Docs/ 待登记
This commit is contained in:
1 parent
57d0e8c543
commit
454ddb0b70
18 files changed
+3181
-71
No files matched your search
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -20,17 +22,25 @@ import (
|
||||
// - 它不是任何角色:系统任务,不带人格提示词,也不把写入限到某个角色的域;
|
||||
// - 不写会话:唯一的写入是候选表与运行表;agent 侧对 chat_messages 零写入。
|
||||
//
|
||||
// 心跳范式参照 internal/services/backthink(同一低峰/安静时段语义),但有一处
|
||||
// **有意的不同**:它**不做启动即跑**。backthink 的启动轮是产品行为(一句
|
||||
// 拟人化问候),而整理启动即跑会在每次重启时触发一次全库向量化扫描 —— 那是
|
||||
// 主人没要求的开销。整理因此只在安静时段起点开跑。
|
||||
// 心跳范式参照 internal/services/backthink(同一低峰/安静时段语义),但在
|
||||
// 「启动即跑」这一点上与本包旧版**有意不同**(切片 TIDY-1 修掉的正是旧口径):
|
||||
//
|
||||
// - 旧口径:进程启动时只算「下一个安静时段起点」,且「上次 run」只存在进程
|
||||
// 内存里。后端两天重启十几次 ⇒ 24h 周期永远走不完、安静窗起点永远被下一次
|
||||
// 重启覆盖,于是 memory_tidy_runs 只有 1 行、77 条候选全 pending。
|
||||
// - 新口径(到点即跑 + 持久事实):启动时先读**持久事实**
|
||||
// (memory_tidy_runs 里最近一轮的开始时刻),满足任一条件就**立刻补跑一次**:
|
||||
// ① 距上次 run 已 ≥ interval;② 已在安静窗内且窗口起点晚于上次 run
|
||||
// (= 这个窗口还没跑过)。此后心跳照旧只在安静窗内跑。
|
||||
//
|
||||
// 这样「重启不饿死」是可证的:重启只影响内存,判据只依赖持久事实与墙上时钟。
|
||||
|
||||
// minTidyTickDelay 是心跳循环的最小等待,防止配置异常(周期/时钟跳变)导致
|
||||
// 空转忙轮询。
|
||||
const minTidyTickDelay = time.Minute
|
||||
|
||||
// TidyService 是记忆整理服务。用 NewTidyService 构建,用 Start 启动心跳、
|
||||
// Stop 停止它;RunOnce 是「立即整理」入口(design/23 §4 的手动可触发)。
|
||||
// Stop 停止它;RunOnce / ManualRun 是「立即整理」入口(design/23 §4 的手动可触发)。
|
||||
type TidyService struct {
|
||||
tidier *Tidier
|
||||
cfg TidyConfig
|
||||
@@ -42,6 +52,13 @@ type TidyService struct {
|
||||
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
|
||||
@@ -61,10 +78,24 @@ func NewTidyService(cfg TidyConfig, deps TidyDeps) (*TidyService, error) {
|
||||
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 }
|
||||
|
||||
@@ -93,6 +124,9 @@ func (s *TidyService) Start(ctx context.Context) {
|
||||
s.wg.Add(1)
|
||||
s.mu.Unlock()
|
||||
|
||||
// 持久事实先读进来:判定「要不要补跑」只看它,不看进程内状态。
|
||||
s.loadPersistedLastRun(ctx)
|
||||
|
||||
go s.loop(cctx)
|
||||
|
||||
s.log.Info("memory tidy started",
|
||||
@@ -103,7 +137,41 @@ func (s *TidyService) Start(ctx context.Context) {
|
||||
"compare_limit", s.cfg.CompareLimit,
|
||||
"candidate_limit", s.cfg.CandidateLimit,
|
||||
"rule_version", TidyRuleVersion,
|
||||
"session_id", TidySessionID)
|
||||
"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 在排空完成前被
|
||||
@@ -143,26 +211,38 @@ func (s *TidyService) RunOnce(ctx context.Context) (TidyStats, error) {
|
||||
return s.runRound(ctx, "manual")
|
||||
}
|
||||
|
||||
// loop 是心跳 goroutine:算出下一次低峰时刻 → 等到 →(在低峰窗口内时)跑一轮
|
||||
// → 重算。ctx 被取消时退出。
|
||||
// ManualRun 是**手动真跑**入口(切片 TIDY-1;契约的
|
||||
// POST /admin/memories/tidy/run)。它与心跳跑的是同一条规则路径、同一份配置,
|
||||
// 差别只有两处:
|
||||
//
|
||||
// 1. **尊重配置开关**:关闭时如实返回 ErrTidyDisabled,不退化成空结果;
|
||||
// 2. **触发标签是 manual**:审计里能区分「人点的」与「心跳/补跑的」。
|
||||
//
|
||||
// 它写候选与运行留痕(与试算 DryRun 的零副作用相反),但**绝不自动应用** ——
|
||||
// 应用是另一条显式入口(候选 apply / 按 run 批量 apply),保持 §7 的人工在环。
|
||||
func (s *TidyService) ManualRun(ctx context.Context) (TidyStats, error) {
|
||||
if !s.cfg.Enabled {
|
||||
return TidyStats{}, ErrTidyDisabled
|
||||
}
|
||||
return s.runRound(ctx, "manual")
|
||||
}
|
||||
|
||||
// loop 是心跳 goroutine:先做**启动补跑判定**(持久事实 + 墙上时钟),再进入
|
||||
// 「等到下一个低峰起点 → 在窗口内时跑一轮 → 重算」的循环。ctx 被取消时退出。
|
||||
func (s *TidyService) loop(ctx context.Context) {
|
||||
defer s.wg.Done()
|
||||
s.catchUp(ctx)
|
||||
for {
|
||||
delay := s.nextDelay()
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
if !s.wait(ctx, s.nextDelay()) {
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if !s.cfg.inQuietHours(s.tidier.now()) {
|
||||
if !s.cfg.inQuietHours(s.now()) {
|
||||
// 低峰窗口外不跑:整理不是随时可做的动作(design/23 §4/§9)。
|
||||
s.log.Debug("memory tidy skipped: outside quiet hours",
|
||||
"now_hour", s.tidier.now().Hour())
|
||||
"now_hour", s.now().Hour())
|
||||
continue
|
||||
}
|
||||
if _, err := s.runRound(ctx, "heartbeat"); err != nil {
|
||||
@@ -171,13 +251,60 @@ func (s *TidyService) loop(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 已 ≥ interval —— 周期到点,窗口不再是必要条件(补跑);
|
||||
// 3. 已在安静窗内 **且** 窗口起点晚于上次 run —— 这个窗口还没跑过。
|
||||
//
|
||||
// 三条都**不**看「进程活了多久」,因此重启多少次都不会饿死整理。反过来,窗口
|
||||
// 内跑过一次之后再重启,条件 2/3 都不成立(lastRun 在窗口起点之后),因此也
|
||||
// 不会每次重启都重跑一轮扫描。
|
||||
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.Interval {
|
||||
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 返回距下一次整理开跑的等待时长。
|
||||
//
|
||||
// 口径:下一次 = 下一个低峰窗口起点(窗口关闭时 = 现在 + 周期),并且不早于
|
||||
// 「上一次运行 + 周期」—— 让配置的周期成为真实下限,而不是让安静时段每天
|
||||
// 一次把「每周一次」冲掉。等待恒 ≥ minTidyTickDelay,避免配置异常时空转。
|
||||
func (s *TidyService) nextDelay() time.Duration {
|
||||
now := s.tidier.now()
|
||||
now := s.now()
|
||||
next := s.cfg.nextQuietStart(now)
|
||||
if s.cfg.quietWindowDisabled() {
|
||||
next = now.Add(s.cfg.Interval)
|
||||
@@ -205,7 +332,7 @@ func (s *TidyService) runRound(ctx context.Context, trigger string) (TidyStats,
|
||||
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.lastRun = s.now()
|
||||
s.mu.Unlock()
|
||||
s.audit(stats, trigger)
|
||||
if err != nil {
|
||||
@@ -219,8 +346,10 @@ func (s *TidyService) runRound(ctx context.Context, trigger string) (TidyStats,
|
||||
// **不带任何记忆正文**。
|
||||
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))
|
||||
result := fmt.Sprintf(
|
||||
"trigger=%s run_id=%s scanned=%d candidates=%d duplicates=%d vetoed=%d duration_ms=%d by_action=%s rejected_reasons=%s",
|
||||
trigger, stats.RunID, stats.Scanned, stats.Candidates, stats.Duplicates, stats.Vetoed,
|
||||
stats.DurationMS, tidyActionLabel(stats.ByAction), tidyReasonsLabel(stats.VetoReasons))
|
||||
if hook == nil {
|
||||
s.log.Info("memory tidy audit (no sink configured)", "what", "memory.tidy.run", "result", result)
|
||||
return
|
||||
@@ -228,6 +357,24 @@ func (s *TidyService) audit(stats TidyStats, trigger string) {
|
||||
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"
|
||||
Reference in new issue
Block a user