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

主人实测:memory_items 1191 行、候选 77 条全 pending、memory_tidy_runs 仅 1 行、两晚定时没跑。
根因两条(都在这片里修掉):
① **以前根本没有执行面** —— 只有只读试算,候选永远 pending
② **调度被重启饿死** —— interval=24h 的 ticker 从进程启动算起,两天重启十几次 ⇒ 24h 永远走不完

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

No files matched your search

+452
View File
@@ -0,0 +1,452 @@
package memory
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
// 本文件是记忆整理的**应用路径**(切片 TIDY-1;design/23 §7/§8 的执行面)。
//
// 动因是一条实测口径:整理只产候选、没有任何执行面,于是 77 条候选全 pending、
// 两天一条没应用。本文件把候选真正落地,五条不可走样的口径:
//
// 1. **只走软动作**:merge = 软合并(被吸收行 status='merged' + merged_into=存活
// id;存活行 merged_from/merge_count/updated_at 更新),archive = 软删
// (status='archived'),reclassify = 改 kind。**绝不改任何一行的 content**、
// **绝不物理删除**(design/23 §8:行保留 ⇒ 可 revert);
// 2. **幂等**:候选状态的条件更新(`… WHERE id=? AND status='pending'` +
// RowsAffected)是执行闸,记忆行的 `AND status='active'` 条件更新是第二道闸
// —— 两道都在同一事务里,重复应用同一条候选不产生第二次副作用;
// 3. **可 revert**:RevertTidyCandidate 把 merge 的被吸收行复原为 active、把
// archive 的归档行复原、把 reclassify 的 kind 还原,并把候选退回 pending;
// 4. **失败隔离**:单条候选失败只进 results(failed),批量里的其余条照跑
// (design/23 §9 的失败隔离同口径);
// 5. **动作闭集**:link / keep 是**只读建议**(design/23 §14:link 的存储形态未定),
// 因此不可应用 —— 应用它只会得到 skipped/action_not_applyable,绝不假装做过。
//
// 作用域:应用只在候选自带的分区(persona_id + scope + project_id)内改行;
// 候选与目标行的分区不一致时拒做(纵深防御 design/23 §5.2-7/8)。
// TidyApplyStatus 是一条候选应用结果的状态类别。
type TidyApplyStatus string
const (
// TidyApplyApplied 表示这次调用真的改了记忆(或真的撤销了改动)。
TidyApplyApplied TidyApplyStatus = "applied"
// TidyApplySkipped 表示没有改动,且**不是错误**:幂等命中(已决定)、
// 动作不可应用、候选形状不完整。
TidyApplySkipped TidyApplyStatus = "skipped"
// TidyApplyFailed 表示尝试过并失败。它不影响批量里的其它候选。
TidyApplyFailed TidyApplyStatus = "failed"
)
// 跳过的**机器可读**原因(面板按它聚合,不必解析中文文案)。
const (
// tidySkipAlreadyDecided 表示这条候选已被决定过 —— 重复应用/重复回滚的幂等口径。
tidySkipAlreadyDecided = "already_decided"
// tidySkipNotApplied 表示回滚时这条候选并不在已应用状态。
tidySkipNotApplied = "not_applied"
// tidySkipActionNotApplyable 表示该动作没有执行语义(link/keep)。
tidySkipActionNotApplyable = "action_not_applyable"
// tidySkipSourceNotActive 表示候选指向的记忆行已不在册(已被别的动作改过)。
tidySkipSourceNotActive = "source_not_active"
// tidySkipSourceMissing 表示候选指向的记忆行不存在。
tidySkipSourceMissing = "source_missing"
// tidySkipPartitionMismatch 表示候选的分区与它指向的行不一致。
tidySkipPartitionMismatch = "partition_mismatch"
// tidySkipInvalidCandidate 表示候选形状不完整(缺 target / 缺 source / kind 非法)。
tidySkipInvalidCandidate = "invalid_candidate"
)
// 整理应用侧的哨兵错误。与 memory 包其余错误同一口径:调用点一律 %w 包裹,
// 调用方用 errors.Is 判断。
var (
// ErrTidyCandidateNotFound 在按 id 操作一条不存在的候选时返回。
ErrTidyCandidateNotFound = errors.New("memory: tidy candidate not found")
// ErrTidyRunRecordNotFound 在按 run 批量应用而该轮**留痕行**不存在时返回 ——
// 拿一个拼错的 runId 来时,必须是「查不到这一轮」,不是「这一轮没有待定候选」。
// (与 ErrTidyRunNotFound 区分:那是异步试算的**进程内存**句柄,两者不是一回事。)
ErrTidyRunRecordNotFound = errors.New("memory: tidy run record not found")
// errTidyCandidateDecided 是事务内的幂等哨兵:候选状态的条件更新没命中,
// 说明它已被决定过。它不越过本包(调用方看到的是 skipped/already_decided)。
errTidyCandidateDecided = errors.New("memory/tidy: candidate already decided")
// errTidyItemNotActive 是事务内的第二道闸哨兵:目标记忆行已不在册
// (已被别的动作改过)。同样不越过本包。
errTidyItemNotActive = errors.New("memory/tidy: item is not active")
)
// TidyApplyOptions 是一次应用/回滚的请求口径。
type TidyApplyOptions struct {
// Actor 是决定者标签,落 memory_tidy_candidates.decided_by。
Actor string
// Limit 是**批量应用**的条数上限(非正取 defaultTidyApplyBatchLimit,
// 超过 maxTidyApplyBatchLimit 时钳到它)。单条应用忽略它。
Limit int
}
// normalizedLimit 归一批量上限:非正取默认值、超上限钳位(与既有查询面的
// limit 约定一致:钳位而不是报错)。
func (o TidyApplyOptions) normalizedLimit() int {
if o.Limit <= 0 {
return defaultTidyApplyBatchLimit
}
if o.Limit > maxTidyApplyBatchLimit {
return maxTidyApplyBatchLimit
}
return o.Limit
}
// TidyApplyResult 是一条候选的应用/回滚结果。
type TidyApplyResult struct {
CandidateID string `json:"candidateId"`
Action TidyAction `json:"action"`
Status TidyApplyStatus `json:"status"`
Reason string `json:"reason,omitempty"`
TargetID string `json:"targetId,omitempty"`
// AffectedIDs 是这次真的被改动的 memory_items id(merge=被吸收行;
// archive=被归档行;reclassify=被改 kind 的行)。它既是回滚的抓手,
// 也是「软删除后总行数不变」这条断言的证据面。
AffectedIDs []string `json:"affectedIds"`
// Error 是 failed 时的原因(面向运维;网关侧只记日志,不把原始正文上线)。
Error string `json:"error,omitempty"`
}
// TidyApplyBatch 是一次批量应用的回执。Total 是命中筛选的**真总数**(不是
// len(Results)),Truncated 报告「待定候选比上限多」—— 与扫描/候选上限的
// 诚实口径一致(超限绝不静默)。
type TidyApplyBatch struct {
RunID string `json:"runId"`
Limit int `json:"limit"`
Total int `json:"total"`
Truncated bool `json:"truncated"`
Results []TidyApplyResult `json:"results"`
Applied int `json:"applied"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
// Merged / Archived / Reclassified 是**成功应用**的候选按动作分解
// (skipped 的不计)—— 「新增/合并/软删各多少」这条回执的领域来源。
Merged int `json:"merged"`
Archived int `json:"archived"`
Reclassified int `json:"reclassified"`
}
// ApplyTidyCandidate 应用**一条**候选(design/23 §11 的 apply 粒度之一)。
// 找不到候选返回 ErrTidyCandidateNotFound;已决定/不可应用/形状不完整都以
// TidyApplySkipped 结果如实作答(不是错误 —— 幂等命中不该是错误)。
func (s *Store) ApplyTidyCandidate(ctx context.Context, id string, opts TidyApplyOptions) (TidyApplyResult, error) {
cand, err := s.GetTidyCandidate(ctx, id)
if err != nil {
return TidyApplyResult{}, err
}
return s.applyCandidate(ctx, cand, opts)
}
// applyCandidate 是单条应用的真实实现(批量路径也走它,保证两种粒度同一套语义)。
func (s *Store) applyCandidate(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions) (TidyApplyResult, error) {
base := TidyApplyResult{
CandidateID: cand.ID,
Action: cand.Action,
TargetID: cand.TargetID,
AffectedIDs: []string{},
}
if !cand.Action.applyable() {
// link / keep 没有执行语义(design/23 §14):如实说「不可应用」,不写任何行。
return skipTidyApply(base, tidySkipActionNotApplyable), nil
}
now := time.Now().UTC()
switch cand.Action {
case TidyMerge:
return s.applyTidyMerge(ctx, cand, opts, now)
case TidyArchive:
return s.applyTidyArchive(ctx, cand, opts, now)
case TidyReclassify:
return s.applyTidyReclassify(ctx, cand, opts, now)
default:
return skipTidyApply(base, tidySkipActionNotApplyable), nil
}
}
// applyable 报告一个动作是否有执行语义。
func (a TidyAction) applyable() bool {
switch a {
case TidyMerge, TidyArchive, TidyReclassify:
return true
default:
return false
}
}
// applyTidyMerge 把候选指认的「被吸收行」软合并进「存活行」。
func (s *Store) applyTidyMerge(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) {
base := TidyApplyResult{
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID, AffectedIDs: []string{},
}
if len(cand.SourceIDs) != 1 || strings.TrimSpace(cand.TargetID) == "" {
return skipTidyApply(base, tidySkipInvalidCandidate), nil
}
absorbed, survivor, skip, err := s.loadTidyMergeRows(ctx, cand)
if err != nil {
return base, err
}
if skip != "" {
return skipTidyApply(base, skip), nil
}
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
if err := markTidyAbsorbed(ctx, tx, absorbed.ID, survivor.ID, now); err != nil {
return err
}
return bumpSurvivor(ctx, tx, survivor, absorbed, now)
})
return finishTidyApply(base, []string{absorbed.ID}, err, tidySkipSourceNotActive)
}
// applyTidyArchive 软删一条一次性事件(status='archived'):行保留、内容不改,
// 只是不再参与检索与整理(design/23 §10 的 status 列 + §8 的软删除口径)。
func (s *Store) applyTidyArchive(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) {
base := TidyApplyResult{
CandidateID: cand.ID, Action: cand.Action, AffectedIDs: []string{},
}
if len(cand.SourceIDs) != 1 {
return skipTidyApply(base, tidySkipInvalidCandidate), nil
}
item, err := s.Get(ctx, cand.SourceIDs[0])
if errors.Is(err, ErrNotFound) {
return skipTidyApply(base, tidySkipSourceMissing), nil
}
if err != nil {
return base, err
}
if skip := tidyPartitionMismatch(cand, item); skip != "" {
return skipTidyApply(base, skip), nil
}
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
return archiveTidyItem(ctx, tx, item.ID, now)
})
return finishTidyApply(base, []string{item.ID}, err, tidySkipSourceNotActive)
}
// applyTidyReclassify 修正 kind 误分类(design/23 §5.1 T0-6):只改 kind 一列,
// 建议值来自候选证据里的 ProposedKind。
func (s *Store) applyTidyReclassify(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) {
base := TidyApplyResult{
CandidateID: cand.ID, Action: cand.Action, AffectedIDs: []string{},
}
if len(cand.SourceIDs) != 1 {
return skipTidyApply(base, tidySkipInvalidCandidate), nil
}
kind := Kind(cand.Evidence.ProposedKind)
if !kind.valid() {
return skipTidyApply(base, tidySkipInvalidCandidate), nil
}
item, err := s.Get(ctx, cand.SourceIDs[0])
if errors.Is(err, ErrNotFound) {
return skipTidyApply(base, tidySkipSourceMissing), nil
}
if err != nil {
return base, err
}
if skip := tidyPartitionMismatch(cand, item); skip != "" {
return skipTidyApply(base, skip), nil
}
if item.Kind == kind {
// 已经是建议值:没有可改的,如实说「源行不在册」的口径不适用,这里
// 用 already_decided 更像「无需再做」。
return skipTidyApply(base, tidySkipAlreadyDecided), nil
}
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
return reclassifyTidyItem(ctx, tx, item.ID, kind, now)
})
return finishTidyApply(base, []string{item.ID}, err, tidySkipSourceNotActive)
}
// loadTidyMergeRows 读取软合并的两行并做分区硬门校验。skip 非空表示应如实跳过
// (原因由它给出),此时两行不可用。
func (s *Store) loadTidyMergeRows(ctx context.Context, cand TidyCandidate) (MemoryItem, MemoryItem, string, error) {
absorbed, err := s.Get(ctx, cand.SourceIDs[0])
if errors.Is(err, ErrNotFound) {
return MemoryItem{}, MemoryItem{}, tidySkipSourceMissing, nil
}
if err != nil {
return MemoryItem{}, MemoryItem{}, "", err
}
survivor, err := s.Get(ctx, cand.TargetID)
if errors.Is(err, ErrNotFound) {
return MemoryItem{}, MemoryItem{}, tidySkipSourceMissing, nil
}
if err != nil {
return MemoryItem{}, MemoryItem{}, "", err
}
if skip := tidyPartitionMismatch(cand, absorbed); skip != "" {
return MemoryItem{}, MemoryItem{}, skip, nil
}
if skip := tidyPartitionMismatch(cand, survivor); skip != "" {
return MemoryItem{}, MemoryItem{}, skip, nil
}
if absorbed.ID == survivor.ID {
return MemoryItem{}, MemoryItem{}, tidySkipInvalidCandidate, nil
}
return absorbed, survivor, "", nil
}
// tidyPartitionMismatch 报告候选与一条记忆是否不同分区(是 ⇒ 返回不匹配原因)。
// 分区键是 design/23 §5.2-7/8 的硬门,应用侧必须再核一次:候选行是落库数据,
// 而落库数据可能来自更早的规则版本或手工改动。
func tidyPartitionMismatch(cand TidyCandidate, item MemoryItem) string {
if cand.PersonaID != item.PersonaID || cand.Scope != item.Scope || cand.ProjectID != item.ProjectID {
return tidySkipPartitionMismatch
}
return ""
}
// RevertTidyCandidate 撤销一条**已应用**的候选(design/23 §8 的回滚 + §11 的
// candidates/{id}/revert)。它把记忆行复原并把候选退回 pending(于是可以重新决定),
// 全程不删任何一行。候选不在已应用状态时返回 skipped/not_applied。
func (s *Store) RevertTidyCandidate(ctx context.Context, id string, opts TidyApplyOptions) (TidyApplyResult, error) {
cand, err := s.GetTidyCandidate(ctx, id)
if err != nil {
return TidyApplyResult{}, err
}
base := TidyApplyResult{
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID, AffectedIDs: []string{},
}
if !cand.Action.applyable() {
return skipTidyApply(base, tidySkipActionNotApplyable), nil
}
if cand.Status != tidyCandidateApplied {
return skipTidyApply(base, tidySkipNotApplied), nil
}
now := time.Now().UTC()
var reverted []string
err = s.tidyRevertTx(ctx, cand, opts, func(ctx context.Context, tx *sql.Tx) error {
out, err := revertTidyAction(ctx, tx, cand, now)
reverted = out
return err
})
if errors.Is(err, errTidyCandidateDecided) {
return skipTidyApply(base, tidySkipNotApplied), nil
}
if err != nil {
return tidyFailed(base, err), nil
}
base.Status = TidyApplyApplied
base.AffectedIDs = reverted
return base, nil
}
// ApplyTidyCandidates 批量应用待定候选(design/23 §11 的 apply 粒度之二/之三):
// runID 非空 ⇒ 只应用该轮产出的候选(「按 run 批量应用」);runID 为空 ⇒
// 应用全部待定候选(「全部待定应用」)。上限由 opts.Limit 决定,超限如实回报
// Truncated + Total(绝不静默少应用)。
//
// 逐条结果都在 Results 里(失败隔离:一条失败不阻塞其余)。
func (s *Store) ApplyTidyCandidates(ctx context.Context, runID string, opts TidyApplyOptions) (TidyApplyBatch, error) {
limit := opts.normalizedLimit()
if strings.TrimSpace(runID) != "" {
if _, err := s.GetTidyRun(ctx, runID); err != nil {
return TidyApplyBatch{}, fmt.Errorf("memory/tidy: apply by run %s: %w", runID, err)
}
}
total, err := s.countTidyCandidates(ctx, tidyCandidatePending, runID)
if err != nil {
return TidyApplyBatch{}, err
}
cands, err := s.listTidyCandidates(ctx, tidyCandidatePending, runID, limit)
if err != nil {
return TidyApplyBatch{}, err
}
batch := TidyApplyBatch{
RunID: runID,
Limit: limit,
Total: total,
Truncated: total > len(cands),
Results: make([]TidyApplyResult, 0, len(cands)),
}
for _, cand := range cands {
if err := ctx.Err(); err != nil {
// 上下文取消时**已应用的保持已应用**,未做的留给下一次(与清扫同口径)。
break
}
res, err := s.applyCandidate(ctx, cand, opts)
if err != nil {
res = tidyFailed(TidyApplyResult{
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID,
AffectedIDs: []string{},
}, err)
}
batch.Results = append(batch.Results, res)
tallyTidyApply(&batch, res)
}
if batch.RunID != "" && batch.Applied > 0 {
// 该轮的 applied 计数累加(design/23 §10 的 memory_tidy_runs.applied):
// 运行时那一轮写 0(当轮绝不自动应用),事后应用在这里补记。
if err := s.addTidyRunApplied(ctx, batch.RunID, batch.Applied); err != nil {
return batch, err
}
}
return batch, nil
}
// tallyTidyApply 把一条结果计入批量回执的计数器。
func tallyTidyApply(batch *TidyApplyBatch, res TidyApplyResult) {
switch res.Status {
case TidyApplyApplied:
batch.Applied++
switch res.Action {
case TidyMerge:
batch.Merged++
case TidyArchive:
batch.Archived++
case TidyReclassify:
batch.Reclassified++
}
case TidyApplyFailed:
batch.Failed++
default:
batch.Skipped++
}
}
// skipTidyApply 造一条 skipped 结果。
func skipTidyApply(base TidyApplyResult, reason string) TidyApplyResult {
base.Status = TidyApplySkipped
base.Reason = reason
if base.AffectedIDs == nil {
base.AffectedIDs = []string{}
}
return base
}
// tidyFailed 造一条 failed 结果(错误只进 Error 字段,调用方决定记不记日志)。
func tidyFailed(base TidyApplyResult, err error) TidyApplyResult {
base.Status = TidyApplyFailed
base.Reason = "apply_failed"
base.Error = err.Error()
if base.AffectedIDs == nil {
base.AffectedIDs = []string{}
}
return base
}
// finishTidyApply 把事务结果收敛成一条结果:nil ⇒ applied;幂等哨兵 ⇒ skipped;
// 目标行不在册 ⇒ skipped(原因由 notActiveReason 给);其余 ⇒ failed。
//
// affected 是**成功时才会挂上去**的受影响行 id:跳过的结果绝不能声称改过哪一行
// (那是把「没做」说成「做了」)。
func finishTidyApply(base TidyApplyResult, affected []string, err error, notActiveReason string) (TidyApplyResult, error) {
switch {
case err == nil:
base.Status = TidyApplyApplied
base.AffectedIDs = affected
return base, nil
case errors.Is(err, errTidyCandidateDecided):
return skipTidyApply(base, tidySkipAlreadyDecided), nil
case errors.Is(err, errTidyItemNotActive):
return skipTidyApply(base, notActiveReason), nil
default:
return tidyFailed(base, err), nil
}
}