From 454ddb0b702472fea75a80c20aa5839149041f39 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 22:39:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(memory):=20=E8=AE=B0=E5=BF=86=E6=95=B4?= =?UTF-8?q?=E7=90=86**=E5=BA=94=E7=94=A8=E8=B7=AF=E5=BE=84=20+=20=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E4=BF=AE=E5=A4=8D=20+=20=E6=89=8B=E5=8A=A8=E7=9C=9F?= =?UTF-8?q?=E8=B7=91**=EF=BC=88TIDY-1=EF=BC=8C=E4=B8=BB=E4=BA=BA=E6=8A=95?= =?UTF-8?q?=E8=AF=89=E7=9A=84=E6=A0=B9=E5=9B=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人实测: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/ 待登记 --- internal/runtime/memory/tidy.go | 36 +- internal/runtime/memory/tidy_apply.go | 452 +++++++++++++++++ internal/runtime/memory/tidy_apply_store.go | 294 +++++++++++ internal/runtime/memory/tidy_apply_test.go | 477 ++++++++++++++++++ internal/runtime/memory/tidy_candidate.go | 24 +- internal/runtime/memory/tidy_config.go | 29 ++ internal/runtime/memory/tidy_dryrun_test.go | 3 +- internal/runtime/memory/tidy_schedule_test.go | 306 +++++++++++ internal/runtime/memory/tidy_schema.go | 56 +- internal/runtime/memory/tidy_service.go | 187 ++++++- internal/runtime/memory/tidy_service_test.go | 17 +- internal/runtime/memory/tidy_store.go | 168 +++++- .../gateway/admin_memory_tidy_apply.go | 336 ++++++++++++ .../gateway/admin_memory_tidy_apply_dto.go | 97 ++++ .../gateway/admin_memory_tidy_apply_test.go | 470 +++++++++++++++++ .../gateway/admin_memory_tidy_e2e_test.go | 188 +++++++ .../services/gateway/admin_memory_tidy_run.go | 100 ++++ internal/services/gateway/routes.go | 12 + 18 files changed, 3181 insertions(+), 71 deletions(-) create mode 100644 internal/runtime/memory/tidy_apply.go create mode 100644 internal/runtime/memory/tidy_apply_store.go create mode 100644 internal/runtime/memory/tidy_apply_test.go create mode 100644 internal/runtime/memory/tidy_schedule_test.go create mode 100644 internal/services/gateway/admin_memory_tidy_apply.go create mode 100644 internal/services/gateway/admin_memory_tidy_apply_dto.go create mode 100644 internal/services/gateway/admin_memory_tidy_apply_test.go create mode 100644 internal/services/gateway/admin_memory_tidy_e2e_test.go create mode 100644 internal/services/gateway/admin_memory_tidy_run.go diff --git a/internal/runtime/memory/tidy.go b/internal/runtime/memory/tidy.go index fd4941d..8892558 100644 --- a/internal/runtime/memory/tidy.go +++ b/internal/runtime/memory/tidy.go @@ -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)]++ + } } } diff --git a/internal/runtime/memory/tidy_apply.go b/internal/runtime/memory/tidy_apply.go new file mode 100644 index 0000000..0e88439 --- /dev/null +++ b/internal/runtime/memory/tidy_apply.go @@ -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 + } +} diff --git a/internal/runtime/memory/tidy_apply_store.go b/internal/runtime/memory/tidy_apply_store.go new file mode 100644 index 0000000..de8d71c --- /dev/null +++ b/internal/runtime/memory/tidy_apply_store.go @@ -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 +} diff --git a/internal/runtime/memory/tidy_apply_test.go b/internal/runtime/memory/tidy_apply_test.go new file mode 100644 index 0000000..2669944 --- /dev/null +++ b/internal/runtime/memory/tidy_apply_test.go @@ -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) +} diff --git a/internal/runtime/memory/tidy_candidate.go b/internal/runtime/memory/tidy_candidate.go index db3bc60..f371ecc 100644 --- a/internal/runtime/memory/tidy_candidate.go +++ b/internal/runtime/memory/tidy_candidate.go @@ -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 diff --git a/internal/runtime/memory/tidy_config.go b/internal/runtime/memory/tidy_config.go index 6047a1d..b1cb688 100644 --- a/internal/runtime/memory/tidy_config.go +++ b/internal/runtime/memory/tidy_config.go @@ -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 +} diff --git a/internal/runtime/memory/tidy_dryrun_test.go b/internal/runtime/memory/tidy_dryrun_test.go index a652743..991a790 100644 --- a/internal/runtime/memory/tidy_dryrun_test.go +++ b/internal/runtime/memory/tidy_dryrun_test.go @@ -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", diff --git a/internal/runtime/memory/tidy_schedule_test.go b/internal/runtime/memory/tidy_schedule_test.go new file mode 100644 index 0000000..2f25a22 --- /dev/null +++ b/internal/runtime/memory/tidy_schedule_test.go @@ -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) + } +} diff --git a/internal/runtime/memory/tidy_schema.go b/internal/runtime/memory/tidy_schema.go index abfa6b7..98e4c20 100644 --- a/internal/runtime/memory/tidy_schema.go +++ b/internal/runtime/memory/tidy_schema.go @@ -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 的整理列前滚到既有表上:列缺失时 diff --git a/internal/runtime/memory/tidy_service.go b/internal/runtime/memory/tidy_service.go index 92f5ca2..b68fc6a 100644 --- a/internal/runtime/memory/tidy_service.go +++ b/internal/runtime/memory/tidy_service.go @@ -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" diff --git a/internal/runtime/memory/tidy_service_test.go b/internal/runtime/memory/tidy_service_test.go index 10a9b14..3988155 100644 --- a/internal/runtime/memory/tidy_service_test.go +++ b/internal/runtime/memory/tidy_service_test.go @@ -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) } } diff --git a/internal/runtime/memory/tidy_store.go b/internal/runtime/memory/tidy_store.go index c449355..ea95c26 100644 --- a/internal/runtime/memory/tidy_store.go +++ b/internal/runtime/memory/tidy_store.go @@ -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) diff --git a/internal/services/gateway/admin_memory_tidy_apply.go b/internal/services/gateway/admin_memory_tidy_apply.go new file mode 100644 index 0000000..156137e --- /dev/null +++ b/internal/services/gateway/admin_memory_tidy_apply.go @@ -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) +} diff --git a/internal/services/gateway/admin_memory_tidy_apply_dto.go b/internal/services/gateway/admin_memory_tidy_apply_dto.go new file mode 100644 index 0000000..f925d25 --- /dev/null +++ b/internal/services/gateway/admin_memory_tidy_apply_dto.go @@ -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"` +} diff --git a/internal/services/gateway/admin_memory_tidy_apply_test.go b/internal/services/gateway/admin_memory_tidy_apply_test.go new file mode 100644 index 0000000..2f5e19b --- /dev/null +++ b/internal/services/gateway/admin_memory_tidy_apply_test.go @@ -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) + } + } +} diff --git a/internal/services/gateway/admin_memory_tidy_e2e_test.go b/internal/services/gateway/admin_memory_tidy_e2e_test.go new file mode 100644 index 0000000..5e71ec4 --- /dev/null +++ b/internal/services/gateway/admin_memory_tidy_e2e_test.go @@ -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) + } +} diff --git a/internal/services/gateway/admin_memory_tidy_run.go b/internal/services/gateway/admin_memory_tidy_run.go new file mode 100644 index 0000000..eb3b6f9 --- /dev/null +++ b/internal/services/gateway/admin_memory_tidy_run.go @@ -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, + } +} diff --git a/internal/services/gateway/routes.go b/internal/services/gateway/routes.go index 01dc583..fb50aa4 100644 --- a/internal/services/gateway/routes.go +++ b/internal/services/gateway/routes.go @@ -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)))