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

@@ -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,
}
}