Files
Cyrene_v2/internal/runtime/memory/tidy_apply.go
T
AskaEth 54e32304f2 fix(memory): dedupe_group 补取值门 + 临时层双击复核;闸补 3 类长期事实安全阀(M34 r1 复审)
r1 复审 p1-1(会毁数据):tag_set 簇的安全门只有数值与否定,**专有名词/引号取值不比**,
生产语料实测把 `用户的昵称是「叶酱」。` 并进 `主人的昵称是「喵喵七号」…` 的存活行,
而同库另有 4 行写着「喵喵七号已作废、应称叶酱」⇒ 一次 apply 丢掉当前事实。
修法(结构性,不是补丁):
- 新增取值门 tidy_dedupe_values.go:被吸收行的**引号取值**(「」『』“”‘’")与
  **拉丁 token**(≥2 字母,含 h3R3 这类混排)必须在存活行正文中一个不少;
  方向单向(被吸收 ⊆ 存活),与软合并语义同向(存活行多出的取值不会丢)。
  补齐 design/23 §5.1-3 要求的「专有名词 / 数值 / 否定词」三件套里缺的第一件。
- tag_set 簇也过相似度下限(dedupeGroupMinSimilarity 两档统一)。取值 0.10 而非更高:
  语料实测 tag_set 合法改写(`用户可能需要提醒吃早饭…` 0.047、`叶酱有工作…` 0.109)
  的余弦本来就在 0.05~0.4 —— 抬高它只会删掉合法去重,不会多挡住危险对。
  实测(503 对):0.10→保留 98%、0.20→86%、0.30→63%、0.40→40%、0.50→20%。
- 回归用例:TestDedupeGroupRejectsSameTagDifferentValue(脱敏真实语料 + 同值对照组)、
  TestDedupeGroupLatinTokenIsAlsoAValue、TestDedupeGroupNeverLosesAValue(簇级不变式)、
  TestDedupeGroupTagSetAlsoNeedsSimilarityFloor。去掉取值门 ⇒ 前三条红;把 tag 相似度
  归零 ⇒ 第四条红。

r1 复审 p2-1(数据安全):元信息查询失败时**该分区本轮不分簇**(旧实现降级成 nil map,
`temporary` 恒 false ⇒ 绕开「临时行绝不进簇」红线);apply 侧补 `expires_at` 复核
(候选久置/生成后变临时 ⇒ 整簇 skip,tidySkipTemporaryRow)。用例:
TestDedupeInPartitionSkipsWhenMetaUnavailable、TestTidyApplyDedupeGroupSkipsTemporaryRows。

r1 复审 p2-2(闸误杀被低报):补三类安全阀 ——「以后/从此/往后/今后」= 自此刻起永久成立、
「换成/变成/改成/搬进」= 状态已改变且新状态持续、N7 的进行中标记同样必须句首 12 字内;
「宿舍」进长期事实词表;顺手修 `<=` 的 off-by-one(改为 `<`)。

实测(生产库只读 mode=ro,active 3667):
- dedupe:281 簇/594 行 → **255 簇/505 行(13.8%)**;tag_set 240 / prefix12 15;
  取值门挡 76 对、相似度门挡 12 对;簇大小 2:155 3:48 4:26 5:8 6:7 7:4 8:2 9:2 18:1 20:2
  (两个 20 行簇与 18 行簇的取值集合均为单一「喵喵七号」⇒ 大簇=同一事实的重写)。
- 闸:改前 2 (0.05%) → 改后 **75 (2.05%)**(n6 53 / n7 20 / n1 2);含锚点词的 27 条
  逐条人读均为「约定明天中午见 / 下周留个午后」类到期即失效的安排。
- 达标代价:+目前/现在 当锚点 → 2.13%;再叠一次性事件动词表(曾/发来/分享…)→ 6.74%
  且新增 172 条里 56 条是长期身份/里程碑(「i love you 文件」17 条、「海报就是助手本人」
  10 条…)⇒ 仍到不了 8%,不许为凑区间放宽。

验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
2026-10-25 00:29:02 +08:00

454 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"context"
"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,绝不假装做过。
// 🔴 2026-10-07 起规则层**不再产出** link 候选(判 keep,见 tidy_rules.go);
// 这条分支留着是给**库里既有的** link 行用:它们仍要能被如实判为「不可应用」,
// 并由 RejectTidyCandidatesByAction 批量拒绝(tidy_reject.go)。
//
// 作用域:应用只在候选自带的分区(persona_id + scope + project_id)内改行;
// 候选与目标行的分区不一致时拒做(纵深防御 design/23 §5.2-7/8)。受限来源
// (群聊/陌生人)同样三类动作一律拒做(tidySkipSourceRestricted,design/23 §5.2-9/§6)。
// 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"
// tidySkipSourceRestricted 表示候选指向的记忆行来自群聊/陌生人等**只读**来源
// (design/23 §5.2-9 + §6)。规则层本就把这类条目排除在扫描外,这里是纵深防御:
// 候选行是落库数据,可能来自更早的规则版本或手工改动,而「整理不得改写这类来源」
// 是红线而不是权衡 —— 宁可如实报 skipped,也不动那一行。
tidySkipSourceRestricted = "restricted_source"
// tidySkipSourceMissing 表示候选指向的记忆行不存在。
tidySkipSourceMissing = "source_missing"
// tidySkipPartitionMismatch 表示候选的分区与它指向的行不一致。
tidySkipPartitionMismatch = "partition_mismatch"
// tidySkipInvalidCandidate 表示候选形状不完整(缺 target / 缺 source / kind 非法)。
tidySkipInvalidCandidate = "invalid_candidate"
// tidySkipTemporaryRow 表示簇里有行在**临时层**(`expires_at` 非空):
// 临时行 7 天后会被后台物理删除(design/23 §19),既不许当存活行(会让被吸收行
// 指向一个不存在的 id),也不值得当被吸收行(它本来就要消失)。整簇原子的 skip。
tidySkipTemporaryRow = "temporary_row"
)
// 整理应用侧的哨兵错误。与 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"`
// Deduped 是成功落地的 `dedupe_group` 簇数(2026-10-24)。它与 Merged 分开:
// 「一对一并进」与「一簇 N 条并进」在面板上是两件事(后者一次动多行)。
Deduped int `json:"deduped"`
// RowsAffected 是本次真的被改动的**记忆行数**(merge/archive/reclassify 各 1 行,
// dedupe_group 是簇里的被吸收行数)。行预算 `maxTidyApplyRowsPerBatch` 按它计,
// 因此这个数字是「这一次点击改了多少行记忆」的直接回答。
RowsAffected int `json:"rowsAffected"`
}
// 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;2026-10-07 起 link 也不再产出,
// 这里兜的是库里既有的 link 行):如实说「不可应用」,不写任何行。
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)
case TidyDedupeGroup:
return s.applyTidyDedupeGroup(ctx, cand, opts, now)
default:
return skipTidyApply(base, tidySkipActionNotApplyable), nil
}
}
// applyable 报告一个动作是否有执行语义。link/keep **永远**返回 false:
// 2026-10-07 起规则层已不产 link(无落点,见 tidy_rules.go),这条判据只服务
// 库里既有的历史行与「不许假装做过」的诚实口径。
func (a TidyAction) applyable() bool {
switch a {
case TidyMerge, TidyArchive, TidyReclassify, TidyDedupeGroup:
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
}
if nonOwnerSource(item.SourceConversation) {
// 只读来源(群聊/陌生人)绝不归档(design/23 §5.2-9/§6):纵深防御,
// 规则层本就把这类条目排除在扫描范围外。
return skipTidyApply(base, tidySkipSourceRestricted), 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。
//
// 边界(2026-10-07 纳入自动档时写死):它不能改正文、不能改作用域、不能碰第二条行。
// 候选上的 ProposedContent / MergedContent 之类正文字段**一律不采纳** —— 落地语句
// 只写 kind/updated_at(reclassifyTidyItemSQL),因此即使候选是手工塞进来的畸形行,
// 也只会得到一次分类修正,不会有任何一个字节的正文被写入。
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 nonOwnerSource(item.SourceConversation) {
// 只读来源(群聊/陌生人)绝不改(design/23 §5.2-9/§6):纵深防御,规则层本就
// 把这类条目排除在扫描范围外。archive/merge 早就有这道门,reclassify 在
// 2026-10-07 纳入自动档时必须补上同一道 —— 三类动作的只读来源口径必须一致,
// 否则「自动档只碰主人自己的记忆」这句话就不成立。
return skipTidyApply(base, tidySkipSourceRestricted), 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
}
// 只读来源硬门(design/23 §5.2-9/§6):两侧任一来自群聊/陌生人都不合。
// 规则层已把这类条目排除在扫描外,这里是纵深防御。
if nonOwnerSource(absorbed.SourceConversation) || nonOwnerSource(survivor.SourceConversation) {
return MemoryItem{}, MemoryItem{}, tidySkipSourceRestricted, 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)),
}
rows := 0
for _, cand := range cands {
if err := ctx.Err(); err != nil {
// 上下文取消时**已应用的保持已应用**,未做的留给下一次(与清扫同口径)。
break
}
// 按行计的批量上限(2026-10-24):一条 dedupe_group 候选会改 N 行,只按候选
// 条数限批会让「一次点击改上千行记忆」。超限时**如实标 truncated** 并停下,
// 剩下的留给下一次点击(与扫描/候选上限同一口径:绝不静默少做)。
if cost := tidyApplyRowCost(cand); rows+cost > maxTidyApplyRowsPerBatch {
batch.Truncated = true
break
} else {
rows += cost
}
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)
batch.RowsAffected += len(res.AffectedIDs)
tallyTidyApply(&batch, res)
}
if len(batch.Results) < len(cands) {
// 循环被行预算或 ctx 打断:`len(cands)` 之内还有未处理项 ⇒ 不完整。
batch.Truncated = true
}
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
}