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)
@@ -0,0 +1,336 @@
package gateway
import (
"context"
"errors"
"net/http"
"strconv"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件承载记忆整理**应用路径**的管理面(切片 TIDY-1;design/23 §7/§8/§11)。
// 动因是主人的实测口径:整理只产候选、两天一条没应用(77 条全 pending),而现有
// `POST …/tidy/dry-run` 是**只读试算**,改不了任何东西。
//
// 端点分工(DTO 在 admin_memory_tidy_apply_dto.go,手动真跑在
// admin_memory_tidy_run.go —— 拆开只为守 AGENTS §10 的文件行数上限):
//
// GET /api/v1/admin/memories/candidates 候选列表(含 id/runId/证据)
// POST /api/v1/admin/memories/candidates/{id}/apply 按候选 id 应用(写记忆)
// POST /api/v1/admin/memories/candidates/apply?runId=&limit= 批量应用(按 run / 全部待定)
// POST /api/v1/admin/memories/candidates/{id}/revert 回滚一条已应用候选
// POST /api/v1/admin/memories/tidy/run 手动**真跑**一轮(写候选)
//
// 口径:
//
// 1. **门同族**:全部走 `writeAuth`(owner|admin),与同族的 `GET /admin/memories`、
// `POST …/tidy/dry-run`、`POST …/memories/{id}/revert` 同档 —— 候选正文含隐私,
// design/23 §11 的既有取向是「含隐私正文的读侧不给 viewer」。⚠️ §11 表格把
// candidates 写回滚标为 owner-only:那只需把 routes.go 里对应行的 `writeAuth`
// 换成 `ownerAuth`(一行一处,本文件不用改)。
// 2. **写动作落审计**:动作名 `memory.tidy.apply` / `memory.tidy.revert`(design/23
// §8 的 `memory.tidy.*` 族),who 来自请求身份;审计只带计数/动作,**不带记忆正文**。
// `POST …/tidy/run` 的审计由**领域侧**(TidyService 的 Audit 出口,design §8 的
// `memory.tidy.run`,result 里带 trigger=manual + run_id + 计数)写,本文件不重复记
// —— 一次手动点击只该有一条 memory.tidy.run。
// 3. **诚实失败**:未接线 ⇒ 500 `tidy_unavailable`;候选不存在 ⇒ 404;
// runId 查不到 ⇒ 404 `tidy_run_not_found`;配置关闭 ⇒ 409 `tidy_disabled`;
// 超时 ⇒ 503 `tidy_timeout`。**绝不**把「没做」回成一份空结果。
// 4. **不写第二套语义**:应用/回滚的真实动作全在 `memory.Store` 里(候选状态条件更新
// 是幂等闸),端点只做鉴权、取参、投影与审计。
// 5. **不加任何上限**:批量上限由领域侧(memory.DefaultTidyApplyBatchLimit 等代码
// 默认值)决定,端点只解析 `limit` 并交给领域侧钳位(`.env` 冻结,不新增键)。
// MemoryTidyRunner 是记忆整理「真跑」的注入出口(切片 TIDY-1)。组合根注入的
// 就是低峰心跳跑的那个 `*memory.TidyService`(与试算同一份配置、同一套规则)。
//
// 它与 `MemoryTidyDryRunner` 分开两个接口是**刻意**的:试算只读、真跑写候选 + 留痕,
// 两者可分别接线;未实现本接口的替身(例如只做试算的测试桩)得到的是诚实的
// 「真跑未接线」,而不是一条静默成功的假回执。
type MemoryTidyRunner interface {
// ManualRun 立即跑一轮**真跑**(写候选 + 写运行留痕 + 审计)。配置关闭时
// 返回 memory.ErrTidyDisabled。
ManualRun(ctx context.Context) (memory.TidyStats, error)
}
// tidyApplyStore 是整理候选写面的窄视图,由 `*memory.Store` 实现。未实现它的
// `Deps.Memories` 让本组端点回 500 `tidy_unavailable`(「没接线」与「没有候选」
// 必须能分辨,与同族的 ingestReviewStore 同一手法)。
type tidyApplyStore interface {
ListTidyCandidates(ctx context.Context, status string, limit int) ([]memory.TidyCandidate, error)
ApplyTidyCandidate(ctx context.Context, id string, opts memory.TidyApplyOptions) (memory.TidyApplyResult, error)
ApplyTidyCandidates(ctx context.Context, runID string, opts memory.TidyApplyOptions) (memory.TidyApplyBatch, error)
RevertTidyCandidate(ctx context.Context, id string, opts memory.TidyApplyOptions) (memory.TidyApplyResult, error)
}
const (
// tidyUnavailableCode 是「整理写面未接线」的线上错误码(与试算组的
// tidy_unavailable 同名同义:它们背后是同一件事)。
tidyUnavailableCode = "tidy_unavailable"
// tidyApplyAction 是应用动作的审计动作名(design/23 §8)。
tidyApplyAction = "memory.tidy.apply"
// tidyRevertAction 是候选回滚动作的审计动作名。它与 D3 的
// `memory.review.revert`(按**记忆 id**撤销一次软合并)**不同**:本条按**候选 id**
// 回滚,并把候选退回 pending,因此不复用。
tidyRevertAction = "memory.tidy.revert"
// tidyRunTimeout 是手动真跑的墙钟上限。真跑要写候选 + 留痕,比试算多几次写入;
// 上限存在的意义是「不让卡住的请求占着连接」,超时**如实报错**(503 tidy_timeout)。
tidyRunTimeout = 5 * time.Minute
)
// adminTidyCandidatesList 处理 GET /api/v1/admin/memories/candidates。
// 只读、不审计(与同族只读管理端点同一档)。
func (s *Server) adminTidyCandidatesList(w http.ResponseWriter, r *http.Request) {
store := s.tidyCandidates()
if store == nil {
writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线"))
return
}
// limit 复用同族读侧那一个解析器(默认 100 / 钳到 500 / 非法 400)——
// 本切片不复制第三份 limit 解析(AGENTS §10「三次复制必须抽取」,此处是第二次)。
limit, ok := parseMemoryReviewLimit(r)
if !ok {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit"))
return
}
cands, err := store.ListTidyCandidates(r.Context(), r.URL.Query().Get("status"), limit)
if err != nil {
s.log.Error("list tidy candidates", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_candidates_failed", "读取整理候选失败"))
return
}
out := AdminTidyCandidatesResponse{
Candidates: make([]AdminTidyCandidate, 0, len(cands)),
Count: len(cands),
}
for _, c := range cands {
out.Candidates = append(out.Candidates, adminTidyCandidate(c))
}
writeJSON(w, http.StatusOK, out)
}
// adminTidyCandidateApply 处理 POST /api/v1/admin/memories/candidates/{id}/apply:
// 应用**一条**候选(唯一真正改记忆的粒度之一)。三种结果都如实作答:
// 不存在 ⇒ 404;已决定/不可应用 ⇒ 200 + `status:skipped`(幂等不是错误);
// 真的改了 ⇒ 200 + `status:applied` + `affectedIds`。
func (s *Server) adminTidyCandidateApply(w http.ResponseWriter, r *http.Request) {
store := s.tidyCandidates()
if store == nil {
s.auditClaims(r, tidyApplyAction, "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线"))
return
}
id := r.PathValue("id")
if id == "" {
s.auditClaims(r, tidyApplyAction, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "candidate id is required"))
return
}
res, err := store.ApplyTidyCandidate(r.Context(), id, memory.TidyApplyOptions{Actor: s.tidyActor(r)})
if err != nil {
s.auditClaims(r, tidyApplyAction, "error")
if errors.Is(err, memory.ErrTidyCandidateNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "整理候选 %q 不存在", id))
return
}
s.log.Error("apply tidy candidate", "err", err, "candidate", id)
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_apply_failed", "应用整理候选失败"))
return
}
s.auditClaims(r, tidyApplyAction, tidyApplyAuditResult(res))
writeJSON(w, http.StatusOK, adminTidyApplyResult(res))
}
// adminTidyCandidatesApplyBatch 处理 POST /api/v1/admin/memories/candidates/apply:
// **批量**应用。`runId` 给出时只应用该轮产出的候选(按 run);省略时应用全部待定
// (「全部待定应用」这一档)。`limit` 省略 ⇒ 领域侧默认值。
//
// 逐条结果都在 `results` 里(失败隔离),超限如实回报 `truncated` + `total`。
func (s *Server) adminTidyCandidatesApplyBatch(w http.ResponseWriter, r *http.Request) {
store := s.tidyCandidates()
if store == nil {
s.auditClaims(r, tidyApplyAction, "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线"))
return
}
limit, ok := parseTidyApplyLimit(r)
if !ok {
s.auditClaims(r, tidyApplyAction, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit"))
return
}
runID := r.URL.Query().Get("runId")
batch, err := store.ApplyTidyCandidates(r.Context(), runID, memory.TidyApplyOptions{
Actor: s.tidyActor(r),
Limit: limit,
})
if err != nil {
s.auditClaims(r, tidyApplyAction, "error")
if errors.Is(err, memory.ErrTidyRunRecordNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "tidy_run_not_found", "整理运行 %q 不存在", runID))
return
}
s.log.Error("apply tidy candidates", "err", err, "run_id", runID)
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_apply_failed", "批量应用整理候选失败"))
return
}
s.auditClaims(r, tidyApplyAction, tidyBatchAuditResult(batch))
writeJSON(w, http.StatusOK, adminTidyApplyBatch(batch))
}
// adminTidyCandidateRevert 处理 POST /api/v1/admin/memories/candidates/{id}/revert:
// 回滚一条已应用候选(记忆行复原 + 候选退回 pending,行永不物理删除,design §8)。
func (s *Server) adminTidyCandidateRevert(w http.ResponseWriter, r *http.Request) {
store := s.tidyCandidates()
if store == nil {
s.auditClaims(r, tidyRevertAction, "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线"))
return
}
id := r.PathValue("id")
if id == "" {
s.auditClaims(r, tidyRevertAction, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "candidate id is required"))
return
}
res, err := store.RevertTidyCandidate(r.Context(), id, memory.TidyApplyOptions{Actor: s.tidyActor(r)})
if err != nil {
s.auditClaims(r, tidyRevertAction, "error")
if errors.Is(err, memory.ErrTidyCandidateNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "整理候选 %q 不存在", id))
return
}
s.log.Error("revert tidy candidate", "err", err, "candidate", id)
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_revert_failed", "回滚整理候选失败"))
return
}
s.auditClaims(r, tidyRevertAction, tidyApplyAuditResult(res))
writeJSON(w, http.StatusOK, adminTidyApplyResult(res))
}
// tidyCandidates 返回当前接入的整理候选写面;`Memories` 未实现该视图时为 nil。
func (s *Server) tidyCandidates() tidyApplyStore {
if s.memories == nil {
return nil
}
store, ok := s.memories.(tidyApplyStore)
if !ok {
return nil
}
return store
}
// tidyActor 返回决定者的审计标签(落 memory_tidy_candidates.decided_by)。
// 与 auditClaimsAccount 的 who 同口径,使面板「谁批的」与审计链能对上。
func (s *Server) tidyActor(r *http.Request) string {
if claims, ok := auth.ClaimsFromContext(r.Context()); ok {
return claims.Sub + ":" + string(claims.Role)
}
return "anonymous"
}
// parseTidyApplyLimit 解析批量的 limit:缺省/空 ⇒ 0(交给领域侧默认值),
// 非整数或负数 ⇒ ok=false(调用方回 400)。上限由**领域侧**钳位,端点不复制那个常量。
func parseTidyApplyLimit(r *http.Request) (int, bool) {
raw := r.URL.Query().Get("limit")
if raw == "" {
return 0, true
}
v, err := strconv.Atoi(raw)
if err != nil || v < 0 {
return 0, false
}
return v, true
}
// adminTidyCandidate 投影一条落库候选。SourceIDs 兜成非 null 数组;
// 时间为 UTC RFC3339(与同族管理 DTO 同一口径)。
func adminTidyCandidate(c memory.TidyCandidate) AdminTidyCandidate {
ids := c.SourceIDs
if ids == nil {
ids = []string{}
}
return AdminTidyCandidate{
ID: c.ID,
Action: string(c.Action),
Status: c.Status,
SourceIDs: ids,
TargetID: c.TargetID,
PersonaID: c.PersonaID,
Scope: string(c.Scope),
ProjectID: c.ProjectID,
Kind: string(c.Kind),
ProposedContent: c.ProposedContent,
MergedContent: c.MergedContent,
Reason: c.Reason,
RuleVersion: c.RuleVersion,
RunID: c.RunID,
ScoreMin: c.ScoreMin,
ScoreMax: c.ScoreMax,
CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339),
Evidence: c.Evidence,
}
}
// adminTidyApplyResult 投影一条应用/回滚结果。
func adminTidyApplyResult(res memory.TidyApplyResult) AdminTidyApplyResult {
ids := res.AffectedIDs
if ids == nil {
ids = []string{}
}
return AdminTidyApplyResult{
CandidateID: res.CandidateID,
Action: string(res.Action),
Status: string(res.Status),
Reason: res.Reason,
TargetID: res.TargetID,
AffectedIDs: ids,
}
}
// adminTidyApplyBatch 投影批量回执(results 恒为非 null 数组)。
func adminTidyApplyBatch(batch memory.TidyApplyBatch) AdminTidyApplyBatchResponse {
out := AdminTidyApplyBatchResponse{
RunID: batch.RunID,
Limit: batch.Limit,
Total: batch.Total,
Truncated: batch.Truncated,
Applied: batch.Applied,
Skipped: batch.Skipped,
Failed: batch.Failed,
Merged: batch.Merged,
Archived: batch.Archived,
Reclassified: batch.Reclassified,
Results: make([]AdminTidyApplyResult, 0, len(batch.Results)),
}
for _, res := range batch.Results {
out.Results = append(out.Results, adminTidyApplyResult(res))
}
return out
}
// tidyApplyAuditResult 渲染单条应用/回滚的审计 result。它**以 ok 开头**(与
// 同族「result=ok/error」的既有口径一致,面板按前缀就能分桶),其后才挂动作与
// 结果类别、受影响行数 —— 绝不带记忆正文或候选正文(design §8)。
func tidyApplyAuditResult(res memory.TidyApplyResult) string {
return "ok action=" + string(res.Action) +
" status=" + string(res.Status) +
" reason=" + res.Reason +
" affected=" + strconv.Itoa(len(res.AffectedIDs))
}
// tidyBatchAuditResult 渲染批量应用的审计 result(同样以 ok 开头):带真总数与
// 超限标记,因此审计能回答「这一轮到底批了多少、有没有被上限截住」。
func tidyBatchAuditResult(batch memory.TidyApplyBatch) string {
return "ok run_id=" + batch.RunID +
" limit=" + strconv.Itoa(batch.Limit) +
" total=" + strconv.Itoa(batch.Total) +
" truncated=" + strconv.FormatBool(batch.Truncated) +
" applied=" + strconv.Itoa(batch.Applied) +
" skipped=" + strconv.Itoa(batch.Skipped) +
" failed=" + strconv.Itoa(batch.Failed)
}
@@ -0,0 +1,97 @@
package gateway
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件是记忆整理应用路径的**线上契约形状**(DTO 与投影;切片 TIDY-1)。
// 端点与鉴权/审计在 admin_memory_tidy_apply.go,手动真跑在 admin_memory_tidy_run.go。
// 拆开只为守 AGENTS §10 的文件行数上限(≤500 行),不改变任何口径。
// AdminTidyCandidate 是一条落库候选的线上投影(契约里新增的读侧形状)。
// 与试算候选(AdminTidyDryRunCandidate)的区别:这里**有 id / status / runId /
// createdAt** —— 它们是「拿这条去应用/回滚」所必需的键。
type AdminTidyCandidate struct {
ID string `json:"id"`
Action string `json:"action"`
Status string `json:"status"`
SourceIDs []string `json:"sourceIds"`
TargetID string `json:"targetId,omitempty"`
PersonaID string `json:"personaId"`
Scope string `json:"scope"`
ProjectID string `json:"projectId,omitempty"`
Kind string `json:"kind,omitempty"`
ProposedContent string `json:"proposedContent,omitempty"`
MergedContent string `json:"mergedContent,omitempty"`
Reason string `json:"reason"`
RuleVersion string `json:"ruleVersion"`
RunID string `json:"runId,omitempty"`
ScoreMin float64 `json:"scoreMin"`
ScoreMax float64 `json:"scoreMax"`
CreatedAt string `json:"createdAt"`
Evidence memory.TidyEvidence `json:"evidence"`
}
// AdminTidyCandidatesResponse 是候选列表的线上结构。`candidates` 恒为非 null 数组。
type AdminTidyCandidatesResponse struct {
Candidates []AdminTidyCandidate `json:"candidates"`
Count int `json:"count"`
}
// AdminTidyApplyResult 是一条候选的应用/回滚回执(领域结构直接投影:它是
// 纯数据、无隐私之外的派生逻辑)。
type AdminTidyApplyResult struct {
CandidateID string `json:"candidateId"`
Action string `json:"action"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
TargetID string `json:"targetId,omitempty"`
AffectedIDs []string `json:"affectedIds"`
}
// AdminTidyApplyBatchResponse 是批量应用回执。`total` 是命中筛选的**真**待定候选数,
// `applied+skipped+failed` 只覆盖 `limit` 之内处理过的那些 —— 两者不相等时
// `truncated=true`(超限绝不静默)。
type AdminTidyApplyBatchResponse struct {
RunID string `json:"runId,omitempty"`
Limit int `json:"limit"`
Total int `json:"total"`
Truncated bool `json:"truncated"`
Applied int `json:"applied"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Merged int `json:"merged"`
Archived int `json:"archived"`
Reclassified int `json:"reclassified"`
Results []AdminTidyApplyResult `json:"results"`
}
// AdminTidyRunResponse 是手动**真跑**一轮的回执(契约里新增的写面形状):
// 「新增 / 合并 / 软删 各多少 + 耗时」就是 `candidatesCreated` /
// `candidatesByAction{merge|archive}` / `durationMs`。
//
// 注意它是**建议侧**计数:真跑只产候选、写运行留痕,**不自动应用**(design §7 的
// 人工在环)。要落地改动,接着调 candidates 的 apply / 批量 apply。
type AdminTidyRunResponse struct {
RunID string `json:"runId"`
Trigger string `json:"trigger"`
RuleVersion string `json:"ruleVersion"`
DurationMs int64 `json:"durationMs"`
Partitions int `json:"partitions"`
Scanned int `json:"scanned"`
Compared int `json:"compared"`
Pairs int `json:"pairs"`
Vetoed int `json:"vetoed"`
// Errors 是本轮的失败计数(分区失败 + 候选写入失败):非 0 时面板要如实显示。
Errors int `json:"errors"`
// Truncated 列出被护栏(扫描/比对/候选上限或时间预算)截断的分区标签。
Truncated []string `json:"truncated,omitempty"`
// VetoReasons 是本轮否决原因分布。
VetoReasons map[string]int `json:"vetoReasons"`
// CandidatesCreated 是本次**新建**的候选数;CandidatesDuplicate 是被幂等键
// 挡下(同一批数据重复跑)的数量。
CandidatesCreated int `json:"candidatesCreated"`
CandidatesDuplicate int `json:"candidatesDuplicate"`
// CandidatesByAction 是新建候选按动作的分布(merge / link / archive / reclassify)。
CandidatesByAction map[string]int `json:"candidatesByAction"`
}
@@ -0,0 +1,470 @@
package gateway
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件锁定记忆整理**应用路径 + 手动真跑**的管理面(切片 TIDY-1)。五条判据:
//
// ① 候选列表:投影带 id/status/runId/createdAt(应用所需的键)、`candidates`
// 恒为非 null 数组、limit 默认/钳位/非法值三态;
// ② 单条应用:applied/skipped 都 200(幂等不是错误)、候选不存在 ⇒ 404、
// 存储报错 ⇒ 500,**成功与失败都落审计** `memory.tidy.apply`;
// ③ 批量应用:runId/limit 如实透传、超限回执带 truncated+total、未知 runId ⇒
// 404 `tidy_run_not_found`;
// ④ 回滚:200 + 落审计 `memory.tidy.revert`;
// ⑤ 手动真跑:回执含「新增/合并/软删 各多少 + 耗时」;未接线真跑 ⇒ 500
// `tidy_run_unavailable`(只读试算の替身不得谎报跑过);关闭 ⇒ 409 `tidy_disabled`;
// ⑥ 门:匿名 401 / viewer 403 / owner|admin 200。
// fakeTidyApply 是整理候选写面的替身:既满足 Deps.Memories(MemoryStore),
// 又实现管理面要的窄视图(tidyApplyStore)。
type fakeTidyApply struct {
cands []memory.TidyCandidate
listErr error
listStatus string
listLimit int
applyResult memory.TidyApplyResult
applyErr error
applyID string
applyOpts memory.TidyApplyOptions
batchResult memory.TidyApplyBatch
batchErr error
batchRunID string
batchOpts memory.TidyApplyOptions
revertResult memory.TidyApplyResult
revertErr error
revertID string
revertOpts memory.TidyApplyOptions
}
func (f *fakeTidyApply) List(context.Context) ([]memory.MemoryItem, error) {
return []memory.MemoryItem{{ID: "m1", Scope: memory.ScopeUser, Content: "一条记忆"}}, nil
}
func (f *fakeTidyApply) ListTidyCandidates(_ context.Context, status string, limit int) ([]memory.TidyCandidate, error) {
f.listStatus, f.listLimit = status, limit
return f.cands, f.listErr
}
func (f *fakeTidyApply) ApplyTidyCandidate(_ context.Context, id string, opts memory.TidyApplyOptions) (memory.TidyApplyResult, error) {
f.applyID, f.applyOpts = id, opts
return f.applyResult, f.applyErr
}
func (f *fakeTidyApply) ApplyTidyCandidates(_ context.Context, runID string, opts memory.TidyApplyOptions) (memory.TidyApplyBatch, error) {
f.batchRunID, f.batchOpts = runID, opts
return f.batchResult, f.batchErr
}
func (f *fakeTidyApply) RevertTidyCandidate(_ context.Context, id string, opts memory.TidyApplyOptions) (memory.TidyApplyResult, error) {
f.revertID, f.revertOpts = id, opts
return f.revertResult, f.revertErr
}
// fakeTidyRunner 同时实现试算(MemoryTidyDryRunner)与真跑(MemoryTidyRunner),
// 使「真跑入口」这条断言能在不碰真实 store 的情况下验投影与错误映射。
type fakeTidyRunner struct {
stats memory.TidyStats
err error
calls int
dryRuns int
}
func (f *fakeTidyRunner) DryRun(context.Context) (memory.TidyDryRun, error) {
f.dryRuns++
return memory.TidyDryRun{}, nil
}
func (f *fakeTidyRunner) StartDryRun(time.Duration) (memory.TidyDryRunHandle, error) {
return memory.TidyDryRunHandle{}, nil
}
func (f *fakeTidyRunner) DryRunResult(string) (memory.TidyAsyncDryRun, error) {
return memory.TidyAsyncDryRun{}, memory.ErrTidyRunNotFound
}
func (f *fakeTidyRunner) ManualRun(context.Context) (memory.TidyStats, error) {
f.calls++
return f.stats, f.err
}
// dryRunOnlyRunner 是**只做试算**的替身:真跑入口必须如实回「未接线」,
// 不许因为试算可用就假装跑过。
type dryRunOnlyRunner struct{}
func (dryRunOnlyRunner) DryRun(context.Context) (memory.TidyDryRun, error) {
return memory.TidyDryRun{}, nil
}
func (dryRunOnlyRunner) StartDryRun(time.Duration) (memory.TidyDryRunHandle, error) {
return memory.TidyDryRunHandle{}, nil
}
func (dryRunOnlyRunner) DryRunResult(string) (memory.TidyAsyncDryRun, error) {
return memory.TidyAsyncDryRun{}, memory.ErrTidyRunNotFound
}
// fixtureTidyApplyCandidate 是一条落库候选(merge:把 mem-a 软并入 mem-b)。
func fixtureTidyApplyCandidate() memory.TidyCandidate {
return memory.TidyCandidate{
ID: "cand-1",
PersonaID: "cyrene",
Scope: memory.ScopePersona,
Kind: memory.KindRelationship,
Action: memory.TidyMerge,
SourceIDs: []string{"mem-a"},
TargetID: "mem-b",
RunID: "run-1",
ProposedContent: "主人常在深夜与助手聊天,有熬夜习惯",
MergedContent: "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚",
Reason: "fact_subset",
RuleVersion: memory.TidyRuleVersion,
ScoreMin: 0.95,
ScoreMax: 0.95,
Status: "pending",
CreatedAt: time.Date(2026, 10, 7, 23, 5, 0, 0, time.UTC),
Evidence: memory.TidyEvidence{
Similarity: 0.95, RecallMode: "embedding", Containment: "a_subset_b",
ContentA: "主人常在深夜与助手聊天,有熬夜习惯",
ContentB: "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚",
},
}
}
// TestAdminTidyCandidatesListProjection 钉住①。
func TestAdminTidyCandidatesListProjection(t *testing.T) {
t.Parallel()
fake := &fakeTidyApply{cands: []memory.TidyCandidate{fixtureTidyApplyCandidate()}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/candidates", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if fake.listStatus != "" || fake.listLimit != memoryReviewDefaultLimit {
t.Fatalf("透传 status/limit = %q/%d, want \"\"/%d", fake.listStatus, fake.listLimit, memoryReviewDefaultLimit)
}
var payload struct {
Candidates []map[string]json.RawMessage `json:"candidates"`
Count int `json:"count"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &payload); err != nil {
t.Fatalf("解析 data: %v (body=%s)", err, body)
}
if payload.Count != 1 || len(payload.Candidates) != 1 {
t.Fatalf("count/candidates = %d/%d, want 1/1", payload.Count, len(payload.Candidates))
}
want := map[string]string{
"id": `"cand-1"`,
"action": `"merge"`,
"status": `"pending"`,
"targetId": `"mem-b"`,
"personaId": `"cyrene"`,
"scope": `"persona"`,
"reason": `"fact_subset"`,
"ruleVersion": `"` + memory.TidyRuleVersion + `"`,
"runId": `"run-1"`,
"createdAt": `"2026-10-07T23:05:00Z"`,
"scoreMin": `0.95`,
"mergedContent": `"主人常在深夜与助手聊天,有熬夜习惯,作息偏晚"`,
}
for key, val := range want {
raw, ok := payload.Candidates[0][key]
if !ok {
t.Fatalf("缺键 %q(row=%v)", key, payload.Candidates[0])
}
if string(raw) != val {
t.Fatalf("%s = %s, want %s", key, raw, val)
}
}
if string(payload.Candidates[0]["sourceIds"]) != `["mem-a"]` {
t.Fatalf("sourceIds = %s, want [\"mem-a\"]", payload.Candidates[0]["sourceIds"])
}
// 空候选 ⇒ 200 + 非 null 空数组(与「未接线」必须可区分)。
empty := &fakeTidyApply{}
_, ts2, jwt2 := buildEnv(t, cfgDefault(), Deps{Memories: empty})
resp, body = doReq(t, http.MethodGet, ts2.URL+"/api/v1/admin/memories/candidates", nil, signAny(t, jwt2, "acct", auth.RoleOwner))
if resp.StatusCode != http.StatusOK {
t.Fatalf("空候选 status = %d, want 200", resp.StatusCode)
}
var emptyPayload struct {
Candidates []map[string]json.RawMessage `json:"candidates"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &emptyPayload); err != nil {
t.Fatalf("解析空 data: %v", err)
}
if emptyPayload.Candidates == nil {
t.Fatal("空候选必须是 [],不能是 null")
}
// 非法 limit ⇒ 400。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/candidates?limit=abc", nil, tok)
if resp.StatusCode != http.StatusBadRequest || mediaErrorCode(t, body) != "bad_request" {
t.Fatalf("limit=abc ⇒ %d/%s, want 400/bad_request", resp.StatusCode, body)
}
// 未接线(存储没有候选视图)⇒ 500 tidy_unavailable,绝不回空数组。
_, bare, bareJWT := buildEnv(t, cfgDefault(), Deps{Memories: listOnlyMemories{}})
resp, body = doReq(t, http.MethodGet, bare.URL+"/api/v1/admin/memories/candidates", nil, signAny(t, bareJWT, "acct", auth.RoleOwner))
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != tidyUnavailableCode {
t.Fatalf("未接线列表 ⇒ %d/%s, want 500/%s", resp.StatusCode, body, tidyUnavailableCode)
}
}
// TestAdminTidyCandidateApplyOutcomesAndAudit 钉住②。
func TestAdminTidyCandidateApplyOutcomesAndAudit(t *testing.T) {
t.Parallel()
audit := &recordingAudit{}
fake := &fakeTidyApply{applyResult: memory.TidyApplyResult{
CandidateID: "cand-1", Action: memory.TidyMerge, Status: memory.TidyApplyApplied,
TargetID: "mem-b", AffectedIDs: []string{"mem-a"},
}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake, AuditStore: audit})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/cand-1/apply", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("apply status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if fake.applyID != "cand-1" || fake.applyOpts.Actor != "acct:owner" {
t.Fatalf("透传 id/Actor = %q/%q, want cand-1/acct:owner", fake.applyID, fake.applyOpts.Actor)
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(decodeBody(t, body)["data"], &payload); err != nil {
t.Fatalf("解析 data: %v", err)
}
if string(payload["status"]) != `"applied"` || string(payload["affectedIds"]) != `["mem-a"]` {
t.Fatalf("回执 = %s, want status=applied affectedIds=[mem-a]", decodeBody(t, body)["data"])
}
if len(audit.entries) != 1 || audit.entries[0].What != tidyApplyAction || !strings.HasPrefix(audit.entries[0].Result, "ok") {
t.Fatalf("审计 = %+v, want 一条 %s/ok", audit.entries, tidyApplyAction)
}
// 审计不得带记忆正文。
if strings.Contains(audit.entries[0].Result, "熬夜") || strings.Contains(audit.entries[0].Result, "mem-a") {
t.Fatalf("审计 result 带了正文/行 id:%q", audit.entries[0].Result)
}
// skipped(幂等命中)也是 200:它不是错误。
fake.applyResult = memory.TidyApplyResult{
CandidateID: "cand-1", Action: memory.TidyMerge, Status: memory.TidyApplySkipped,
Reason: "already_decided", AffectedIDs: []string{},
}
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/cand-1/apply", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("skipped ⇒ %d, want 200 (body=%s)", resp.StatusCode, body)
}
if !strings.Contains(string(decodeBody(t, body)["data"]), `"reason":"already_decided"`) {
t.Fatalf("skipped 回执缺 reason:%s", decodeBody(t, body)["data"])
}
// 候选不存在 ⇒ 404 not_found,且**失败也落审计**。
fake.applyResult = memory.TidyApplyResult{}
fake.applyErr = memory.ErrTidyCandidateNotFound
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/nope/apply", nil, tok)
if resp.StatusCode != http.StatusNotFound || mediaErrorCode(t, body) != "not_found" {
t.Fatalf("不存在的候选 ⇒ %d/%s, want 404/not_found", resp.StatusCode, body)
}
if len(audit.entries) != 3 || audit.entries[2].Result != "error" {
t.Fatalf("失败后的审计 = %+v, want 一条 error", audit.entries)
}
// 存储报错 ⇒ 500(不谎报成功)。
fake.applyErr = errors.New("db exploded")
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/cand-1/apply", nil, tok)
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != "tidy_apply_failed" {
t.Fatalf("存储报错 ⇒ %d/%s, want 500/tidy_apply_failed", resp.StatusCode, body)
}
}
// TestAdminTidyCandidatesApplyBatch 钉住③。
func TestAdminTidyCandidatesApplyBatch(t *testing.T) {
t.Parallel()
audit := &recordingAudit{}
fake := &fakeTidyApply{batchResult: memory.TidyApplyBatch{
RunID: "run-1", Limit: 2, Total: 5, Truncated: true,
Applied: 2, Archived: 2,
Results: []memory.TidyApplyResult{{
CandidateID: "c1", Action: memory.TidyArchive, Status: memory.TidyApplyApplied,
AffectedIDs: []string{"m1"},
}},
}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake, AuditStore: audit})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost,
ts.URL+"/api/v1/admin/memories/candidates/apply?runId=run-1&limit=2", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("batch status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if fake.batchRunID != "run-1" || fake.batchOpts.Limit != 2 || fake.batchOpts.Actor != "acct:owner" {
t.Fatalf("透传 runId/limit/Actor = %q/%d/%q", fake.batchRunID, fake.batchOpts.Limit, fake.batchOpts.Actor)
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(decodeBody(t, body)["data"], &payload); err != nil {
t.Fatalf("解析 data: %v", err)
}
for key, want := range map[string]string{
"runId": `"run-1"`, "limit": `2`, "total": `5`, "truncated": `true`,
"applied": `2`, "skipped": `0`, "archived": `2`, "merged": `0`,
} {
if string(payload[key]) != want {
t.Fatalf("%s = %s, want %s", key, payload[key], want)
}
}
if len(audit.entries) != 1 || audit.entries[0].What != tidyApplyAction || !strings.HasPrefix(audit.entries[0].Result, "ok") {
t.Fatalf("批量审计 = %+v, want 一条 %s/ok", audit.entries, tidyApplyAction)
}
// 省略 runId ⇒ 「全部待定应用」这一档(runId 透传空串)。
if _, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/apply", nil, tok); fake.batchRunID != "" {
t.Fatalf("省略 runId 时透传 = %q, want 空串(全部待定)(body=%s)", fake.batchRunID, body)
}
// 未知 runId ⇒ 404 tidy_run_not_found(不是「这一轮没有候选」)。
fake.batchErr = memory.ErrTidyRunRecordNotFound
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/apply?runId=nope", nil, tok)
fake.batchErr = nil
if resp.StatusCode != http.StatusNotFound || mediaErrorCode(t, body) != "tidy_run_not_found" {
t.Fatalf("未知 runId ⇒ %d/%s, want 404/tidy_run_not_found", resp.StatusCode, body)
}
// 非法 limit ⇒ 400。
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/apply?limit=-3", nil, tok)
if resp.StatusCode != http.StatusBadRequest || mediaErrorCode(t, body) != "bad_request" {
t.Fatalf("limit=-3 ⇒ %d/%s, want 400/bad_request", resp.StatusCode, body)
}
}
// TestAdminTidyCandidateRevertOutcomes 钉住④。
func TestAdminTidyCandidateRevertOutcomes(t *testing.T) {
t.Parallel()
audit := &recordingAudit{}
fake := &fakeTidyApply{revertResult: memory.TidyApplyResult{
CandidateID: "cand-1", Action: memory.TidyMerge, Status: memory.TidyApplyApplied,
AffectedIDs: []string{"mem-a"},
}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake, AuditStore: audit})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/cand-1/revert", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("revert status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if fake.revertID != "cand-1" {
t.Fatalf("透传 id = %q, want cand-1", fake.revertID)
}
if len(audit.entries) != 1 || audit.entries[0].What != tidyRevertAction || !strings.HasPrefix(audit.entries[0].Result, "ok") {
t.Fatalf("审计 = %+v, want 一条 %s/ok", audit.entries, tidyRevertAction)
}
// 未接线 ⇒ 500 tidy_unavailable。
_, bare, bareJWT := buildEnv(t, cfgDefault(), Deps{Memories: listOnlyMemories{}})
resp, body = doReq(t, http.MethodPost, bare.URL+"/api/v1/admin/memories/candidates/cand-1/revert", nil,
signAny(t, bareJWT, "acct", auth.RoleOwner))
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != tidyUnavailableCode {
t.Fatalf("未接线 revert ⇒ %d/%s, want 500/%s", resp.StatusCode, body, tidyUnavailableCode)
}
}
// TestAdminMemoryTidyRunReceipt 钉住⑤:真跑回执含「新增/合并/软删 + 耗时」。
func TestAdminMemoryTidyRunReceipt(t *testing.T) {
t.Parallel()
runner := &fakeTidyRunner{stats: memory.TidyStats{
RunID: "run-9", Partitions: 4, Scanned: 1191, Compared: 1191, Pairs: 77,
Candidates: 77, Duplicates: 3, Vetoed: 12, DurationMS: 89900,
VetoReasons: map[string]int{"temporal": 7, "scope": 5},
ByAction: map[string]int{"merge": 30, "link": 40, "archive": 5, "reclassify": 2},
Truncated: []string{"cyrene/persona/(compare)"},
}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{MemoryTidy: runner})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/run", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("tidy run status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if runner.calls != 1 {
t.Fatalf("真跑调用次数 = %d, want 1", runner.calls)
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(decodeBody(t, body)["data"], &payload); err != nil {
t.Fatalf("解析 data: %v", err)
}
for key, want := range map[string]string{
"runId": `"run-9"`, "trigger": `"manual"`, "durationMs": `89900`,
"scanned": `1191`, "candidatesCreated": `77`, "candidatesDuplicate": `3`,
"errors": `0`, "partitions": `4`,
} {
if string(payload[key]) != want {
t.Fatalf("%s = %s, want %s", key, payload[key], want)
}
}
if got := string(payload["candidatesByAction"]); got != `{"archive":5,"link":40,"merge":30,"reclassify":2}` {
t.Fatalf("candidatesByAction = %s", got)
}
if got := string(payload["truncated"]); got != `["cyrene/persona/(compare)"]` {
t.Fatalf("truncated = %s(截断必须如实上线)", got)
}
// 关闭 ⇒ 409 tidy_disabled(显式选择,不是故障)。
runner.err = memory.ErrTidyDisabled
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/run", nil, tok)
runner.err = nil
if resp.StatusCode != http.StatusConflict || mediaErrorCode(t, body) != "tidy_disabled" {
t.Fatalf("关闭时 ⇒ %d/%s, want 409/tidy_disabled", resp.StatusCode, body)
}
// 只做试算的替身(未实现真跑)⇒ 500 tidy_run_unavailable,绝不假装跑过。
_, only, onlyJWT := buildEnv(t, cfgDefault(), Deps{MemoryTidy: dryRunOnlyRunner{}})
resp, body = doReq(t, http.MethodPost, only.URL+"/api/v1/admin/memories/tidy/run", nil,
signAny(t, onlyJWT, "acct", auth.RoleOwner))
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != "tidy_run_unavailable" {
t.Fatalf("真跑未接线 ⇒ %d/%s, want 500/tidy_run_unavailable", resp.StatusCode, body)
}
// 整理服务完全未接线 ⇒ 500 tidy_unavailable。
_, none, noneJWT := buildEnv(t, cfgDefault(), Deps{})
resp, body = doReq(t, http.MethodPost, none.URL+"/api/v1/admin/memories/tidy/run", nil,
signAny(t, noneJWT, "acct", auth.RoleOwner))
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != tidyUnavailableCode {
t.Fatalf("未接线 ⇒ %d/%s, want 500/%s", resp.StatusCode, body, tidyUnavailableCode)
}
}
// TestAdminTidyApplyRBAC 钉住⑥:门与同族管理面一致(匿名 401 / viewer 403 /
// owner|admin 200)。
func TestAdminTidyApplyRBAC(t *testing.T) {
t.Parallel()
fake := &fakeTidyApply{}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake})
paths := []struct{ method, path string }{
{http.MethodGet, "/api/v1/admin/memories/candidates"},
{http.MethodPost, "/api/v1/admin/memories/candidates/cand-1/apply"},
{http.MethodPost, "/api/v1/admin/memories/candidates/apply"},
{http.MethodPost, "/api/v1/admin/memories/candidates/cand-1/revert"},
}
for _, p := range paths {
if resp, _ := doReq(t, p.method, ts.URL+p.path, nil, ""); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("%s %s 匿名 status = %d, want 401", p.method, p.path, resp.StatusCode)
}
viewer := signAny(t, jwt, "acct", auth.RoleViewer)
if resp, _ := doReq(t, p.method, ts.URL+p.path, nil, viewer); resp.StatusCode != http.StatusForbidden {
t.Fatalf("%s %s viewer status = %d, want 403", p.method, p.path, resp.StatusCode)
}
admin := signAny(t, jwt, "acct", auth.RoleAdmin)
if resp, body := doReq(t, p.method, ts.URL+p.path, nil, admin); resp.StatusCode != http.StatusOK {
t.Fatalf("%s %s admin status = %d, want 200 (body=%s)", p.method, p.path, resp.StatusCode, body)
}
}
}
@@ -0,0 +1,188 @@
package gateway
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// TestAdminTidyApplyEndToEndWithRealStore 是**该切片的端到端验收**:真
// *memory.Store + 真 memory.TidyService + 真 HTTP 入口,走完整条主人的使用路径 ——
//
// 手动真跑 → 候选落库 → 列候选 → 单条应用(软合并)→ 再按 run 批量应用 → 回滚。
//
// 断言全部落在**真库**上(行数不变、状态列、merge_count),不是替身转述。
func TestAdminTidyApplyEndToEndWithRealStore(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", nil)
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
store, err := memory.NewStore(ctx, conn, nil)
if err != nil {
t.Fatalf("memory.NewStore: %v", err)
}
// 两条同分区记忆:短句是长句的事实子集 ⇒ 规则判 merge(design/23 §12.1 P1)。
const (
shortText = "主人常在深夜与助手聊天,有熬夜习惯"
longText = "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚"
)
short, err := store.Add(ctx, memory.MemoryItem{
PersonaID: "cyrene", Scope: memory.ScopePersona, Kind: memory.KindRelationship,
Content: shortText, Confidence: 0.9,
})
if err != nil {
t.Fatalf("seed short: %v", err)
}
long, err := store.Add(ctx, memory.MemoryItem{
PersonaID: "cyrene", Scope: memory.ScopePersona, Kind: memory.KindRelationship,
Content: longText, Confidence: 0.9,
})
if err != nil {
t.Fatalf("seed long: %v", err)
}
// 无 embedder ⇒ 词项余弦召回(生产环境不可用时的降级路径,正好一并走一遍)。
svc, err := memory.NewTidyService(memory.TidyConfig{
Enabled: true, RecallThreshold: 0.8, ScanLimit: 200,
}, memory.TidyDeps{Store: store})
if err != nil {
t.Fatalf("NewTidyService: %v", err)
}
_, ts, jwt := buildEnv(t, nil, Deps{Memories: store, MemoryTidy: svc})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// ① 手动真跑:写候选 + 留痕,并回回执(新增/合并/软删/耗时)。
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/run", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("tidy/run status = %d (body=%s)", resp.StatusCode, body)
}
var run AdminTidyRunResponse
if err := json.Unmarshal(decodeBody(t, body)["data"], &run); err != nil {
t.Fatalf("解析真跑回执: %v (%s)", err, body)
}
if run.RunID == "" || run.Trigger != "manual" {
t.Fatalf("真跑回执 = %+v, want 有 runId 且 trigger=manual", run)
}
if run.CandidatesByAction["merge"] != 1 {
t.Fatalf("merge 候选 = %d, want 1(回执=%+v)", run.CandidatesByAction["merge"], run)
}
// ② 列候选:拿 id(apply 的唯一钥匙)。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/candidates", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("candidates status = %d (body=%s)", resp.StatusCode, body)
}
var list AdminTidyCandidatesResponse
if err := json.Unmarshal(decodeBody(t, body)["data"], &list); err != nil {
t.Fatalf("解析候选列表: %v", err)
}
var mergeID string
for _, c := range list.Candidates {
if c.Action == "merge" && c.TargetID == long.ID && c.RunID == run.RunID {
mergeID = c.ID
break
}
}
if mergeID == "" {
t.Fatalf("列表里没有指向超集的 merge 候选:%s", body)
}
// ③ 单条应用:软合并(被吸收行 merged,存活行 merge_count+1,content 不变,行数不变)。
rowsBefore := countRealMemoryRows(t, conn)
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/"+mergeID+"/apply", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("apply status = %d (body=%s)", resp.StatusCode, body)
}
var applied AdminTidyApplyResult
if err := json.Unmarshal(decodeBody(t, body)["data"], &applied); err != nil {
t.Fatalf("解析应用回执: %v", err)
}
if applied.Status != string(memory.TidyApplyApplied) || applied.AffectedIDs[0] != short.ID {
t.Fatalf("应用回执 = %+v, want applied/affected=%s", applied, short.ID)
}
assertRealMemoryRow(t, conn, short.ID, "merged", long.ID, shortText)
if got := countRealMemoryRows(t, conn); got != rowsBefore {
t.Fatalf("软合并改变了行数:%d → %d", rowsBefore, got)
}
// 重复应用 ⇒ 幂等(skipped),且不产生第二次副作用。
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/"+mergeID+"/apply", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("重复应用 status = %d (body=%s)", resp.StatusCode, body)
}
var again AdminTidyApplyResult
if err := json.Unmarshal(decodeBody(t, body)["data"], &again); err != nil {
t.Fatalf("解析重复应用回执: %v", err)
}
if again.Status != "skipped" || again.Reason != "already_decided" {
t.Fatalf("重复应用 = %+v, want skipped/already_decided", again)
}
assertRealMergeCount(t, conn, long.ID, 2)
// ④ 按 run 批量应用:已应用的候选被幂等挡下(skipped),不产生第二次副作用。
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/apply?runId="+run.RunID, nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("批量应用 status = %d (body=%s)", resp.StatusCode, body)
}
assertRealMergeCount(t, conn, long.ID, 2)
// ⑤ 回滚:字段复原、行数不变。
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/"+mergeID+"/revert", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("revert status = %d (body=%s)", resp.StatusCode, body)
}
assertRealMemoryRow(t, conn, short.ID, "active", "", shortText)
assertRealMergeCount(t, conn, long.ID, 1)
if got := countRealMemoryRows(t, conn); got != rowsBefore {
t.Fatalf("回滚改变了行数:%d → %d", rowsBefore, got)
}
}
// countRealMemoryRows 数真库里的 memory_items 行数。
func countRealMemoryRows(t *testing.T, conn *sql.DB) int {
t.Helper()
var n int
if err := conn.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM memory_items`).Scan(&n); err != nil {
t.Fatalf("数行数: %v", err)
}
return n
}
// assertRealMemoryRow 断言一行记忆的 status/merged_into/content。
func assertRealMemoryRow(t *testing.T, conn *sql.DB, id, wantStatus, wantMergedInto, wantContent string) {
t.Helper()
var status, mergedInto, content string
if err := conn.QueryRowContext(context.Background(),
`SELECT status, merged_into, content FROM memory_items WHERE id = ?`, id).
Scan(&status, &mergedInto, &content); err != nil {
t.Fatalf("读记忆行 %s: %v", id, err)
}
if status != wantStatus || mergedInto != wantMergedInto {
t.Fatalf("行 %s = status=%q merged_into=%q, want %q/%q", id, status, mergedInto, wantStatus, wantMergedInto)
}
if content != wantContent {
t.Fatalf("行 %s 的 content 被改了:%q → %q", id, wantContent, content)
}
}
// assertRealMergeCount 断言存活行的 merge_count。
func assertRealMergeCount(t *testing.T, conn *sql.DB, id string, want int) {
t.Helper()
var got int
if err := conn.QueryRowContext(context.Background(),
`SELECT merge_count FROM memory_items WHERE id = ?`, id).Scan(&got); err != nil {
t.Fatalf("读记忆行 %s: %v", id, err)
}
if got != want {
t.Fatalf("行 %s merge_count = %d, want %d", id, got, want)
}
}
@@ -0,0 +1,100 @@
package gateway
import (
"context"
"errors"
"net/http"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件是记忆整理**手动真跑**入口(切片 TIDY-1):
// POST /api/v1/admin/memories/tidy/run —— 立刻按同一套规则跑一轮,写候选 + 写运行
// 留痕,并把「新增/合并/软删各多少 + 耗时」如实回执。审计由领域侧 TidyService 记
// `memory.tidy.run`(一次点击一条,本文件不重复)。
// adminMemoryTidyRun 处理 POST /api/v1/admin/memories/tidy/run:手动**真跑**一轮
// (与心跳、试算走同一套规则与配置;写候选 + 运行留痕)。它是「panic button」:
// 主人不必等到 23:00 就能让整理真的产出一批待定候选,再决定应用。
//
// 审计不在这里写 —— 领域侧 TidyService 已按 design §8 记 `memory.tidy.run`
// (result 里带 trigger=manual / run_id / 计数)。一次点击一条审计。
func (s *Server) adminMemoryTidyRun(w http.ResponseWriter, r *http.Request) {
if s.memoryTidy == nil {
writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理服务未接线"))
return
}
runner, ok := s.memoryTidy.(MemoryTidyRunner)
if !ok {
writeError(w, &apperr.AppError{
Code: "tidy_run_unavailable",
Message: "记忆整理真跑入口未接线(只有只读试算可用)",
HTTPCode: http.StatusInternalServerError,
})
return
}
ctx, cancel := context.WithTimeout(r.Context(), tidyRunTimeout)
defer cancel()
stats, err := runner.ManualRun(ctx)
if err != nil {
writeError(w, tidyRunAppError(err))
return
}
s.log.Info("memory tidy manual run finished",
"run_id", stats.RunID, "scanned", stats.Scanned, "candidates", stats.Candidates,
"duplicates", stats.Duplicates, "duration_ms", stats.DurationMS)
writeJSON(w, http.StatusOK, adminTidyRunResponse(stats))
}
// tidyRunAppError 把「真跑」失败映射成诚实的错误码。映射表与试算那张
// (tidyDryRunAppError)**分开**:试算的 500/409 文案讲的是「没产出候选」,
// 真跑讲的是「没跑成」—— 同一类别、不同事实,混用会让排障读到错的那句。
func tidyRunAppError(err error) *apperr.AppError {
switch {
case errors.Is(err, memory.ErrTidyDisabled):
return &apperr.AppError{
Code: "tidy_disabled",
Message: "记忆整理已被配置关闭(MEMORY_TIDY_ENABLED=false)",
HTTPCode: http.StatusConflict,
}
case errors.Is(err, context.DeadlineExceeded):
return &apperr.AppError{
Code: "tidy_timeout",
Message: "记忆整理真跑超时(本轮未完整跑完,已跑部分仍写入了留痕)",
HTTPCode: http.StatusServiceUnavailable,
}
default:
return apperr.Wrap(apperr.ErrInternal, "tidy_run_failed", "记忆整理真跑失败")
}
}
// adminTidyRunResponse 投影真跑回执。
func adminTidyRunResponse(stats memory.TidyStats) AdminTidyRunResponse {
byAction := stats.ByAction
if byAction == nil {
byAction = map[string]int{}
}
reasons := stats.VetoReasons
if reasons == nil {
reasons = map[string]int{}
}
return AdminTidyRunResponse{
RunID: stats.RunID,
Trigger: "manual",
RuleVersion: memory.TidyRuleVersion,
DurationMs: stats.DurationMS,
Partitions: stats.Partitions,
Scanned: stats.Scanned,
Compared: stats.Compared,
Pairs: stats.Pairs,
Vetoed: stats.Vetoed,
Errors: stats.Errors,
Truncated: stats.Truncated,
VetoReasons: reasons,
CandidatesCreated: stats.Candidates,
CandidatesDuplicate: stats.Duplicates,
CandidatesByAction: byAction,
}
}
+12
View File
@@ -243,6 +243,18 @@ func (s *Server) registerAdminRoutes(
// 它是**写**,因此落审计(动作名 memory.review.revert,见 admin_memory_review.go)。
mux.Handle("GET /api/v1/admin/memories/reviews", writeAuth(http.HandlerFunc(s.adminMemoryReviews)))
mux.Handle("POST /api/v1/admin/memories/{id}/revert", writeAuth(http.HandlerFunc(s.adminMemoryRevert)))
// 记忆整理的**应用路径 + 手动真跑**(切片 TIDY-1,design/23 §7/§8/§11;见
// admin_memory_tidy_apply.go)。五条端点补齐「整理真的能用」:此前只有
// `POST …/tidy/dry-run`(只读试算),候选落库后**没有任何执行面**,于是 77 条
// 全 pending、两天一条没应用。门与同族的 `GET /admin/memories` /
// `POST …/memories/{id}/revert` 同档(writeAuth = owner|admin):候选正文含隐私。
// 写动作落审计 `memory.tidy.apply` / `memory.tidy.revert`;`…/tidy/run` 的审计
// 由领域侧 TidyService 记 `memory.tidy.run`(一次点击一条,不重复)。
mux.Handle("GET /api/v1/admin/memories/candidates", writeAuth(http.HandlerFunc(s.adminTidyCandidatesList)))
mux.Handle("POST /api/v1/admin/memories/candidates/apply", writeAuth(http.HandlerFunc(s.adminTidyCandidatesApplyBatch)))
mux.Handle("POST /api/v1/admin/memories/candidates/{id}/apply", writeAuth(http.HandlerFunc(s.adminTidyCandidateApply)))
mux.Handle("POST /api/v1/admin/memories/candidates/{id}/revert", writeAuth(http.HandlerFunc(s.adminTidyCandidateRevert)))
mux.Handle("POST /api/v1/admin/memories/tidy/run", writeAuth(http.HandlerFunc(s.adminMemoryTidyRun)))
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))
mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession)))
mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag)))