Files
Cyrene_v2/internal/runtime/memory/tidy_apply.go
T
AskaEth 454ddb0b70
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 记忆整理**应用路径 + 调度修复 + 手动真跑**(TIDY-1,主人投诉的根因)
主人实测:memory_items 1191 行、候选 77 条全 pending、memory_tidy_runs 仅 1 行、两晚定时没跑。
根因两条(都在这片里修掉):
① **以前根本没有执行面** —— 只有只读试算,候选永远 pending
② **调度被重启饿死** —— interval=24h 的 ticker 从进程启动算起,两天重启十几次 ⇒ 24h 永远走不完

- 应用路径:逐条 / 按 run 批量(默认 100、硬顶 500、超限 truncated)/ revert;**软合并**
  (status + merged_into/merged_from/merge_count,**绝不改 content、行不删**)+ 审计
  (memory.tidy.apply / memory.tidy.revert)+ **幂等**(候选状态条件更新 + 行 status 闸两道)
- 调度:改成**到点即跑**(catchUp:无历史+窗口内 / 距上次 run ≥ interval / 窗口起点晚于上次 run),
  「上次 run」以 memory_tidy_runs 为**持久事实**;注入时钟用例证明**重启不饿死**
- 手动真跑端点 POST /admin/memories/tidy/run(同档鉴权 + 审计 + 回执:新增/合并/软删/耗时)
- 候选表加 run_id 列 + 索引(前滚;顺序=建表→补列→建索引,否则既有库会 no such column)
- 3 个变异自检(删 catchUp / 去掉两道 status 闸 / truncated 恒 false)
⚠️ 生产库零动作:1191 行真跑与 77 条候选应用**未执行**(按红线由主人确认后主会话执行)
⚠️ 契约段在报告里(candidates 列表/apply/revert/tidy.run),Docs/ 待登记
2026-10-06 22:39:35 +08:00

453 lines
18 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,绝不假装做过。
//
// 作用域:应用只在候选自带的分区(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
}
}