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

+29
View File
@@ -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
}