feat(memory): 记忆整理**应用路径 + 调度修复 + 手动真跑**(TIDY-1,主人投诉的根因)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人实测: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:
AskaEth committed 2026-10-06 22:39:35 +08:00
1 parent 57d0e8c543
commit 454ddb0b70
18 files changed
+3181 -71

No files matched your search

+30 -6
View File
@@ -97,6 +97,10 @@ type TidyRun struct {
// TidyStats 是一轮整理的观测口径:**扫描 N / 候选 M / 建议 K / 否决原因分布**
// (design/23 §9)。它同时写进日志与运行表,绝不静默。
type TidyStats struct {
// RunID 是本轮在 memory_tidy_runs 里的留痕 id(切片 TIDY-1):候选按它归属,
// 「按 run 批量应用」因此有确定的抓手。留痕写失败时它仍是本轮已用的 id,
// 调用方不该据此假装留痕存在。
RunID string
Scanned int
Partitions int
Compared int
@@ -106,10 +110,15 @@ type TidyStats struct {
Skipped int
Vetoed int
VetoReasons map[string]int
// ByAction 是本轮**新建**候选按动作的分布(幂等跳过的 duplicates 不计,
// 它们不是这一轮的产出)。它是「新增/合并/软删各多少」这条回执的领域来源。
ByAction map[string]int
// Errors 是本轮的失败计数(分区失败 + 候选写入失败),进运行表。
Errors int
// Truncated 是被上限截断的分区标签(扫描或比对上限),供运维放大配额。
Truncated []string
// DurationMS 是本轮的墙钟耗时(毫秒),与日志里的 duration_ms 同一算法。
DurationMS int64
}
// TidyDeps 是注入给整理器的依赖。Store 必需;其余可空并优雅降级。
@@ -183,19 +192,24 @@ type tidyRoundPolicy struct {
// 日志;只有「所有分区都失败」或运行留痕本身写不进去时才回错误。
func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
started := t.now().UTC()
// 运行 id 与轮次开始**同时**定下来:候选要按它归属(切片 TIDY-1 的按轮
// 批量应用),运行留痕也要用它,因此不能等到扫完再生成。
runID := mustNewTidyID()
stats, partitions, err := t.beginRound(ctx)
if err != nil {
return stats, err
}
stats.RunID = runID
failures := t.scanPartitions(ctx, partitions, &stats, tidyRoundPolicy{
sink: t.persistCandidates,
sink: func(ctx context.Context, cands []TidyCandidate, stats *TidyStats) {
t.persistCandidates(ctx, runID, cands, stats)
},
recall: t.fallbackRecall,
})
run := TidyRun{
// 运行 id 在这里就定下来:留痕与日志必须指向同一条运行记录。
ID: mustNewTidyID(),
ID: runID,
StartedAt: started,
FinishedAt: t.now().UTC(),
Scanned: stats.Scanned,
@@ -207,6 +221,7 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
VetoReasons: stats.VetoReasons,
RuleVersion: TidyRuleVersion,
}
stats.DurationMS = run.FinishedAt.Sub(run.StartedAt).Milliseconds()
if err := t.deps.Store.persistTidyRun(ctx, run); err != nil {
failures = append(failures, err)
}
@@ -251,7 +266,10 @@ func (t *Tidier) logRun(stats TidyStats, run TidyRun) {
// beginRound 开一轮:准备口径容器并列出待整理分区。分区列表拿不到时整轮失败,
// 且**不写运行留痕** —— 那一轮根本没开始(口径与 design §9 一致)。
func (t *Tidier) beginRound(ctx context.Context) (TidyStats, []tidyPartition, error) {
stats := TidyStats{VetoReasons: make(map[string]int)}
stats := TidyStats{
VetoReasons: make(map[string]int),
ByAction: make(map[string]int),
}
partitions, err := t.deps.Store.listTidyPartitions(ctx)
if err != nil {
return stats, nil, fmt.Errorf("memory/tidy: list partitions: %w", err)
@@ -412,9 +430,12 @@ func (t *Tidier) classifyForTidy(it MemoryItem, stats *TidyStats) tidyDispositio
}
// persistCandidates 落库一批候选:幂等键已存在时只计入 duplicates
// (design/23 §9「重复跑不重复产候选」)。
func (t *Tidier) persistCandidates(ctx context.Context, cands []TidyCandidate, stats *TidyStats) {
// (design/23 §9「重复跑不重复产候选」),新建的计入 Candidates 并按动作进
// ByAction(回执的领域来源)。runID 是本轮的留痕 id:「按 run 批量应用」据此
// 找到这一轮产出的候选;幂等跳过的行保留它**首次**被产出的轮次,不被改写。
func (t *Tidier) persistCandidates(ctx context.Context, runID string, cands []TidyCandidate, stats *TidyStats) {
for _, c := range cands {
c.RunID = runID
created, err := t.deps.Store.insertTidyCandidate(ctx, c)
if err != nil {
stats.Errors++
@@ -426,6 +447,9 @@ func (t *Tidier) persistCandidates(ctx context.Context, cands []TidyCandidate, s
continue
}
stats.Candidates++
if stats.ByAction != nil {
stats.ByAction[string(c.Action)]++
}
}
}
+452
View File
@@ -0,0 +1,452 @@
package memory
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
// 本文件是记忆整理的**应用路径**(切片 TIDY-1;design/23 §7/§8 的执行面)。
//
// 动因是一条实测口径:整理只产候选、没有任何执行面,于是 77 条候选全 pending、
// 两天一条没应用。本文件把候选真正落地,五条不可走样的口径:
//
// 1. **只走软动作**:merge = 软合并(被吸收行 status='merged' + merged_into=存活
// id;存活行 merged_from/merge_count/updated_at 更新),archive = 软删
// (status='archived'),reclassify = 改 kind。**绝不改任何一行的 content**、
// **绝不物理删除**(design/23 §8:行保留 ⇒ 可 revert);
// 2. **幂等**:候选状态的条件更新(`… WHERE id=? AND status='pending'` +
// RowsAffected)是执行闸,记忆行的 `AND status='active'` 条件更新是第二道闸
// —— 两道都在同一事务里,重复应用同一条候选不产生第二次副作用;
// 3. **可 revert**:RevertTidyCandidate 把 merge 的被吸收行复原为 active、把
// archive 的归档行复原、把 reclassify 的 kind 还原,并把候选退回 pending;
// 4. **失败隔离**:单条候选失败只进 results(failed),批量里的其余条照跑
// (design/23 §9 的失败隔离同口径);
// 5. **动作闭集**:link / keep 是**只读建议**(design/23 §14:link 的存储形态未定),
// 因此不可应用 —— 应用它只会得到 skipped/action_not_applyable,绝不假装做过。
//
// 作用域:应用只在候选自带的分区(persona_id + scope + project_id)内改行;
// 候选与目标行的分区不一致时拒做(纵深防御 design/23 §5.2-7/8)。
// TidyApplyStatus 是一条候选应用结果的状态类别。
type TidyApplyStatus string
const (
// TidyApplyApplied 表示这次调用真的改了记忆(或真的撤销了改动)。
TidyApplyApplied TidyApplyStatus = "applied"
// TidyApplySkipped 表示没有改动,且**不是错误**:幂等命中(已决定)、
// 动作不可应用、候选形状不完整。
TidyApplySkipped TidyApplyStatus = "skipped"
// TidyApplyFailed 表示尝试过并失败。它不影响批量里的其它候选。
TidyApplyFailed TidyApplyStatus = "failed"
)
// 跳过的**机器可读**原因(面板按它聚合,不必解析中文文案)。
const (
// tidySkipAlreadyDecided 表示这条候选已被决定过 —— 重复应用/重复回滚的幂等口径。
tidySkipAlreadyDecided = "already_decided"
// tidySkipNotApplied 表示回滚时这条候选并不在已应用状态。
tidySkipNotApplied = "not_applied"
// tidySkipActionNotApplyable 表示该动作没有执行语义(link/keep)。
tidySkipActionNotApplyable = "action_not_applyable"
// tidySkipSourceNotActive 表示候选指向的记忆行已不在册(已被别的动作改过)。
tidySkipSourceNotActive = "source_not_active"
// tidySkipSourceMissing 表示候选指向的记忆行不存在。
tidySkipSourceMissing = "source_missing"
// tidySkipPartitionMismatch 表示候选的分区与它指向的行不一致。
tidySkipPartitionMismatch = "partition_mismatch"
// tidySkipInvalidCandidate 表示候选形状不完整(缺 target / 缺 source / kind 非法)。
tidySkipInvalidCandidate = "invalid_candidate"
)
// 整理应用侧的哨兵错误。与 memory 包其余错误同一口径:调用点一律 %w 包裹,
// 调用方用 errors.Is 判断。
var (
// ErrTidyCandidateNotFound 在按 id 操作一条不存在的候选时返回。
ErrTidyCandidateNotFound = errors.New("memory: tidy candidate not found")
// ErrTidyRunRecordNotFound 在按 run 批量应用而该轮**留痕行**不存在时返回 ——
// 拿一个拼错的 runId 来时,必须是「查不到这一轮」,不是「这一轮没有待定候选」。
// (与 ErrTidyRunNotFound 区分:那是异步试算的**进程内存**句柄,两者不是一回事。)
ErrTidyRunRecordNotFound = errors.New("memory: tidy run record not found")
// errTidyCandidateDecided 是事务内的幂等哨兵:候选状态的条件更新没命中,
// 说明它已被决定过。它不越过本包(调用方看到的是 skipped/already_decided)。
errTidyCandidateDecided = errors.New("memory/tidy: candidate already decided")
// errTidyItemNotActive 是事务内的第二道闸哨兵:目标记忆行已不在册
// (已被别的动作改过)。同样不越过本包。
errTidyItemNotActive = errors.New("memory/tidy: item is not active")
)
// TidyApplyOptions 是一次应用/回滚的请求口径。
type TidyApplyOptions struct {
// Actor 是决定者标签,落 memory_tidy_candidates.decided_by。
Actor string
// Limit 是**批量应用**的条数上限(非正取 defaultTidyApplyBatchLimit,
// 超过 maxTidyApplyBatchLimit 时钳到它)。单条应用忽略它。
Limit int
}
// normalizedLimit 归一批量上限:非正取默认值、超上限钳位(与既有查询面的
// limit 约定一致:钳位而不是报错)。
func (o TidyApplyOptions) normalizedLimit() int {
if o.Limit <= 0 {
return defaultTidyApplyBatchLimit
}
if o.Limit > maxTidyApplyBatchLimit {
return maxTidyApplyBatchLimit
}
return o.Limit
}
// TidyApplyResult 是一条候选的应用/回滚结果。
type TidyApplyResult struct {
CandidateID string `json:"candidateId"`
Action TidyAction `json:"action"`
Status TidyApplyStatus `json:"status"`
Reason string `json:"reason,omitempty"`
TargetID string `json:"targetId,omitempty"`
// AffectedIDs 是这次真的被改动的 memory_items id(merge=被吸收行;
// archive=被归档行;reclassify=被改 kind 的行)。它既是回滚的抓手,
// 也是「软删除后总行数不变」这条断言的证据面。
AffectedIDs []string `json:"affectedIds"`
// Error 是 failed 时的原因(面向运维;网关侧只记日志,不把原始正文上线)。
Error string `json:"error,omitempty"`
}
// TidyApplyBatch 是一次批量应用的回执。Total 是命中筛选的**真总数**(不是
// len(Results)),Truncated 报告「待定候选比上限多」—— 与扫描/候选上限的
// 诚实口径一致(超限绝不静默)。
type TidyApplyBatch struct {
RunID string `json:"runId"`
Limit int `json:"limit"`
Total int `json:"total"`
Truncated bool `json:"truncated"`
Results []TidyApplyResult `json:"results"`
Applied int `json:"applied"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
// Merged / Archived / Reclassified 是**成功应用**的候选按动作分解
// (skipped 的不计)—— 「新增/合并/软删各多少」这条回执的领域来源。
Merged int `json:"merged"`
Archived int `json:"archived"`
Reclassified int `json:"reclassified"`
}
// ApplyTidyCandidate 应用**一条**候选(design/23 §11 的 apply 粒度之一)。
// 找不到候选返回 ErrTidyCandidateNotFound;已决定/不可应用/形状不完整都以
// TidyApplySkipped 结果如实作答(不是错误 —— 幂等命中不该是错误)。
func (s *Store) ApplyTidyCandidate(ctx context.Context, id string, opts TidyApplyOptions) (TidyApplyResult, error) {
cand, err := s.GetTidyCandidate(ctx, id)
if err != nil {
return TidyApplyResult{}, err
}
return s.applyCandidate(ctx, cand, opts)
}
// applyCandidate 是单条应用的真实实现(批量路径也走它,保证两种粒度同一套语义)。
func (s *Store) applyCandidate(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions) (TidyApplyResult, error) {
base := TidyApplyResult{
CandidateID: cand.ID,
Action: cand.Action,
TargetID: cand.TargetID,
AffectedIDs: []string{},
}
if !cand.Action.applyable() {
// link / keep 没有执行语义(design/23 §14):如实说「不可应用」,不写任何行。
return skipTidyApply(base, tidySkipActionNotApplyable), nil
}
now := time.Now().UTC()
switch cand.Action {
case TidyMerge:
return s.applyTidyMerge(ctx, cand, opts, now)
case TidyArchive:
return s.applyTidyArchive(ctx, cand, opts, now)
case TidyReclassify:
return s.applyTidyReclassify(ctx, cand, opts, now)
default:
return skipTidyApply(base, tidySkipActionNotApplyable), nil
}
}
// applyable 报告一个动作是否有执行语义。
func (a TidyAction) applyable() bool {
switch a {
case TidyMerge, TidyArchive, TidyReclassify:
return true
default:
return false
}
}
// applyTidyMerge 把候选指认的「被吸收行」软合并进「存活行」。
func (s *Store) applyTidyMerge(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) {
base := TidyApplyResult{
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID, AffectedIDs: []string{},
}
if len(cand.SourceIDs) != 1 || strings.TrimSpace(cand.TargetID) == "" {
return skipTidyApply(base, tidySkipInvalidCandidate), nil
}
absorbed, survivor, skip, err := s.loadTidyMergeRows(ctx, cand)
if err != nil {
return base, err
}
if skip != "" {
return skipTidyApply(base, skip), nil
}
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
if err := markTidyAbsorbed(ctx, tx, absorbed.ID, survivor.ID, now); err != nil {
return err
}
return bumpSurvivor(ctx, tx, survivor, absorbed, now)
})
return finishTidyApply(base, []string{absorbed.ID}, err, tidySkipSourceNotActive)
}
// applyTidyArchive 软删一条一次性事件(status='archived'):行保留、内容不改,
// 只是不再参与检索与整理(design/23 §10 的 status 列 + §8 的软删除口径)。
func (s *Store) applyTidyArchive(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) {
base := TidyApplyResult{
CandidateID: cand.ID, Action: cand.Action, AffectedIDs: []string{},
}
if len(cand.SourceIDs) != 1 {
return skipTidyApply(base, tidySkipInvalidCandidate), nil
}
item, err := s.Get(ctx, cand.SourceIDs[0])
if errors.Is(err, ErrNotFound) {
return skipTidyApply(base, tidySkipSourceMissing), nil
}
if err != nil {
return base, err
}
if skip := tidyPartitionMismatch(cand, item); skip != "" {
return skipTidyApply(base, skip), nil
}
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
return archiveTidyItem(ctx, tx, item.ID, now)
})
return finishTidyApply(base, []string{item.ID}, err, tidySkipSourceNotActive)
}
// applyTidyReclassify 修正 kind 误分类(design/23 §5.1 T0-6):只改 kind 一列,
// 建议值来自候选证据里的 ProposedKind。
func (s *Store) applyTidyReclassify(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) {
base := TidyApplyResult{
CandidateID: cand.ID, Action: cand.Action, AffectedIDs: []string{},
}
if len(cand.SourceIDs) != 1 {
return skipTidyApply(base, tidySkipInvalidCandidate), nil
}
kind := Kind(cand.Evidence.ProposedKind)
if !kind.valid() {
return skipTidyApply(base, tidySkipInvalidCandidate), nil
}
item, err := s.Get(ctx, cand.SourceIDs[0])
if errors.Is(err, ErrNotFound) {
return skipTidyApply(base, tidySkipSourceMissing), nil
}
if err != nil {
return base, err
}
if skip := tidyPartitionMismatch(cand, item); skip != "" {
return skipTidyApply(base, skip), nil
}
if item.Kind == kind {
// 已经是建议值:没有可改的,如实说「源行不在册」的口径不适用,这里
// 用 already_decided 更像「无需再做」。
return skipTidyApply(base, tidySkipAlreadyDecided), nil
}
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
return reclassifyTidyItem(ctx, tx, item.ID, kind, now)
})
return finishTidyApply(base, []string{item.ID}, err, tidySkipSourceNotActive)
}
// loadTidyMergeRows 读取软合并的两行并做分区硬门校验。skip 非空表示应如实跳过
// (原因由它给出),此时两行不可用。
func (s *Store) loadTidyMergeRows(ctx context.Context, cand TidyCandidate) (MemoryItem, MemoryItem, string, error) {
absorbed, err := s.Get(ctx, cand.SourceIDs[0])
if errors.Is(err, ErrNotFound) {
return MemoryItem{}, MemoryItem{}, tidySkipSourceMissing, nil
}
if err != nil {
return MemoryItem{}, MemoryItem{}, "", err
}
survivor, err := s.Get(ctx, cand.TargetID)
if errors.Is(err, ErrNotFound) {
return MemoryItem{}, MemoryItem{}, tidySkipSourceMissing, nil
}
if err != nil {
return MemoryItem{}, MemoryItem{}, "", err
}
if skip := tidyPartitionMismatch(cand, absorbed); skip != "" {
return MemoryItem{}, MemoryItem{}, skip, nil
}
if skip := tidyPartitionMismatch(cand, survivor); skip != "" {
return MemoryItem{}, MemoryItem{}, skip, nil
}
if absorbed.ID == survivor.ID {
return MemoryItem{}, MemoryItem{}, tidySkipInvalidCandidate, nil
}
return absorbed, survivor, "", nil
}
// tidyPartitionMismatch 报告候选与一条记忆是否不同分区(是 ⇒ 返回不匹配原因)。
// 分区键是 design/23 §5.2-7/8 的硬门,应用侧必须再核一次:候选行是落库数据,
// 而落库数据可能来自更早的规则版本或手工改动。
func tidyPartitionMismatch(cand TidyCandidate, item MemoryItem) string {
if cand.PersonaID != item.PersonaID || cand.Scope != item.Scope || cand.ProjectID != item.ProjectID {
return tidySkipPartitionMismatch
}
return ""
}
// RevertTidyCandidate 撤销一条**已应用**的候选(design/23 §8 的回滚 + §11 的
// candidates/{id}/revert)。它把记忆行复原并把候选退回 pending(于是可以重新决定),
// 全程不删任何一行。候选不在已应用状态时返回 skipped/not_applied。
func (s *Store) RevertTidyCandidate(ctx context.Context, id string, opts TidyApplyOptions) (TidyApplyResult, error) {
cand, err := s.GetTidyCandidate(ctx, id)
if err != nil {
return TidyApplyResult{}, err
}
base := TidyApplyResult{
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID, AffectedIDs: []string{},
}
if !cand.Action.applyable() {
return skipTidyApply(base, tidySkipActionNotApplyable), nil
}
if cand.Status != tidyCandidateApplied {
return skipTidyApply(base, tidySkipNotApplied), nil
}
now := time.Now().UTC()
var reverted []string
err = s.tidyRevertTx(ctx, cand, opts, func(ctx context.Context, tx *sql.Tx) error {
out, err := revertTidyAction(ctx, tx, cand, now)
reverted = out
return err
})
if errors.Is(err, errTidyCandidateDecided) {
return skipTidyApply(base, tidySkipNotApplied), nil
}
if err != nil {
return tidyFailed(base, err), nil
}
base.Status = TidyApplyApplied
base.AffectedIDs = reverted
return base, nil
}
// ApplyTidyCandidates 批量应用待定候选(design/23 §11 的 apply 粒度之二/之三):
// runID 非空 ⇒ 只应用该轮产出的候选(「按 run 批量应用」);runID 为空 ⇒
// 应用全部待定候选(「全部待定应用」)。上限由 opts.Limit 决定,超限如实回报
// Truncated + Total(绝不静默少应用)。
//
// 逐条结果都在 Results 里(失败隔离:一条失败不阻塞其余)。
func (s *Store) ApplyTidyCandidates(ctx context.Context, runID string, opts TidyApplyOptions) (TidyApplyBatch, error) {
limit := opts.normalizedLimit()
if strings.TrimSpace(runID) != "" {
if _, err := s.GetTidyRun(ctx, runID); err != nil {
return TidyApplyBatch{}, fmt.Errorf("memory/tidy: apply by run %s: %w", runID, err)
}
}
total, err := s.countTidyCandidates(ctx, tidyCandidatePending, runID)
if err != nil {
return TidyApplyBatch{}, err
}
cands, err := s.listTidyCandidates(ctx, tidyCandidatePending, runID, limit)
if err != nil {
return TidyApplyBatch{}, err
}
batch := TidyApplyBatch{
RunID: runID,
Limit: limit,
Total: total,
Truncated: total > len(cands),
Results: make([]TidyApplyResult, 0, len(cands)),
}
for _, cand := range cands {
if err := ctx.Err(); err != nil {
// 上下文取消时**已应用的保持已应用**,未做的留给下一次(与清扫同口径)。
break
}
res, err := s.applyCandidate(ctx, cand, opts)
if err != nil {
res = tidyFailed(TidyApplyResult{
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID,
AffectedIDs: []string{},
}, err)
}
batch.Results = append(batch.Results, res)
tallyTidyApply(&batch, res)
}
if batch.RunID != "" && batch.Applied > 0 {
// 该轮的 applied 计数累加(design/23 §10 的 memory_tidy_runs.applied):
// 运行时那一轮写 0(当轮绝不自动应用),事后应用在这里补记。
if err := s.addTidyRunApplied(ctx, batch.RunID, batch.Applied); err != nil {
return batch, err
}
}
return batch, nil
}
// tallyTidyApply 把一条结果计入批量回执的计数器。
func tallyTidyApply(batch *TidyApplyBatch, res TidyApplyResult) {
switch res.Status {
case TidyApplyApplied:
batch.Applied++
switch res.Action {
case TidyMerge:
batch.Merged++
case TidyArchive:
batch.Archived++
case TidyReclassify:
batch.Reclassified++
}
case TidyApplyFailed:
batch.Failed++
default:
batch.Skipped++
}
}
// skipTidyApply 造一条 skipped 结果。
func skipTidyApply(base TidyApplyResult, reason string) TidyApplyResult {
base.Status = TidyApplySkipped
base.Reason = reason
if base.AffectedIDs == nil {
base.AffectedIDs = []string{}
}
return base
}
// tidyFailed 造一条 failed 结果(错误只进 Error 字段,调用方决定记不记日志)。
func tidyFailed(base TidyApplyResult, err error) TidyApplyResult {
base.Status = TidyApplyFailed
base.Reason = "apply_failed"
base.Error = err.Error()
if base.AffectedIDs == nil {
base.AffectedIDs = []string{}
}
return base
}
// finishTidyApply 把事务结果收敛成一条结果:nil ⇒ applied;幂等哨兵 ⇒ skipped;
// 目标行不在册 ⇒ skipped(原因由 notActiveReason 给);其余 ⇒ failed。
//
// affected 是**成功时才会挂上去**的受影响行 id:跳过的结果绝不能声称改过哪一行
// (那是把「没做」说成「做了」)。
func finishTidyApply(base TidyApplyResult, affected []string, err error, notActiveReason string) (TidyApplyResult, error) {
switch {
case err == nil:
base.Status = TidyApplyApplied
base.AffectedIDs = affected
return base, nil
case errors.Is(err, errTidyCandidateDecided):
return skipTidyApply(base, tidySkipAlreadyDecided), nil
case errors.Is(err, errTidyItemNotActive):
return skipTidyApply(base, notActiveReason), nil
default:
return tidyFailed(base, err), nil
}
}
+294
View File
@@ -0,0 +1,294 @@
package memory
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// 本文件是记忆整理**应用路径**(切片 TIDY-1)的存储层:候选认领、软合并 /
// 软删 / 改 kind 的事务写入,以及回滚。它复用 memory 包既有的 *sql.DB、
// 分区口径(tidyPartition / tidyStatusActive)与软合并且已存在的辅助
// (preinsert_store.go 的 bumpSurvivor / dropSurvivorLink / revertMergedSQL /
// selectMergedIntoSQL —— 那是 design/23 §8 的**同一套**软合并列语义,不是第二套)。
//
// 与 D3(入库前审查)那套的关系:语义同源(status/merged_into/merged_from/
// merge_count),但**闸口不同** —— D3 的顺序队列保证一条记忆只审一次,因此它的
// markAbsorbed 不带 `AND status='active'`;整理的候选是**事后**执行的,可能
// 过时(源行已被别的动作改过),所以这里每一条 UPDATE 都带状态条件,并把它
// 当作幂等/过时的判据(RowsAffected == 0)。
const (
// tidyCandidateApplied 是候选「已应用」状态(design/23 §10 的 status 闭集)。
tidyCandidateApplied = "applied"
// tidyStatusArchived 是软删后的行状态(design/23 §10:active/merged/archived)。
// 归档行不再参与检索与整理,但**行与原文都还在**,因此可 revert(§8)。
tidyStatusArchived = "archived"
)
// claimTidyCandidateSQL 是**执行闸**:只有把候选从 pending 原子地推到
// applied 的那一次调用才继续往下改记忆。WHERE 里的状态条件就是幂等性的根,
// 因此它绝不能省(少了它,重复应用会再写一次 memory_items)。
const claimTidyCandidateSQL = `UPDATE memory_tidy_candidates
SET status = ?, decided_at = ?, decided_by = ? WHERE id = ? AND status = ?`
// markTidyAbsorbedSQL 标记被吸收行:status='merged' + merged_into=存活 id。
// `AND status='active'` 是第二道闸:源行已被别的动作改过时 RowsAffected==0,
// 调用方据此回滚整个事务(不产生第二次合并计数)。
const markTidyAbsorbedSQL = `UPDATE memory_items
SET status = ?, merged_into = ?, updated_at = ? WHERE id = ? AND status = ?`
// setTidyItemStatusSQL 是软删 / 复原共用的一条:只改 status 与 updated_at,
// 带 from 状态条件。
const setTidyItemStatusSQL = `UPDATE memory_items
SET status = ?, updated_at = ? WHERE id = ? AND status = ?`
// reclassifyTidyItemSQL 只改 kind 一列(design/23 §5.1 T0-6),带状态条件。
const reclassifyTidyItemSQL = `UPDATE memory_items
SET kind = ?, updated_at = ? WHERE id = ? AND status = ?`
// undoTidyReclassifySQL 撤回一次 kind 修正:只在当前 kind 仍是当时写的建议值、
// 且行仍在册时生效(否则说明有人后来手动改过,不该被回滚覆盖)。
const undoTidyReclassifySQL = `UPDATE memory_items
SET kind = ?, updated_at = ? WHERE id = ? AND status = ? AND kind = ?`
// tidyApplyTx 在**一个事务**里跑一次应用:先认领候选(条件更新 = 执行闸),
// 再执行 mutate,最后提交。任何一步失败都回滚 —— 因此「候选被标 applied 但
// 记忆没改」这种半成品状态不可能落库。
func (s *Store) tidyApplyTx(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions,
now time.Time, mutate func(context.Context, *sql.Tx) error) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("memory/tidy: apply candidate begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
claimed, err := claimTidyCandidate(ctx, tx, cand.ID, tidyCandidatePending, tidyCandidateApplied, opts.Actor, now)
if err != nil {
return err
}
if !claimed {
return errTidyCandidateDecided
}
if err := mutate(ctx, tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("memory/tidy: apply candidate commit: %w", err)
}
return nil
}
// tidyRevertTx 与 tidyApplyTx 同构,只是认领的方向相反(applied → pending)。
func (s *Store) tidyRevertTx(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions,
mutate func(context.Context, *sql.Tx) error) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("memory/tidy: revert candidate begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
claimed, err := claimTidyCandidate(ctx, tx, cand.ID, tidyCandidateApplied, tidyCandidatePending, opts.Actor, time.Now().UTC())
if err != nil {
return err
}
if !claimed {
return errTidyCandidateDecided
}
if err := mutate(ctx, tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("memory/tidy: revert candidate commit: %w", err)
}
return nil
}
// claimTidyCandidate 原子地把候选从**当前状态 from** 推到 to。返回是否真的改了行:
// false 表示它已经被决定过(幂等命中,不是错误)。
//
// from 参数是这道闸的关键:应用要求 from='pending',回滚要求 from='applied'。
// 两者共用一个语句,但**不能共用一个硬编码的 from** —— 那会让回滚永远匹配不到行。
func claimTidyCandidate(ctx context.Context, tx *sql.Tx, id, from, to, actor string, now time.Time) (bool, error) {
res, err := tx.ExecContext(ctx, claimTidyCandidateSQL, to, formatTime(now), actor, id, from)
if err != nil {
return false, fmt.Errorf("memory/tidy: claim candidate %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("memory/tidy: claim candidate %s rows: %w", id, err)
}
return n > 0, nil
}
// markTidyAbsorbed 标记被吸收行并刷新存活行的合并元信息。源行不在册时返回
// errTidyItemNotActive(事务因此回滚)。
func markTidyAbsorbed(ctx context.Context, tx *sql.Tx, absorbedID, survivorID string, now time.Time) error {
res, err := tx.ExecContext(ctx, markTidyAbsorbedSQL,
string(ingestStatusMerged), survivorID, formatTime(now), absorbedID, tidyStatusActive)
if err != nil {
return fmt.Errorf("memory/tidy: mark absorbed item: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("memory/tidy: mark absorbed item rows: %w", err)
}
if n == 0 {
return errTidyItemNotActive
}
return nil
}
// setTidyItemStatus 在 from → to 之间改一行的 status。返回是否真的改了行。
func setTidyItemStatus(ctx context.Context, tx *sql.Tx, id, from, to string, now time.Time) (bool, error) {
res, err := tx.ExecContext(ctx, setTidyItemStatusSQL, to, formatTime(now), id, from)
if err != nil {
return false, fmt.Errorf("memory/tidy: set item status %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("memory/tidy: set item status rows: %w", err)
}
return n > 0, nil
}
// archiveTidyItem 把一条在册记忆软删(active → archived)。不在册时返回
// errTidyItemNotActive。它**只改 status/updated_at**,绝不碰 content。
func archiveTidyItem(ctx context.Context, tx *sql.Tx, id string, now time.Time) error {
changed, err := setTidyItemStatus(ctx, tx, id, tidyStatusActive, tidyStatusArchived, now)
if err != nil {
return err
}
if !changed {
return errTidyItemNotActive
}
return nil
}
// reclassifyTidyItem 把一条在册记忆的 kind 改为 kind。不在册时返回
// errTidyItemNotActive。
func reclassifyTidyItem(ctx context.Context, tx *sql.Tx, id string, kind Kind, now time.Time) error {
res, err := tx.ExecContext(ctx, reclassifyTidyItemSQL, string(kind), formatTime(now), id, tidyStatusActive)
if err != nil {
return fmt.Errorf("memory/tidy: reclassify item %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("memory/tidy: reclassify item rows: %w", err)
}
if n == 0 {
return errTidyItemNotActive
}
return nil
}
// addTidyRunApplied 把该轮留痕的 applied 计数累加(design/23 §10)。
func (s *Store) addTidyRunApplied(ctx context.Context, runID string, applied int) error {
if _, err := s.db.ExecContext(ctx, incrementTidyRunAppliedSQL, applied, runID); err != nil {
return fmt.Errorf("memory/tidy: bump run applied %s: %w", runID, err)
}
return nil
}
// revertTidyAction 在事务里撤销一条已应用候选对记忆的改动,返回真正被复原的
// item id(一条都没复原时返回空切片 —— 那说明用户已经用 /memories/{id}/revert
// 手工撤过了,此时只把候选退回 pending,不假装改过东西)。
func revertTidyAction(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) {
switch cand.Action {
case TidyMerge:
return revertTidyMerge(ctx, tx, cand, now)
case TidyArchive:
return revertTidyArchive(ctx, tx, cand, now)
case TidyReclassify:
return revertTidyReclassify(ctx, tx, cand, now)
default:
return []string{}, nil
}
}
// revertTidyMerge 复原一次软合并:逐条把被吸收行从 merged 复原为 active,并从
// 存活行的 merged_from 里摘掉它(dropSurvivorLink)。只复原**这条候选做的那次**
// 合并(merged_into 必须等于候选的 target)。
func revertTidyMerge(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) {
out := make([]string, 0, len(cand.SourceIDs))
for _, id := range cand.SourceIDs {
ok, err := revertTidyMergeInTx(ctx, tx, id, cand.TargetID, now)
if err != nil {
return nil, err
}
if ok {
out = append(out, id)
}
}
return out, nil
}
// revertTidyMergeInTx 复原单条被吸收行。它只认 `status='merged'` 且
// `merged_into=<该候选的存活 id>` 的行;targetID 为空时接受任意存活 id
// (容错:老候选可能没落 target)。
func revertTidyMergeInTx(ctx context.Context, tx *sql.Tx, absorbedID, targetID string, now time.Time) (bool, error) {
var survivorID string
err := tx.QueryRowContext(ctx, selectMergedIntoSQL, absorbedID, string(ingestStatusMerged)).Scan(&survivorID)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("memory/tidy: read merged item %s: %w", absorbedID, err)
}
if targetID != "" && survivorID != targetID {
// 它现在挂在**别的**存活行上(后来又被合并过一次):这条候选的回滚
// 不该把它从新关系里拽出来。
return false, nil
}
if _, err := tx.ExecContext(ctx, revertMergedSQL,
string(tidyStatusActive), formatTime(now), absorbedID, string(ingestStatusMerged)); err != nil {
return false, fmt.Errorf("memory/tidy: restore merged item %s: %w", absorbedID, err)
}
if survivorID != "" {
if err := dropSurvivorLink(ctx, tx, survivorID, absorbedID, now); err != nil {
return false, err
}
}
return true, nil
}
// revertTidyArchive 复原被软删(archived)的行:archived → active。
func revertTidyArchive(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) {
out := make([]string, 0, len(cand.SourceIDs))
for _, id := range cand.SourceIDs {
changed, err := setTidyItemStatus(ctx, tx, id, tidyStatusArchived, tidyStatusActive, now)
if err != nil {
return nil, err
}
if changed {
out = append(out, id)
}
}
return out, nil
}
// revertTidyReclassify 撤回一次 kind 修正:只在当前 kind 仍等于当时写的建议值、
// 且行仍在册时生效(有人后来手动改过就保持不动)。旧 kind 来自候选证据 KindA。
func revertTidyReclassify(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) {
kind := Kind(cand.Evidence.ProposedKind)
previous := Kind(cand.Evidence.KindA)
if !previous.valid() || !kind.valid() || len(cand.SourceIDs) != 1 {
return []string{}, nil
}
res, err := tx.ExecContext(ctx, undoTidyReclassifySQL,
string(previous), formatTime(now), cand.SourceIDs[0], tidyStatusActive, string(kind))
if err != nil {
return nil, fmt.Errorf("memory/tidy: undo reclassify %s: %w", cand.SourceIDs[0], err)
}
n, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("memory/tidy: undo reclassify rows: %w", err)
}
if n == 0 {
return []string{}, nil
}
return []string{cand.SourceIDs[0]}, nil
}
+477
View File
@@ -0,0 +1,477 @@
package memory
import (
"context"
"encoding/json"
"errors"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件锁定记忆整理**应用路径**(切片 TIDY-1;design/23 §7/§8)的六条判据:
//
// ① merge = 软合并:被吸收行 status='merged' + merged_into,存活行
// merged_from/merge_count 更新,**content 逐字不变**,memory_items 总行数不变;
// ② **幂等**:重复应用同一条候选 ⇒ skipped/already_decided,且不产生第二次副作用
// (merge_count 不再涨、没有第二行被改);
// ③ **可 revert**:回滚后字段复原(status/merged_into/merged_from/merge_count/kind),
// 行数不变,候选退回 pending;
// ④ archive = 软删(status='archived'),reclassify = 改 kind,都不碰 content;
// ⑤ link 不可应用 ⇒ skipped/action_not_applyable,零改动;
// ⑥ 批量(按 run)有上限、逐条结果、超限如实报 Truncated + Total。
// tidyItemRow 是断言用的一行记忆关键字段(含正文,用来钉「绝不改 content」)。
type tidyItemRow struct {
status string
mergedInto string
mergedFrom string
mergeCount int
content string
kind string
}
// loadTidyItemRow 读一行记忆的整理列 + 正文。
func loadTidyItemRow(t *testing.T, store *Store, id string) tidyItemRow {
t.Helper()
var r tidyItemRow
err := store.db.QueryRowContext(context.Background(),
`SELECT status, merged_into, merged_from, merge_count, content, kind
FROM memory_items WHERE id = ?`, id).
Scan(&r.status, &r.mergedInto, &r.mergedFrom, &r.mergeCount, &r.content, &r.kind)
if err != nil {
t.Fatalf("读记忆行 %s: %v", id, err)
}
return r
}
// countMemoryItems 数 memory_items 的总行数(软删除 ⇒ 应用前后必须不变)。
func countMemoryItems(t *testing.T, store *Store) int {
t.Helper()
var n int
if err := store.db.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM memory_items`).Scan(&n); err != nil {
t.Fatalf("数 memory_items: %v", err)
}
return n
}
// mergeCandidateFromRun 跑一轮整理并返回唯一的 merge 候选(顺带把运行 id 带出来)。
func mergeCandidateFromRun(t *testing.T, store *Store, emb embedding.Embedder) TidyCandidate {
t.Helper()
tr := newTestTidier(t, store, tidyTestCfg(0.80), emb)
stats, err := tr.RunOnce(context.Background())
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
cands, err := store.ListTidyCandidates(context.Background(), "", 50)
if err != nil {
t.Fatalf("ListTidyCandidates: %v", err)
}
merges := candidatesByAction(cands, TidyMerge)
if len(merges) != 1 {
t.Fatalf("merge 候选 = %d, want 1(本轮 run=%s)", len(merges), stats.RunID)
}
if merges[0].RunID != stats.RunID {
t.Fatalf("候选 run_id = %q, want 本轮 run_id = %q", merges[0].RunID, stats.RunID)
}
return merges[0]
}
// TestTidyApplyMergeIsSoftIdempotentAndRevertible 是①②③三条判据的合并用例:
// 软合并 → 重复应用无副作用 → 回滚后字段复原。
func TestTidyApplyMergeIsSoftIdempotentAndRevertible(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
short := seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
long := seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
cand := mergeCandidateFromRun(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
fixtureHabitLong: simVector(0.95),
}})
if cand.SourceIDs[0] != short.ID || cand.TargetID != long.ID {
t.Fatalf("候选方向不对:sources=%v target=%q", cand.SourceIDs, cand.TargetID)
}
rowsBefore := countMemoryItems(t, store)
shortBefore := loadTidyItemRow(t, store, short.ID)
longBefore := loadTidyItemRow(t, store, long.ID)
res, err := store.ApplyTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("ApplyTidyCandidate: %v", err)
}
if res.Status != TidyApplyApplied {
t.Fatalf("应用结果 = %+v, want applied", res)
}
absorbed := loadTidyItemRow(t, store, short.ID)
if absorbed.status != ingestStatusMerged || absorbed.mergedInto != long.ID {
t.Fatalf("被吸收行 = %+v, want status=merged merged_into=%s", absorbed, long.ID)
}
if absorbed.content != shortBefore.content {
t.Fatalf("被吸收行 content 被改了:%q → %q", shortBefore.content, absorbed.content)
}
survivor := loadTidyItemRow(t, store, long.ID)
if survivor.content != longBefore.content {
t.Fatalf("存活行 content 被改了:%q → %q", longBefore.content, survivor.content)
}
if survivor.mergeCount != longBefore.mergeCount+1 {
t.Fatalf("存活行 merge_count = %d, want %d", survivor.mergeCount, longBefore.mergeCount+1)
}
if !jsonHasID(t, survivor.mergedFrom, short.ID) {
t.Fatalf("存活行 merged_from = %s, want 含 %s", survivor.mergedFrom, short.ID)
}
if got := countMemoryItems(t, store); got != rowsBefore {
t.Fatalf("软合并改变了行数:%d → %d(行永不物理删除)", rowsBefore, got)
}
applied, err := store.GetTidyCandidate(ctx, cand.ID)
if err != nil {
t.Fatalf("GetTidyCandidate: %v", err)
}
if applied.Status != tidyCandidateApplied {
t.Fatalf("候选状态 = %q, want %q", applied.Status, tidyCandidateApplied)
}
// ② 重复应用:skipped/already_decided,且**零副作用**。
again, err := store.ApplyTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("第二次 ApplyTidyCandidate 不该报错(幂等命中不是错误):%v", err)
}
if again.Status != TidyApplySkipped || again.Reason != tidySkipAlreadyDecided {
t.Fatalf("第二次应用 = %+v, want skipped/%s", again, tidySkipAlreadyDecided)
}
if len(again.AffectedIDs) != 0 {
t.Fatalf("跳过的结果不得声称改过行:%v", again.AffectedIDs)
}
if got := loadTidyItemRow(t, store, long.ID).mergeCount; got != survivor.mergeCount {
t.Fatalf("重复应用把 merge_count 又加了一次:%d → %d", survivor.mergeCount, got)
}
if got := countMemoryItems(t, store); got != rowsBefore {
t.Fatalf("重复应用改变了行数:%d → %d", rowsBefore, got)
}
// ③ 回滚:字段逐字复原(updated_at 除外 —— 它是「什么时候动过」的审计面)。
rev, err := store.RevertTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("RevertTidyCandidate: %v", err)
}
if rev.Status != TidyApplyApplied {
t.Fatalf("回滚结果 = %+v, want applied", rev)
}
if got := loadTidyItemRow(t, store, short.ID); got != shortBefore {
t.Fatalf("回滚后被吸收行未复原:\n got=%+v\nwant=%+v", got, shortBefore)
}
if got := loadTidyItemRow(t, store, long.ID); got.mergeCount != longBefore.mergeCount || got.mergedFrom != "[]" {
t.Fatalf("回滚后存活行合并元信息未复原:%+v(want merge_count=%d merged_from=[])", got, longBefore.mergeCount)
}
if got := countMemoryItems(t, store); got != rowsBefore {
t.Fatalf("回滚改变了行数:%d → %d", rowsBefore, got)
}
if back, err := store.GetTidyCandidate(ctx, cand.ID); err != nil || back.Status != tidyCandidatePending {
t.Fatalf("回滚后候选状态 = %q (err=%v), want pending", back.Status, err)
}
// 回滚是幂等的:再回一次 ⇒ skipped/not_applied。
if againRev, err := store.RevertTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"}); err != nil {
t.Fatalf("第二次回滚不该报错:%v", err)
} else if againRev.Status != TidyApplySkipped || againRev.Reason != tidySkipNotApplied {
t.Fatalf("第二次回滚 = %+v, want skipped/%s", againRev, tidySkipNotApplied)
}
}
// TestTidyApplyMergeUnderConcurrencyHasSingleWinner 钉住执行闸(候选状态的条件
// 更新)在**并发**下也只有一次生效:16 路同时应用同一条候选,恰好一条 applied,
// merge_count 只涨一次。少了这道闸,多路会各自把源行当作 active 再合并一次。
func TestTidyApplyMergeUnderConcurrencyHasSingleWinner(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
short := seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
long := seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
cand := mergeCandidateFromRun(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
fixtureHabitLong: simVector(0.95),
}})
const racers = 16
var (
mu sync.Mutex
applied int
skipped int
start = make(chan struct{})
wg sync.WaitGroup
)
for i := 0; i < racers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
res, err := store.ApplyTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:race"})
mu.Lock()
defer mu.Unlock()
switch {
case err != nil:
t.Errorf("并发应用报错:%v", err)
case res.Status == TidyApplyApplied:
applied++
default:
skipped++
}
}()
}
close(start)
wg.Wait()
if applied != 1 || skipped != racers-1 {
t.Fatalf("并发应用 applied=%d skipped=%d, want 1/%d", applied, skipped, racers-1)
}
if got := loadTidyItemRow(t, store, long.ID).mergeCount; got != 2 {
t.Fatalf("存活行 merge_count = %d, want 2(只允许一次吸收)", got)
}
if got := loadTidyItemRow(t, store, short.ID); got.status != ingestStatusMerged || got.mergedInto != long.ID {
t.Fatalf("被吸收行 = %+v, want merged into %s", got, long.ID)
}
}
// TestTidyApplyArchiveAndRevert 钉住④:archive 是软删(status='archived',内容不变、
// 行数不变),revert 复原为 active。
func TestTidyApplyArchiveAndRevert(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
oneOff := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base, 0.9))
tr := newTestTidier(t, store, tidyTestCfg(0.80), nil)
if _, err := tr.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
cands, err := store.ListTidyCandidates(ctx, "", 50)
if err != nil {
t.Fatalf("ListTidyCandidates: %v", err)
}
archives := candidatesByAction(cands, TidyArchive)
if len(archives) != 1 {
t.Fatalf("archive 候选 = %d, want 1(%+v)", len(archives), cands)
}
rowsBefore := countMemoryItems(t, store)
before := loadTidyItemRow(t, store, oneOff.ID)
if res, err := store.ApplyTidyCandidate(ctx, archives[0].ID, TidyApplyOptions{Actor: "owner:test"}); err != nil {
t.Fatalf("ApplyTidyCandidate: %v", err)
} else if res.Status != TidyApplyApplied {
t.Fatalf("archive 应用 = %+v, want applied", res)
}
archived := loadTidyItemRow(t, store, oneOff.ID)
if archived.status != tidyStatusArchived {
t.Fatalf("归档后 status = %q, want %q", archived.status, tidyStatusArchived)
}
if archived.content != before.content {
t.Fatalf("软删改了 content:%q → %q", before.content, archived.content)
}
if got := countMemoryItems(t, store); got != rowsBefore {
t.Fatalf("软删改变了行数:%d → %d(design/23 §8:行保留)", rowsBefore, got)
}
if res, err := store.RevertTidyCandidate(ctx, archives[0].ID, TidyApplyOptions{Actor: "owner:test"}); err != nil {
t.Fatalf("RevertTidyCandidate: %v", err)
} else if res.Status != TidyApplyApplied {
t.Fatalf("archive 回滚 = %+v, want applied", res)
}
if got := loadTidyItemRow(t, store, oneOff.ID); got != before {
t.Fatalf("回滚后未复原:\n got=%+v\nwant=%+v", got, before)
}
if got := countMemoryItems(t, store); got != rowsBefore {
t.Fatalf("回滚改变了行数:%d → %d", rowsBefore, got)
}
}
// TestTidyApplyReclassifyAndRevert 钉住④的另一半:reclassify 只改 kind 一列,
// 内容不变;revert 把 kind 还原成候选证据里的旧值。
func TestTidyApplyReclassifyAndRevert(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
misclassified := MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindWorldEvent,
Content: fixtureHabitShort, Confidence: 0.9, CreatedAt: base,
}
superset := habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9)
bad := seedTidyItem(t, store, misclassified)
seedTidyItem(t, store, superset)
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
fixtureHabitLong: simVector(0.95),
}})
if _, err := tr.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
cands, err := store.ListTidyCandidates(ctx, "", 50)
if err != nil {
t.Fatalf("ListTidyCandidates: %v", err)
}
res := candidatesByAction(cands, TidyReclassify)
if len(res) != 1 {
t.Fatalf("reclassify 候选 = %d, want 1(%+v)", len(res), cands)
}
before := loadTidyItemRow(t, store, bad.ID)
if out, err := store.ApplyTidyCandidate(ctx, res[0].ID, TidyApplyOptions{Actor: "owner:test"}); err != nil {
t.Fatalf("ApplyTidyCandidate: %v", err)
} else if out.Status != TidyApplyApplied {
t.Fatalf("reclassify 应用 = %+v, want applied", out)
}
after := loadTidyItemRow(t, store, bad.ID)
if after.kind != string(KindRelationship) {
t.Fatalf("reclassify 后 kind = %q, want %q", after.kind, KindRelationship)
}
if after.content != before.content || after.status != before.status {
t.Fatalf("reclassify 改了不该改的列:%+v(before=%+v)", after, before)
}
if _, err := store.RevertTidyCandidate(ctx, res[0].ID, TidyApplyOptions{Actor: "owner:test"}); err != nil {
t.Fatalf("RevertTidyCandidate: %v", err)
}
if got := loadTidyItemRow(t, store, bad.ID); got.kind != before.kind {
t.Fatalf("回滚后 kind = %q, want %q(还原成候选证据里的旧值)", got.kind, before.kind)
}
}
// TestTidyApplyLinkIsNotApplyable 钉住⑤:link 没有执行语义(design/23 §14:
// 链接的存储形态未定),应用它只能得到 skipped,且**零改动**。
func TestTidyApplyLinkIsNotApplyable(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, habitItem(fixtureEnergyRedBull, base, 0.95))
seedTidyItem(t, store, habitItem(fixtureEnergyIced, base.Add(time.Minute), 0.95))
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
fixtureEnergyRedBull: {1, 0},
fixtureEnergyIced: simVector(0.99),
}})
if _, err := tr.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
cands, err := store.ListTidyCandidates(ctx, "", 50)
if err != nil {
t.Fatalf("ListTidyCandidates: %v", err)
}
links := candidatesByAction(cands, TidyLink)
if len(links) != 1 {
t.Fatalf("link 候选 = %d, want 1", len(links))
}
before := dumpTidyTable(t, store, "memory_items", memoryItemsColumns)
out, err := store.ApplyTidyCandidate(ctx, links[0].ID, TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("ApplyTidyCandidate(link): %v", err)
}
if out.Status != TidyApplySkipped || out.Reason != tidySkipActionNotApplyable {
t.Fatalf("link 应用结果 = %+v, want skipped/%s", out, tidySkipActionNotApplyable)
}
if len(out.AffectedIDs) != 0 {
t.Fatalf("不可应用的动作不得声称改过行:%v", out.AffectedIDs)
}
if got := dumpTidyTable(t, store, "memory_items", memoryItemsColumns); len(got) != len(before) {
t.Fatalf("link 应用改动了记忆:%d → %d 行", len(before), len(got))
}
if cand, err := store.GetTidyCandidate(ctx, links[0].ID); err != nil || cand.Status != tidyCandidatePending {
t.Fatalf("不可应用的候选应保持 pending,实际 %q (err=%v)", cand.Status, err)
}
}
// TestTidyApplyBatchByRunHonoursLimitAndReportsTruncated 钉住⑥:按 run 批量应用
// 逐条给结果、上限生效、超限如实报 Truncated + Total,并把该轮留痕的 applied 累加。
func TestTidyApplyBatchByRunHonoursLimitAndReportsTruncated(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
const oneOffs = 5
ids := make([]string, 0, oneOffs)
for i := 0; i < oneOffs; i++ {
item := habitItem(fixtureOneOffEvent, base.Add(time.Duration(i)*time.Minute), 0.9)
ids = append(ids, seedTidyItem(t, store, item).ID)
}
tr := newTestTidier(t, store, tidyTestCfg(0.80), nil)
stats, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.ByAction[string(TidyArchive)] != oneOffs {
t.Fatalf("ByAction[archive] = %d, want %d(%+v)", stats.ByAction[string(TidyArchive)], oneOffs, stats.ByAction)
}
batch, err := store.ApplyTidyCandidates(ctx, stats.RunID, TidyApplyOptions{Actor: "owner:test", Limit: 2})
if err != nil {
t.Fatalf("ApplyTidyCandidates: %v", err)
}
if batch.Total != oneOffs || len(batch.Results) != 2 || !batch.Truncated {
t.Fatalf("批量回执 = %+v, want total=%d results=2 truncated=true", batch, oneOffs)
}
if batch.Applied != 2 || batch.Archived != 2 || batch.Skipped != 0 || batch.Failed != 0 {
t.Fatalf("批量计数 = %+v, want applied=2 archived=2", batch)
}
for _, res := range batch.Results {
if res.Status != TidyApplyApplied || res.Action != TidyArchive || len(res.AffectedIDs) != 1 {
t.Fatalf("逐条结果 = %+v, want 每条 applied/archive 带 1 个 affected id", res)
}
}
archived := 0
for _, id := range ids {
if loadTidyItemRow(t, store, id).status == tidyStatusArchived {
archived++
}
}
if archived != 2 {
t.Fatalf("实际归档 = %d, want 2", archived)
}
runs, err := store.ListTidyRuns(ctx, 10)
if err != nil {
t.Fatalf("ListTidyRuns: %v", err)
}
if len(runs) != 1 || runs[0].Applied != 2 {
t.Fatalf("运行留痕 applied = %d (runs=%d), want 2", runs[0].Applied, len(runs))
}
// 剩余 3 条:第二批应用,超限标记消失(Total 已 ≤ 上限)。
batch2, err := store.ApplyTidyCandidates(ctx, stats.RunID, TidyApplyOptions{Actor: "owner:test", Limit: 2})
if err != nil {
t.Fatalf("第二批: %v", err)
}
if batch2.Total != oneOffs-2 || !batch2.Truncated || batch2.Applied != 2 {
t.Fatalf("第二批回执 = %+v, want total=3 truncated=true applied=2", batch2)
}
// runId 拼错必须是「查不到这一轮」,不是「这一轮没有待定候选」。
if _, err := store.ApplyTidyCandidates(ctx, "no-such-run", TidyApplyOptions{Actor: "owner:test"}); !errors.Is(err, ErrTidyRunRecordNotFound) {
t.Fatalf("未知 runId 的 err = %v, want ErrTidyRunRecordNotFound", err)
}
// runId 为空 ⇒ 「全部待定应用」这一档(没有轮次筛)。
rest, err := store.ApplyTidyCandidates(ctx, "", TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("全部待定应用: %v", err)
}
if rest.Total != 1 || rest.Applied != 1 || rest.Archived != 1 {
t.Fatalf("全部待定回执 = %+v, want total=1 applied=1 archived=1", rest)
}
}
// jsonHasID 报告一个 JSON 数组文本里是否含 id。
func jsonHasID(t *testing.T, jsonText, id string) bool {
t.Helper()
var ids []string
if err := json.Unmarshal([]byte(jsonText), &ids); err != nil {
t.Fatalf("解析 %q: %v", jsonText, err)
}
return containsID(ids, id)
}
+16 -8
View File
@@ -39,14 +39,17 @@ type TidyEvidence struct {
// TidyCandidate 是一条整理建议(落库形状,design/23 §10)。
type TidyCandidate struct {
ID string
PersonaID string
Scope Scope
ProjectID string
Kind Kind
Action TidyAction
SourceIDs []string
TargetID string
ID string
PersonaID string
Scope Scope
ProjectID string
Kind Kind
Action TidyAction
SourceIDs []string
TargetID string
// RunID 是产出这条候选的那一轮整理(切片 TIDY-1 补的列:批量应用的抓手)。
// 幂等跳过的候选保留**首次**产它的那一轮,不被后来的轮次改写。
RunID string
ProposedContent string
MergedContent string
Reason string
@@ -151,6 +154,11 @@ func buildCandidates(pr tidyPair, v tidyVerdict, now time.Time) []TidyCandidate
re.MergedContent = ""
re.Reason = "kind_mismatch"
re.Evidence.ProposedKind = string(target.Kind)
// KindA 在 reclassify 候选上**重新定义**为「被改那一行当前的 kind」:
// merge 候选的 KindA/KindB 是一对各自的 kind(供 diff 预览),而
// reclassify 候选只有一个主体(absorbed),回滚必须知道它原来的 kind,
// 否则 revert 会拿另一条行的 kind 去覆盖(design/23 §8 的可逆口径)。
re.Evidence.KindA = string(absorbed.Kind)
out = append(out, re)
}
return out
+29
View File
@@ -70,6 +70,17 @@ const (
// minTidyContentRunes 是参与比对的最小内容长度(按 rune)。过短的内容
// (如单字)没有可判定的事实要素,进比对只会产出噪声,直接跳过。
minTidyContentRunes = 3
// defaultTidyApplyBatchLimit 是「批量应用候选」的单次条数上限(切片 TIDY-1)。
// 它有上限是**刻意**的:一次点下去改几百条记忆是不可回头的观感,因此宁可
// 让主人分批点,且回执如实带上 Total/Truncated(超限绝不静默少应用)。
// 它是**代码默认值**(`.env` 已冻结、design/13 §2.1:新旋钮一律走
// $DATA_DIR/config/** 或代码默认值),不新增任何 env 键。
defaultTidyApplyBatchLimit = 100
// maxTidyApplyBatchLimit 是批量应用上限的硬顶:请求给更大的值就钳到这里
// (与既有查询面「钳位而不是报错」同一约定)。
maxTidyApplyBatchLimit = 500
)
// TidyConfig 携带记忆整理的调优旋钮。零值经 normalize 选取安全默认值,
@@ -173,3 +184,21 @@ func (c TidyConfig) nextQuietStart(at time.Time) time.Time {
}
return start
}
// currentQuietStart 返回 at **所在**的低峰窗口起点(不晚于 at):
// 落在窗口里(含跨午夜的半夜那一段)时是今天/昨天的 QuietStartHour 整点。
// 窗口关闭时返回零值时间。
//
// 它服务**补跑判据**(切片 TIDY-1):判断「这个窗口是不是已经跑过」只需要
// 「窗口起点 ≤ 上次 run」这一个比较,不必去构造「窗口内哪天」这种概念 ——
// 跨午夜的窗口(23:00–07:00)用日期算会算错。
func (c TidyConfig) currentQuietStart(at time.Time) time.Time {
if c.quietWindowDisabled() {
return time.Time{}
}
start := time.Date(at.Year(), at.Month(), at.Day(), c.QuietStartHour, 0, 0, 0, at.Location())
if start.After(at) {
start = start.AddDate(0, 0, -1)
}
return start
}
+2 -1
View File
@@ -42,7 +42,8 @@ var (
tidyCandidateColumns = []string{
"id", "persona_id", "scope", "project_id", "kind", "action", "source_ids", "target_id",
"proposed_content", "merged_content", "reason", "evidence", "rule_version",
"score_min", "score_max", "status", "idempotency_key", "created_at", "decided_at", "decided_by",
"score_min", "score_max", "status", "idempotency_key", "run_id", "created_at",
"decided_at", "decided_by",
}
tidyRunColumns = []string{
"id", "started_at", "finished_at", "scanned", "candidates", "applied", "skipped",
@@ -0,0 +1,306 @@
package memory
import (
"context"
"sync"
"testing"
"time"
)
// 本文件锁定记忆整理**调度修复**(切片 TIDY-1)的三条判据:
//
// ① **重启不饿死**:进程在安静窗内启动、且上次 run 已超过 interval ⇒ 启动即补跑
// (旧实现只看进程内 lastRun + 24h ticker,后端两天重启十几次 ⇒ 永远不跑);
// ② **跨过安静窗起点会跑**:启动在窗口外、无历史 ⇒ 等到窗口起点那一轮自己跑;
// ③ **不重复跑**:同一安静窗内已经跑过(lastRun 在窗口起点之后)⇒ 重启不再跑,
// 否则「修好饿死」会变成「每次重启都全库扫一遍」。
//
// 用例全部用**注入时钟 + 假等待**:不真等、不靠真实时间。假等待把假时钟推进到
// 下一次唤醒时刻,因此「跨过窗口起点」是可确定性验证的。
// schedClock 是可推进的假时钟。
type schedClock struct {
mu sync.Mutex
at time.Time
}
func (c *schedClock) now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.at
}
func (c *schedClock) advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.at = c.at.Add(d)
}
// tidyWaitProbe 是假等待 + 「已被调用」信号的组合:假等待把假时钟推进到下一次
// 唤醒时刻,同时把每次调用记进 signals —— 于是用例可以**确定性地**等到
// 「循环已经走到某个阶段」再断言 / 再 Stop,不必真等也不必 sleep。
//
// 信号序列与循环的对应关系:第 1 次 wait 之前一定已经做完启动补跑判定;
// 第 N+1 次 wait 之前一定已经做完第 N 次唤醒后的那一轮。
type tidyWaitProbe struct {
clock *schedClock
budget int
calls int
signals chan int
}
func newTidyWaitProbe(clock *schedClock, budget int) *tidyWaitProbe {
return &tidyWaitProbe{clock: clock, budget: budget, signals: make(chan int, 16)}
}
func (p *tidyWaitProbe) wait(ctx context.Context, d time.Duration) bool {
p.calls++
select {
case p.signals <- p.calls:
default:
}
if p.calls > p.budget {
return false
}
p.clock.advance(d)
return true
}
// awaitCalls 等到假等待已被调用 n 次(每次至多等 5 秒;超时即 FAIL)。
func (p *tidyWaitProbe) awaitCalls(t *testing.T, n int) {
t.Helper()
for i := 0; i < n; i++ {
select {
case <-p.signals:
case <-time.After(5 * time.Second):
t.Fatalf("等待第 %d 次心跳唤醒超时(循环没走到预期阶段)", i+1)
}
}
}
// seedTidyRun 直接落一条运行留痕(模拟「上次 run 的持久事实」)。
func seedTidyRun(t *testing.T, store *Store, startedAt time.Time) {
t.Helper()
if err := store.persistTidyRun(context.Background(), TidyRun{
ID: mustNewTidyID(),
StartedAt: startedAt.UTC(),
FinishedAt: startedAt.UTC(),
RuleVersion: TidyRuleVersion,
}); err != nil {
t.Fatalf("写运行留痕: %v", err)
}
}
// tidyRunTimes 返回运行留痕的开始时刻(最新在前)。
func tidyRunTimes(t *testing.T, store *Store) []string {
t.Helper()
runs, err := store.ListTidyRuns(context.Background(), 50)
if err != nil {
t.Fatalf("ListTidyRuns: %v", err)
}
out := make([]string, 0, len(runs))
for _, r := range runs {
out = append(out, r.StartedAt.UTC().Format(time.RFC3339))
}
return out
}
// TestTidyServiceCatchUpAfterRestartDoesNotStarve 是判据①:上次 run 已超过
// interval(这里用 25h),进程在安静窗内重启 ⇒ **启动即补跑**,不等 ticker。
//
// 假等待立刻返回 false(模拟「还没到下一个窗口,进程又被重启了」)—— 这正是
// 后端两天重启十几次的真实情形:只看 ticker 的实现会永远不跑。
func TestTidyServiceCatchUpAfterRestartDoesNotStarve(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
// 上次 run:两天前 23:00(安静窗内),现在 23:30 ⇒ 距上次 48.5h ≥ 24h。
last := time.Date(2026, 10, 5, 23, 0, 0, 0, time.Local)
seedTidyRun(t, store, last)
now := time.Date(2026, 10, 7, 23, 30, 0, 0, time.Local)
clock := &schedClock{at: now}
probe := newTidyWaitProbe(clock, 0) // 一次都不等到下一次窗口
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil)
svc.wait = probe.wait
svc.Start(ctx)
probe.awaitCalls(t, 1) // 循环已走完启动补跑判定并进入等待
if err := svc.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
times := tidyRunTimes(t, store)
if len(times) != 2 {
t.Fatalf("运行留痕 = %v, want 2(历史 1 + 启动补跑 1)", times)
}
if times[0] != now.UTC().Format(time.RFC3339) {
t.Fatalf("补跑时刻 = %q, want 启动时刻 %q", times[0], now.UTC().Format(time.RFC3339))
}
}
// TestTidyServiceCatchUpOnFirstStartInsideQuietHours 是判据①的「无历史」分支:
// 全新库在安静窗内启动 ⇒ 立刻跑一轮(旧口径会一直等到下一个 23:00)。
func TestTidyServiceCatchUpOnFirstStartInsideQuietHours(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
now := time.Date(2026, 10, 7, 23, 5, 0, 0, time.Local)
clock := &schedClock{at: now}
probe := newTidyWaitProbe(clock, 0)
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil)
svc.wait = probe.wait
svc.Start(ctx)
probe.awaitCalls(t, 1)
if err := svc.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
times := tidyRunTimes(t, store)
if len(times) != 1 || times[0] != now.UTC().Format(time.RFC3339) {
t.Fatalf("运行留痕 = %v, want 恰为启动时刻 %q", times, now.UTC().Format(time.RFC3339))
}
}
// TestTidyServiceRunsWhenQuietWindowStarts 是判据②:启动在窗口外(22:00)且无
// 历史 ⇒ 先不跑;假等待把时钟推进到窗口起点(23:00)⇒ 心跳跑一轮。
func TestTidyServiceRunsWhenQuietWindowStarts(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
clock := &schedClock{at: time.Date(2026, 10, 7, 22, 0, 0, 0, time.Local)}
probe := newTidyWaitProbe(clock, 1) // 第一次唤醒(= 窗口起点)后放行,第二次退出
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil)
svc.wait = probe.wait
svc.Start(ctx)
probe.awaitCalls(t, 2) // 第 2 次唤醒之前必然已跑完窗口起点那一轮
if err := svc.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
want := time.Date(2026, 10, 7, 23, 0, 0, 0, time.Local)
times := tidyRunTimes(t, store)
if len(times) != 1 || times[0] != want.UTC().Format(time.RFC3339) {
t.Fatalf("运行留痕 = %v, want 恰为窗口起点 %q", times, want.UTC().Format(time.RFC3339))
}
}
// TestTidyServiceDoesNotRerunInsideSameQuietWindow 是判据③(防过度补跑):
// 同一安静窗内已经跑过 ⇒ 重启不再跑。窗口是跨午夜的(23:00–07:00),因此
// 用例特意用「今天 00:30」这种「窗口起点在昨天 23:00」的时刻。
func TestTidyServiceDoesNotRerunInsideSameQuietWindow(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
last := time.Date(2026, 10, 6, 23, 5, 0, 0, time.Local) // 窗口起点 23:00 之后
seedTidyRun(t, store, last)
clock := &schedClock{at: time.Date(2026, 10, 7, 0, 30, 0, 0, time.Local)} // 同一窗口内
probe := newTidyWaitProbe(clock, 0)
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil)
svc.wait = probe.wait
svc.Start(ctx)
probe.awaitCalls(t, 1)
if err := svc.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
if times := tidyRunTimes(t, store); len(times) != 1 {
t.Fatalf("运行留痕 = %v, want 只保留历史那一条(同一窗口不重复跑)", times)
}
}
// TestTidyServiceDueNowJudgements 把 dueNow 的三条判据逐条钉死(纯函数式断言,
// 不涉及 goroutine)。
func TestTidyServiceDueNowJudgements(t *testing.T) {
t.Parallel()
store := newTestStore(t)
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, nil, nil)
// 时钟由用例直接改写 svc.now(不经过 Start)。
at := func(tm time.Time) { svc.now = func() time.Time { return tm } }
setLast := func(tm time.Time) { svc.lastRun = tm }
cases := []struct {
name string
now time.Time
last time.Time
due bool
}{
{
name: "无历史且在窗口外 ⇒ 不补跑",
now: time.Date(2026, 10, 7, 15, 0, 0, 0, time.Local),
last: time.Time{}, due: false,
},
{
name: "无历史且在窗口内 ⇒ 补跑",
now: time.Date(2026, 10, 7, 23, 30, 0, 0, time.Local),
last: time.Time{}, due: true,
},
{
name: "距上次已超 interval ⇒ 补跑(窗口外也跑)",
now: time.Date(2026, 10, 7, 15, 0, 0, 0, time.Local),
last: time.Date(2026, 10, 6, 14, 0, 0, 0, time.Local), due: true,
},
{
name: "窗口内且本窗口未跑 ⇒ 补跑",
now: time.Date(2026, 10, 7, 23, 30, 0, 0, time.Local),
last: time.Date(2026, 10, 7, 12, 0, 0, 0, time.Local), due: true,
},
{
name: "窗口内且本窗口已跑 ⇒ 不补跑(跨午夜也算同一窗口)",
now: time.Date(2026, 10, 7, 0, 30, 0, 0, time.Local),
last: time.Date(2026, 10, 6, 23, 5, 0, 0, time.Local), due: false,
},
{
name: "窗口外且未到周期 ⇒ 不补跑",
now: time.Date(2026, 10, 7, 15, 0, 0, 0, time.Local),
last: time.Date(2026, 10, 7, 2, 0, 0, 0, time.Local), due: false,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
at(tc.now)
setLast(tc.last)
due, reason := svc.dueNow()
if due != tc.due {
t.Fatalf("dueNow = %v(%s), want %v", due, reason, tc.due)
}
if due && reason == "" {
t.Fatal("补跑必须带原因标签(日志/排障要用)")
}
})
}
}
// TestTidyServiceManualRunRespectsDisabled 钉住手动真跑入口的口径:关闭时如实
// 返回 ErrTidyDisabled(不退化成空结果),开启时与 RunOnce 同一条路径。
func TestTidyServiceManualRunRespectsDisabled(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
off := tidyTestCfg(0.80)
off.Enabled = false
svcOff := newTidyServiceForTest(t, store, off, nil, nil, nil)
if _, err := svcOff.ManualRun(ctx); err != ErrTidyDisabled {
t.Fatalf("关闭时 ManualRun err = %v, want ErrTidyDisabled", err)
}
on := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, nil, nil)
stats, err := on.ManualRun(ctx)
if err != nil {
t.Fatalf("ManualRun: %v", err)
}
if stats.RunID == "" {
t.Fatal("真跑必须留下运行 id(候选按它归属)")
}
runs := tidyRunTimes(t, store)
if len(runs) != 1 {
t.Fatalf("运行留痕 = %v, want 1", runs)
}
}
+51 -5
View File
@@ -17,6 +17,11 @@ import (
// createMemoryTidyCandidatesTable 是人工确认队列(design/23 §10)。
// idempotency_key 唯一:同一批数据重复跑不重复产候选(design/23 §9)。
//
// run_id 是**本实现在设计稿列集之上补的一列**(切片 TIDY-1):设计稿 §10 的
// 候选表没有「哪一轮产的」这一列,于是「按 run 批量应用」只能靠时间窗猜 ——
// 那是把幂等键的语义换成时间戳比较。run_id 让批量应用有确定的抓手,且
// **不进幂等键**(否则同一批数据每轮都会重复产候选),因此老语义逐字不变。
const createMemoryTidyCandidatesTable = `
CREATE TABLE IF NOT EXISTS memory_tidy_candidates (
id TEXT PRIMARY KEY,
@@ -36,6 +41,7 @@ CREATE TABLE IF NOT EXISTS memory_tidy_candidates (
score_max REAL NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
idempotency_key TEXT NOT NULL UNIQUE,
run_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
decided_at TEXT NOT NULL DEFAULT '',
decided_by TEXT NOT NULL DEFAULT ''
@@ -45,6 +51,10 @@ const createMemoryTidyCandidatesStatusIndex = `
CREATE INDEX IF NOT EXISTS idx_memory_tidy_candidates_status
ON memory_tidy_candidates (status, scope)`
const createMemoryTidyCandidatesRunIndex = `
CREATE INDEX IF NOT EXISTS idx_memory_tidy_candidates_run
ON memory_tidy_candidates (run_id, status)`
// createMemoryTidyRunsTable 是整理运行的留痕(design/23 §4.1 F:留痕不靠
// 会话)。vetoed / veto_reasons 是本实现在设计稿列集之上补的两列:design/23
// §9 要求「每轮记否决原因分布」,只落日志就无法查询,故一并入库。
@@ -86,14 +96,30 @@ const createMemoryItemsStatusIndex = `
CREATE INDEX IF NOT EXISTS idx_memory_items_status
ON memory_items (persona_id, status)`
// ensureTidySchema 幂等地创建整理所需的两张新表、状态索引,并前滚
// memory_items 的整理列。它由 NewStore 调用,因此整理能力在任何
// *sql.DB 上都可用(与 memory_items 自身同一口径)。
// ensureTidySchema 幂等地建整理所需的两张新表、前滚列、再建索引。它由 NewStore
// 调用,因此整理能力在任何 *sql.DB 上都可用(与 memory_items 自身同一口径)。
//
// 顺序**必须**是「建表 → 前滚列 → 建索引」:既有库上的候选表没有 run_id,
// 若先建 run_id 上的索引,SQLite 会报 "no such column: run_id"(旧实现里
// 索引只依赖建表时就有的列,因此没暴露这个顺序问题)。
func ensureTidySchema(ctx context.Context, db *sql.DB) error {
for _, ddl := range []string{
createMemoryTidyCandidatesTable,
createMemoryTidyCandidatesStatusIndex,
createMemoryTidyRunsTable,
} {
if _, err := db.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("memory: tidy schema: %w", err)
}
}
if err := ensureTidyMemoryColumns(ctx, db); err != nil {
return err
}
if err := ensureTidyCandidateRunColumn(ctx, db); err != nil {
return err
}
for _, ddl := range []string{
createMemoryTidyCandidatesStatusIndex,
createMemoryTidyCandidatesRunIndex,
createMemoryTidyRunsStartedIndex,
createMemoryItemsStatusIndex,
} {
@@ -101,7 +127,27 @@ func ensureTidySchema(ctx context.Context, db *sql.DB) error {
return fmt.Errorf("memory: tidy schema: %w", err)
}
}
return ensureTidyMemoryColumns(ctx, db)
return nil
}
// ensureTidyCandidateRunColumn 把 run_id 前滚到**既有**的候选表上(切片 TIDY-1):
// CREATE TABLE IF NOT EXISTS 只在建表时生效,既有库的表已经建好,因此新列必须
// 单独补一次。手法与 ensureTidyMemoryColumns 一致:已存在时 SQLite 报
// "duplicate column name: run_id",那是预期路径。
//
// ⚠️ 与迁移的关系:internal/db 的 0019_memory_tidy 是权威迁移,本文件是
// 「记忆存储自包含、可在任意 *sql.DB 上使用」的前滚(两处口径必须一致)。
// 本列目前**只在内存包侧前滚**,未同步进 0019(改迁移文件超出本切片边界),
// 组合根每次启动都会跑到这里,因此生产库在下次重启时即可拿到该列。
func ensureTidyCandidateRunColumn(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx,
`ALTER TABLE memory_tidy_candidates ADD COLUMN run_id TEXT NOT NULL DEFAULT ''`); err != nil {
if strings.Contains(err.Error(), "duplicate column name") {
return nil
}
return fmt.Errorf("memory: roll forward tidy candidate run_id: %w", err)
}
return nil
}
// ensureTidyMemoryColumns 把 memory_items 的整理列前滚到既有表上:列缺失时
+167 -20
View File
@@ -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"
+12 -5
View File
@@ -83,16 +83,23 @@ func TestTidyServiceNextDelay(t *testing.T) {
}
}
// TestTidyServiceStartDoesNotRunImmediately 锁定与 backthink 的**有意差异**:
// 整理不做启动即跑,进程重启不会触发一次全库扫描。
func TestTidyServiceStartDoesNotRunImmediately(t *testing.T) {
// TestTidyServiceStartOutsideQuietWindowDoesNotRunImmediately 锁定补跑判据的
// 一条边界(切片 TIDY-1):**无历史**且启动在安静窗**之外**时不做启动即跑 ——
// 新装的库不该在任意时刻烧一次全库向量化扫描,等窗口起点那一轮即可。
// 时钟是注入的固定值,因此这条断言与用例真的跑在几点无关(旧实现用
// time.Now,在 23:00–07:00 之间跑就会抖动)。
func TestTidyServiceStartOutsideQuietWindowDoesNotRunImmediately(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, time.Now, nil)
clock := &schedClock{at: time.Date(2026, 10, 7, 15, 0, 0, 0, time.Local)}
probe := newTidyWaitProbe(clock, 0) // 绝不真的等到窗口起点
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil)
svc.wait = probe.wait
svc.Start(ctx)
probe.awaitCalls(t, 1)
if err := svc.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
@@ -101,7 +108,7 @@ func TestTidyServiceStartDoesNotRunImmediately(t *testing.T) {
t.Fatalf("ListTidyRuns: %v", err)
}
if len(runs) != 0 {
t.Fatalf("启动即跑了整理:%+v", runs)
t.Fatalf("窗口外无历史时不该启动即跑:%+v", runs)
}
}
+142 -26
View File
@@ -2,7 +2,9 @@ package memory
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
@@ -55,21 +57,47 @@ const countTidyPartitionItemsSQL = `SELECT COUNT(*) FROM memory_items
const insertTidyCandidateSQL = `INSERT OR IGNORE INTO memory_tidy_candidates
(id, persona_id, scope, project_id, kind, action, source_ids, target_id,
proposed_content, merged_content, reason, evidence, rule_version,
score_min, score_max, status, idempotency_key, created_at, decided_at, decided_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '')`
score_min, score_max, status, idempotency_key, run_id, created_at, decided_at, decided_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '')`
const insertTidyRunSQL = `INSERT INTO memory_tidy_runs
(id, started_at, finished_at, scanned, candidates, applied, skipped, errors,
vetoed, veto_reasons, rule_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
const listTidyCandidatesSQL = `SELECT id, persona_id, scope, project_id, kind, action,
// listTidyCandidatesByRunSQL 按 **run_id + status** 取候选(最新在前)。run_id
// 传空串表示「不按轮次筛」—— 那正是「全部待定应用」这条粒度(切片 TIDY-1
// 三种粒度里最粗的一档)。
const listTidyCandidatesByRunSQL = `SELECT id, persona_id, scope, project_id, kind, action,
source_ids, target_id, proposed_content, merged_content, reason, evidence,
rule_version, score_min, score_max, status, idempotency_key, created_at
rule_version, score_min, score_max, status, idempotency_key, run_id, created_at
FROM memory_tidy_candidates
WHERE status = ?
WHERE status = ? AND (? = '' OR run_id = ?)
ORDER BY created_at DESC, rowid DESC LIMIT ?`
const countTidyCandidatesByRunSQL = `SELECT COUNT(*) FROM memory_tidy_candidates
WHERE status = ? AND (? = '' OR run_id = ?)`
const selectTidyCandidateSQL = `SELECT id, persona_id, scope, project_id, kind, action,
source_ids, target_id, proposed_content, merged_content, reason, evidence,
rule_version, score_min, score_max, status, idempotency_key, run_id, created_at
FROM memory_tidy_candidates WHERE id = ?`
const selectTidyRunSQL = `SELECT id, started_at, finished_at, scanned, candidates,
applied, skipped, errors, vetoed, veto_reasons, rule_version
FROM memory_tidy_runs WHERE id = ?`
// selectLastTidyRunSQL 取最近一轮整理的开始时刻(切片 TIDY-1 的调度判据):
// 「上次 run 的时刻」是**持久事实**,不是进程内记忆 —— 后端两天重启十几次
// 正是旧实现(只看内存 lastRun)永远走不完 24h 周期的根因。
const selectLastTidyRunSQL = `SELECT started_at FROM memory_tidy_runs
ORDER BY started_at DESC, rowid DESC LIMIT 1`
// incrementTidyRunAppliedSQL 在批量应用之后把该轮的 applied 计数累加上去
// (design/23 §10 的 memory_tidy_runs.applied)。运行时那一轮写的是 0(当轮
// 绝不自动应用,§7),这里补的是「事后来应用了几条」。
const incrementTidyRunAppliedSQL = `UPDATE memory_tidy_runs SET applied = applied + ? WHERE id = ?`
const listTidyRunsSQL = `SELECT id, started_at, finished_at, scanned, candidates,
applied, skipped, errors, vetoed, veto_reasons, rule_version
FROM memory_tidy_runs ORDER BY started_at DESC, rowid DESC LIMIT ?`
@@ -141,7 +169,7 @@ func (s *Store) insertTidyCandidate(ctx context.Context, c TidyCandidate) (bool,
c.ID, c.PersonaID, string(c.Scope), c.ProjectID, string(c.Kind), string(c.Action),
string(sourceJSON), c.TargetID, c.ProposedContent, c.MergedContent, c.Reason,
string(evidenceJSON), c.RuleVersion, c.ScoreMin, c.ScoreMax, c.Status,
c.IdempotencyKey, formatTime(c.CreatedAt))
c.IdempotencyKey, c.RunID, formatTime(c.CreatedAt))
if err != nil {
return false, fmt.Errorf("memory: insert tidy candidate: %w", err)
}
@@ -174,13 +202,25 @@ func (s *Store) persistTidyRun(ctx context.Context, run TidyRun) error {
// 非正 limit 取 defaultTidyCandidateLimit)。它是读侧入口:候选正文含隐私,
// 只应由 owner-only 的读侧使用(design/23 §11)。
func (s *Store) ListTidyCandidates(ctx context.Context, status string, limit int) ([]TidyCandidate, error) {
return s.listTidyCandidates(ctx, status, "", limit)
}
// ListTidyCandidatesByRun 返回某一轮整理产出的候选(status 默认 pending)。
// runID 为空串表示**不按轮次筛** —— 即「全部待定应用」那一档(切片 TIDY-1)。
func (s *Store) ListTidyCandidatesByRun(ctx context.Context, runID, status string, limit int) ([]TidyCandidate, error) {
return s.listTidyCandidates(ctx, status, runID, limit)
}
// listTidyCandidates 是上面两个读侧入口的共用实现:runID 为空串时走「不筛轮次」
// 的 SQL 分支(`? = ” OR run_id = ?`),因此只有一个查询形状要维护。
func (s *Store) listTidyCandidates(ctx context.Context, status, runID string, limit int) ([]TidyCandidate, error) {
if strings.TrimSpace(status) == "" {
status = tidyCandidatePending
}
if limit <= 0 {
limit = defaultTidyCandidateLimit
}
rows, err := s.db.QueryContext(ctx, listTidyCandidatesSQL, status, limit)
rows, err := s.db.QueryContext(ctx, listTidyCandidatesByRunSQL, status, runID, runID, limit)
if err != nil {
return nil, fmt.Errorf("memory: list tidy candidates: %w", err)
}
@@ -200,6 +240,73 @@ func (s *Store) ListTidyCandidates(ctx context.Context, status string, limit int
return out, nil
}
// GetTidyCandidate 按 id 读一条候选(含已决定的行)。不存在时返回 ErrNotFound。
func (s *Store) GetTidyCandidate(ctx context.Context, id string) (TidyCandidate, error) {
if strings.TrimSpace(id) == "" {
return TidyCandidate{}, ErrNotFound
}
row := s.db.QueryRowContext(ctx, selectTidyCandidateSQL, id)
c, err := scanTidyCandidate(row)
if errors.Is(err, sql.ErrNoRows) {
return TidyCandidate{}, ErrNotFound
}
if err != nil {
return TidyCandidate{}, fmt.Errorf("memory: get tidy candidate: %w", err)
}
return c, nil
}
// countTidyCandidates 数某状态下(可选按轮次)的候选总数。批量应用用它并把
// 「真总数 vs 本轮应用上限」如实回报给调用方(超限绝不静默)。
func (s *Store) countTidyCandidates(ctx context.Context, status, runID string) (int, error) {
if strings.TrimSpace(status) == "" {
status = tidyCandidatePending
}
var total int
if err := s.db.QueryRowContext(ctx, countTidyCandidatesByRunSQL, status, runID, runID).
Scan(&total); err != nil {
return 0, fmt.Errorf("memory: count tidy candidates: %w", err)
}
return total, nil
}
// GetTidyRun 按 id 读一条运行留痕。不存在时返回 ErrTidyRunRecordNotFound ——
// 面板拿一个拼错的 runId 来批量应用时,必须是「查不到这一轮」而不是「这一轮
// 没有待定候选」。
func (s *Store) GetTidyRun(ctx context.Context, id string) (TidyRun, error) {
if strings.TrimSpace(id) == "" {
return TidyRun{}, ErrTidyRunRecordNotFound
}
row := s.db.QueryRowContext(ctx, selectTidyRunSQL, id)
r, err := scanTidyRun(row)
if errors.Is(err, sql.ErrNoRows) {
return TidyRun{}, ErrTidyRunRecordNotFound
}
if err != nil {
return TidyRun{}, fmt.Errorf("memory: get tidy run: %w", err)
}
return r, nil
}
// LastTidyRunStartedAt 返回最近一轮整理的开始时刻;一次都没跑过时返回零值
// 时间与 nil(**不是**错误:那是「还没有历史」这一正常状态,调度据此判断
// 是否需要补跑)。
func (s *Store) LastTidyRunStartedAt(ctx context.Context) (time.Time, error) {
var startedAt string
err := s.db.QueryRowContext(ctx, selectLastTidyRunSQL).Scan(&startedAt)
if errors.Is(err, sql.ErrNoRows) {
return time.Time{}, nil
}
if err != nil {
return time.Time{}, fmt.Errorf("memory: read last tidy run: %w", err)
}
t, err := parseTime(startedAt)
if err != nil {
return time.Time{}, err
}
return t, nil
}
// ListTidyRuns 返回整理运行留痕(最新在前,最多 limit 条)。非正 limit 取
// 一个保守的默认条数。
func (s *Store) ListTidyRuns(ctx context.Context, limit int) ([]TidyRun, error) {
@@ -214,26 +321,10 @@ func (s *Store) ListTidyRuns(ctx context.Context, limit int) ([]TidyRun, error)
out := make([]TidyRun, 0)
for rows.Next() {
var (
r TidyRun
startedAt string
finishedAt string
reasonsJSON string
)
if err := rows.Scan(&r.ID, &startedAt, &finishedAt, &r.Scanned, &r.Candidates,
&r.Applied, &r.Skipped, &r.Errors, &r.Vetoed, &reasonsJSON, &r.RuleVersion); err != nil {
r, err := scanTidyRun(rows)
if err != nil {
return nil, fmt.Errorf("memory: scan tidy run: %w", err)
}
var err error
if r.StartedAt, err = parseTime(startedAt); err != nil {
return nil, err
}
if r.FinishedAt, err = parseTime(finishedAt); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(reasonsJSON), &r.VetoReasons); err != nil {
return nil, fmt.Errorf("memory: decode tidy veto reasons: %w", err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
@@ -242,6 +333,31 @@ func (s *Store) ListTidyRuns(ctx context.Context, limit int) ([]TidyRun, error)
return out, nil
}
// scanTidyRun 读取一行运行留痕:veto_reasons 以 JSON 存储,时间戳为定宽 UTC 文本。
func scanTidyRun(sc rowScanner) (TidyRun, error) {
var (
r TidyRun
startedAt string
finishedAt string
reasonsJSON string
)
if err := sc.Scan(&r.ID, &startedAt, &finishedAt, &r.Scanned, &r.Candidates,
&r.Applied, &r.Skipped, &r.Errors, &r.Vetoed, &reasonsJSON, &r.RuleVersion); err != nil {
return TidyRun{}, err
}
var err error
if r.StartedAt, err = parseTime(startedAt); err != nil {
return TidyRun{}, err
}
if r.FinishedAt, err = parseTime(finishedAt); err != nil {
return TidyRun{}, err
}
if err := json.Unmarshal([]byte(reasonsJSON), &r.VetoReasons); err != nil {
return TidyRun{}, fmt.Errorf("memory: decode tidy veto reasons: %w", err)
}
return r, nil
}
// scanTidyCandidate 读取一行候选:source_ids 与 evidence 以 JSON 存储。
func scanTidyCandidate(sc rowScanner) (TidyCandidate, error) {
var (
@@ -255,7 +371,7 @@ func scanTidyCandidate(sc rowScanner) (TidyCandidate, error) {
if err := sc.Scan(&c.ID, &c.PersonaID, &scope, &c.ProjectID, &kind, &action,
&sourceJSON, &c.TargetID, &c.ProposedContent, &c.MergedContent, &c.Reason,
&evidenceRaw, &c.RuleVersion, &c.ScoreMin, &c.ScoreMax, &c.Status,
&c.IdempotencyKey, &createdAt); err != nil {
&c.IdempotencyKey, &c.RunID, &createdAt); err != nil {
return TidyCandidate{}, err
}
c.Scope = Scope(scope)